diff --git a/.claude/learnings/docker-dns-casing-2025.md b/.claude/learnings/docker-dns-casing-2025.md new file mode 100644 index 0000000000..b639a00461 --- /dev/null +++ b/.claude/learnings/docker-dns-casing-2025.md @@ -0,0 +1,44 @@ +# Docker DNS Case Sensitivity + +**Date:** 2025-12-22 +**Source:** PR #345 CodeRabbit review + +## Issue + +Mixed hostname casing in docker-compose.yml caused inconsistent DNS resolution: +- `supabase_kong_PMOVES.AI` (uppercase) +- `supabase_kong_pmoves.ai` (lowercase) + +Docker DNS behavior varies by configuration - some resolve case-insensitively, others don't. + +## Pattern + +**Always use lowercase for Docker hostnames.** + +```yaml +# Bad - inconsistent casing +- SUPABASE_URL=http://supabase_kong_PMOVES.AI:8000 +- SUPABASE_REALTIME_URL=ws://supabase_kong_PMOVES.AI:8000/realtime/v1 + +# Good - consistent lowercase +- SUPABASE_URL=http://supabase_kong_pmoves.ai:8000 +- SUPABASE_REALTIME_URL=ws://supabase_kong_pmoves.ai:8000/realtime/v1 +``` + +## Detection + +```bash +# Find mixed casing in docker-compose files +grep -n "supabase_kong" pmoves/docker-compose.yml | sort -u +``` + +## Fix + +Use `replace_all` to normalize: +```bash +sed -i 's/supabase_kong_PMOVES\.AI/supabase_kong_pmoves.ai/g' pmoves/docker-compose.yml +``` + +## Related + +- `.claude/learnings/docker-entrypoint-patterns-2025.md` diff --git a/.claude/learnings/dockerfile-permissions-2025.md b/.claude/learnings/dockerfile-permissions-2025.md new file mode 100644 index 0000000000..e55d0d9b61 --- /dev/null +++ b/.claude/learnings/dockerfile-permissions-2025.md @@ -0,0 +1,60 @@ +# Dockerfile Non-Root Container Permissions + +**Date:** 2025-12-22 +**Source:** PR #345 CodeRabbit review + +## Issue + +Dockerfile hardening added user creation but missed creating directories +needed by entrypoint scripts: +- entrypoint.sh writes to `/models` via `snapshot_download()` +- Directory doesn't exist or has wrong ownership + +## Pattern + +**Create and chown all directories written to at runtime BEFORE USER directive:** + +```dockerfile +# Security: Run as non-root user +RUN groupadd -r pmoves --gid=65532 && \ + useradd -r -g pmoves --uid=65532 --home-dir=/app --shell=/sbin/nologin pmoves && \ + mkdir -p /models && \ + chown -R pmoves:pmoves /app /app/vibevoice /models + +USER pmoves +``` + +## Checklist for Dockerfile Hardening + +1. **Identify write paths** - grep entrypoint/startup scripts for: + - File writes + - `mkdir` commands + - Model downloads (HuggingFace, etc.) + - Log directories + - Cache directories + +2. **Create directories** - Add `mkdir -p` for each path + +3. **Set ownership** - Add to `chown -R pmoves:pmoves` list + +4. **Order matters** - All `mkdir` and `chown` must be BEFORE `USER pmoves` + +## Detection + +```bash +# Find write operations in entrypoint scripts +grep -E "mkdir|>|>>|download|cache" services/*/entrypoint.sh +``` + +## Anti-Pattern + +```dockerfile +# Bad - USER before mkdir means mkdir runs as non-root +USER pmoves +RUN mkdir -p /models # Permission denied! +``` + +## Related + +- `.claude/learnings/docker-entrypoint-patterns-2025.md` +- PMOVES Dockerfile hardening standard (UID/GID 65532) diff --git a/.claude/learnings/nextjs-env-vars-2025.md b/.claude/learnings/nextjs-env-vars-2025.md new file mode 100644 index 0000000000..8945f5af76 --- /dev/null +++ b/.claude/learnings/nextjs-env-vars-2025.md @@ -0,0 +1,46 @@ +# Next.js NEXT_PUBLIC Environment Variables + +**Date:** 2025-12-22 +**Source:** PR #345 CodeRabbit review + +## Issue + +`NEXT_PUBLIC_*` env vars were set to internal Docker hostnames: +```yaml +- NEXT_PUBLIC_FLUTE_GATEWAY_URL=http://flute-gateway:8055 +- NEXT_PUBLIC_FLUTE_WS_URL=ws://flute-gateway:8056 +``` + +These hostnames resolve inside Docker network but **fail in browser** because: +1. `NEXT_PUBLIC_*` vars are embedded at **build time** +2. Browser JavaScript runs **client-side**, outside Docker network +3. Browser cannot resolve internal Docker DNS names + +## Pattern + +**Use env var overrides with localhost fallback for client-side URLs:** + +```yaml +# Good - allows override, defaults to host-accessible localhost +- NEXT_PUBLIC_FLUTE_GATEWAY_URL=${NEXT_PUBLIC_FLUTE_GATEWAY_URL:-http://localhost:8055} +- NEXT_PUBLIC_FLUTE_WS_URL=${NEXT_PUBLIC_FLUTE_WS_URL:-ws://localhost:8056} +``` + +## Server-Side vs Client-Side + +| Variable | Scope | Can use Docker hostname? | +|----------|-------|-------------------------| +| `FLUTE_GATEWAY_URL` | Server-side | Yes | +| `NEXT_PUBLIC_FLUTE_GATEWAY_URL` | Client-side | No - use localhost | + +## Detection + +```bash +# Find NEXT_PUBLIC vars with Docker hostnames +grep "NEXT_PUBLIC.*http://[a-z-]*:" pmoves/docker-compose.yml +``` + +## Related + +- Next.js documentation on environment variables +- `.claude/context/flute-gateway.md` diff --git a/.claude/learnings/prometheus-registry-2025.md b/.claude/learnings/prometheus-registry-2025.md new file mode 100644 index 0000000000..00b0894688 --- /dev/null +++ b/.claude/learnings/prometheus-registry-2025.md @@ -0,0 +1,53 @@ +# Prometheus Registry Double-Registration + +**Date:** 2025-12-22 +**Source:** PR #345 CodeRabbit review + +## Issue + +Direct access to `REGISTRY._names_to_collectors` is fragile: +1. Private API may change across prometheus_client versions +2. No type validation if metric with same name but different type exists +3. No labelnames validation + +## Pattern + +**Use try/except with type validation instead of direct private API access:** + +```python +from prometheus_client import Counter, REGISTRY + +def _get_or_create_counter(name: str, description: str, labelnames: tuple) -> Counter: + """Get existing counter or create new one (safe for module reimport via -m flag).""" + try: + return Counter(name, description, labelnames=labelnames) + except ValueError as e: + if "Duplicated timeseries" in str(e): + # Metric already exists; retrieve and validate type + existing = REGISTRY._names_to_collectors.get(name) + if existing is not None and isinstance(existing, Counter): + return existing + raise TypeError( + f"Metric '{name}' exists but is {type(existing).__name__}, not Counter" + ) from e + raise +``` + +## Why This Matters + +The `python -m` flag can cause double module import, registering metrics twice. +This helper safely handles the duplicate registration case while validating types. + +## Anti-Pattern + +```python +# Bad - no type validation, silent bugs if metric type differs +if name in REGISTRY._names_to_collectors: + return REGISTRY._names_to_collectors[name] +return Counter(name, description, labelnames=labelnames) +``` + +## Related + +- prometheus_client library documentation +- Python `-m` flag double-import behavior diff --git a/pmoves/docker-compose.yml b/pmoves/docker-compose.yml index 95bd985e22..99d953a3d9 100644 --- a/pmoves/docker-compose.yml +++ b/pmoves/docker-compose.yml @@ -418,6 +418,56 @@ services: extra_hosts: - "host.docker.internal:host-gateway" + # PMOVES.AI Unified Portal - Main Dashboard + pmoves-ui: + build: + context: ./ui + dockerfile: Dockerfile + restart: unless-stopped + env_file: [ env.shared.generated, env.shared, .env.generated, .env.local ] + environment: + - NODE_ENV=production + # Supabase + - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:8000} + - NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} + - NEXT_PUBLIC_SUPABASE_REST_URL=${NEXT_PUBLIC_SUPABASE_REST_URL:-http://localhost:3010} + # Voice/TTS + - NEXT_PUBLIC_FLUTE_GATEWAY_URL=${NEXT_PUBLIC_FLUTE_GATEWAY_URL:-http://localhost:8055} + - NEXT_PUBLIC_FLUTE_WS_URL=${NEXT_PUBLIC_FLUTE_WS_URL:-ws://localhost:8056} + # Agent Services (client-side needs localhost) + - NEXT_PUBLIC_AGENT_ZERO_URL=${NEXT_PUBLIC_AGENT_ZERO_URL:-http://localhost:8080} + - NEXT_PUBLIC_AGENT_ZERO_UI_URL=${NEXT_PUBLIC_AGENT_ZERO_UI_URL:-http://localhost:8081} + - NEXT_PUBLIC_ARCHON_URL=${NEXT_PUBLIC_ARCHON_URL:-http://localhost:8091} + - NEXT_PUBLIC_ARCHON_UI_URL=${NEXT_PUBLIC_ARCHON_UI_URL:-http://localhost:3737} + # Knowledge & Search + - NEXT_PUBLIC_HIRAG_URL=${NEXT_PUBLIC_HIRAG_URL:-http://localhost:8086} + - NEXT_PUBLIC_OPEN_NOTEBOOK_API_URL=${NEXT_PUBLIC_OPEN_NOTEBOOK_API_URL:-http://localhost:8503} + - NEXT_PUBLIC_TENSORZERO_UI_URL=${NEXT_PUBLIC_TENSORZERO_UI_URL:-http://localhost:4000} + # Media & Ingestion + - NEXT_PUBLIC_PRESIGN_URL=${NEXT_PUBLIC_PRESIGN_URL:-http://localhost:8088} + - NEXT_PUBLIC_PMOVES_YT_BASE_URL=${NEXT_PUBLIC_PMOVES_YT_BASE_URL:-http://localhost:8077} + - NEXT_PUBLIC_CHANNEL_MONITOR_STATS_URL=${NEXT_PUBLIC_CHANNEL_MONITOR_STATS_URL:-http://localhost:8097/api/monitor/stats} + # Monitoring Stack + - NEXT_PUBLIC_GRAFANA_URL=${NEXT_PUBLIC_GRAFANA_URL:-http://localhost:3002} + - NEXT_PUBLIC_PROMETHEUS_URL=${NEXT_PUBLIC_PROMETHEUS_URL:-http://localhost:9090} + - NEXT_PUBLIC_LOKI_URL=${NEXT_PUBLIC_LOKI_URL:-http://localhost:3100} + # Server-side only (Docker hostnames OK) + - POSTGREST_URL=http://postgrest:3000 + - PRESIGN_SHARED_SECRET=${PRESIGN_SHARED_SECRET} + - RENDER_WEBHOOK_URL=${RENDER_WEBHOOK_URL:-http://render-webhook:8085/comfy/webhook} + - OPEN_NOTEBOOK_API_URL=${OPEN_NOTEBOOK_API_URL:-http://open-notebook:8503} + ports: + - "4482:3000" + # Note: flute-gateway is optional (for voice features). Start with workers profile for full functionality. + networks: [ app_tier, api_tier, supabase_net ] + profiles: [ "ui" ] + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + ffmpeg-whisper: build: context: .. diff --git a/pmoves/services/vibevoice-realtime/Dockerfile b/pmoves/services/vibevoice-realtime/Dockerfile index 6e7f347d4d..31e75be09c 100644 --- a/pmoves/services/vibevoice-realtime/Dockerfile +++ b/pmoves/services/vibevoice-realtime/Dockerfile @@ -37,7 +37,8 @@ RUN chmod +x /entrypoint.sh # Security: Run as non-root user RUN groupadd -r pmoves --gid=65532 && \ useradd -r -g pmoves --uid=65532 --home-dir=/app --shell=/sbin/nologin pmoves && \ - chown -R pmoves:pmoves /app /app/vibevoice + mkdir -p /models && \ + chown -R pmoves:pmoves /app /app/vibevoice /models USER pmoves diff --git a/pmoves/ui/Dockerfile b/pmoves/ui/Dockerfile new file mode 100644 index 0000000000..589e8de66b --- /dev/null +++ b/pmoves/ui/Dockerfile @@ -0,0 +1,50 @@ +FROM node:20-slim AS builder + +LABEL org.opencontainers.image.source="https://github.com/POWERFULMOVES/PMOVES.AI" +LABEL org.opencontainers.image.description="PMOVES.AI Unified Portal - Next.js Dashboard" + +WORKDIR /app + +# Install curl for health checks +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source and build +COPY . . +RUN npm run build + +# Production image +FROM node:20-slim AS runner +WORKDIR /app + +# Install curl for health checks +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Security: Non-root user (consistent with other PMOVES services) +RUN groupadd -r pmoves --gid=65532 && \ + useradd -r -g pmoves --uid=65532 --home-dir=/app --shell=/sbin/nologin pmoves + +# Copy built assets from builder +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# Change ownership to non-root user +RUN chown -R pmoves:pmoves /app + +USER pmoves + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -sf http://localhost:3000/api/health || exit 1 + +# Next.js standalone server +CMD ["node", "server.js"] diff --git a/pmoves/ui/app/api/health/route.ts b/pmoves/ui/app/api/health/route.ts new file mode 100644 index 0000000000..c73dc5db16 --- /dev/null +++ b/pmoves/ui/app/api/health/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; + +/** + * Health check endpoint for container orchestration. + * Returns service status, name, and timestamp. + */ +export async function GET() { + return NextResponse.json({ + status: 'healthy', + service: 'pmoves-ui', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '0.1.0', + }); +} diff --git a/pmoves/ui/app/dashboard/monitor/page.tsx b/pmoves/ui/app/dashboard/monitor/page.tsx index 565747519b..01350f8f78 100644 --- a/pmoves/ui/app/dashboard/monitor/page.tsx +++ b/pmoves/ui/app/dashboard/monitor/page.tsx @@ -3,6 +3,11 @@ import { useEffect, useState } from "react"; import { DashboardShell } from "../../../components/DashboardNavigation"; +// Monitoring service URLs (configured via NEXT_PUBLIC_* env vars for client-side access) +const GRAFANA_URL = process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://localhost:3002'; +const PROMETHEUS_URL = process.env.NEXT_PUBLIC_PROMETHEUS_URL || 'http://localhost:9090'; +const LOKI_URL = process.env.NEXT_PUBLIC_LOKI_URL || 'http://localhost:3100'; + export default function MonitorDashboardPage() { const statsUrl = "/api/monitor/stats"; const [stats, setStats] = useState | null>(null); @@ -126,7 +131,7 @@ export default function MonitorDashboardPage() { {/* Quick links */}
Grafana - :3000 + {new URL(GRAFANA_URL).port || '80'}

Dashboard visualization

Prometheus - :9090 + {new URL(PROMETHEUS_URL).port || '80'}

Metrics queries

Loki - :3100 + {new URL(LOKI_URL).port || '80'}

Log aggregation

diff --git a/pmoves/ui/lib/fluteClient.ts b/pmoves/ui/lib/fluteClient.ts new file mode 100644 index 0000000000..44643ae603 --- /dev/null +++ b/pmoves/ui/lib/fluteClient.ts @@ -0,0 +1,325 @@ +/** + * Flute Gateway Client + * + * Provides HTTP and WebSocket interfaces to the Flute-Gateway voice service. + * Supports both synchronous TTS synthesis and real-time streaming. + * + * No authentication required for basic synthesis operations. + * + * @see .claude/context/flute-gateway.md for API reference + * + * @example + * ```typescript + * const flute = new FluteClient(); + * + * // Synchronous synthesis + * const audio = await flute.synthesize('Hello world'); + * + * // Session-based streaming + * const session = await flute.createSession(); + * flute.connectWithSession(session, (audioData) => { + * // Handle streaming audio chunks + * }); + * ``` + */ + +export interface SynthesizeOptions { + voice?: string; + speed?: number; + pitch?: number; + emotion?: string; +} + +/** + * Represents a Flute-Gateway voice session. + * Sessions provide dedicated WebSocket connections for real-time streaming. + */ +export interface FluteSession { + /** Unique session identifier */ + sessionId: string; + /** Dedicated WebSocket URL for this session */ + websocketUrl: string; + /** Session creation timestamp */ + createdAt: Date; +} + +/** + * Client for Flute-Gateway voice communication layer. + * + * Provides both synchronous TTS synthesis and real-time WebSocket streaming. + * Session management is recommended for production use with multiple + * concurrent voice interactions. + */ +export class FluteClient { + private ws: WebSocket | null = null; + private readonly httpUrl: string; + private readonly wsUrl: string; + private reconnectAttempts = 0; + private maxReconnectAttempts = 3; + private currentCallbacks: { + onMessage?: (data: ArrayBuffer) => void; + onError?: (error: Event) => void; + onClose?: (event: CloseEvent) => void; + onReconnectExhausted?: () => void; + } = {}; + + constructor(options?: { httpUrl?: string; wsUrl?: string }) { + // Use environment variables with fallbacks for local development + this.httpUrl = options?.httpUrl + || process.env.NEXT_PUBLIC_FLUTE_GATEWAY_URL + || 'http://localhost:8055'; + this.wsUrl = options?.wsUrl + || process.env.NEXT_PUBLIC_FLUTE_WS_URL + || 'ws://localhost:8056'; + } + + /** + * Synthesize text to speech using prosodic synthesis. + * Returns raw audio data as ArrayBuffer (WAV format). + * + * @param text - The text to synthesize + * @param options - Optional synthesis parameters (voice, speed, pitch, emotion) + * @returns ArrayBuffer containing WAV audio data + * @throws {Error} If synthesis request fails or network error occurs + */ + async synthesize(text: string, options?: SynthesizeOptions): Promise { + const response = await fetch(`${this.httpUrl}/v1/voice/synthesize/prosodic`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + voice: options?.voice, + speed: options?.speed, + pitch: options?.pitch, + emotion: options?.emotion, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Flute synthesis failed: ${response.status} - ${error}`); + } + + return response.arrayBuffer(); + } + + /** + * Create a new voice session for real-time streaming. + * Sessions provide dedicated WebSocket connections and are recommended + * for production use with multiple concurrent interactions. + * + * @returns FluteSession with session ID and dedicated WebSocket URL + * @throws {Error} If session creation fails + */ + async createSession(): Promise { + const response = await fetch(`${this.httpUrl}/v1/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Flute session creation failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + return { + sessionId: data.session_id, + websocketUrl: data.websocket_url, + createdAt: new Date(data.created_at), + }; + } + + /** + * Check if Flute Gateway is healthy. + * + * @returns true if gateway is healthy, false otherwise + */ + async isHealthy(): Promise { + try { + const response = await fetch(`${this.httpUrl}/healthz`, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + return response.ok; + } catch (error) { + console.warn('FluteClient: Health check failed', error); + return false; + } + } + + /** + * Connect to WebSocket for real-time streaming using default endpoint. + * + * For production use with multiple concurrent sessions, prefer + * createSession() + connectWithSession() for dedicated connections. + * + * @param onMessage - Callback for incoming audio chunks + * @param onError - Optional error handler + * @param onClose - Optional close handler + * @param onReconnectExhausted - Optional callback when max reconnection attempts exhausted + */ + connect( + onMessage: (data: ArrayBuffer) => void, + onError?: (error: Event) => void, + onClose?: (event: CloseEvent) => void, + onReconnectExhausted?: () => void, + ): void { + this.connectToUrl(this.wsUrl, onMessage, onError, onClose, onReconnectExhausted); + } + + /** + * Connect to WebSocket using a dedicated session. + * + * @param session - FluteSession obtained from createSession() + * @param onMessage - Callback for incoming audio chunks + * @param onError - Optional error handler + * @param onClose - Optional close handler + * @param onReconnectExhausted - Optional callback when max reconnection attempts exhausted + */ + connectWithSession( + session: FluteSession, + onMessage: (data: ArrayBuffer) => void, + onError?: (error: Event) => void, + onClose?: (event: CloseEvent) => void, + onReconnectExhausted?: () => void, + ): void { + this.connectToUrl(session.websocketUrl, onMessage, onError, onClose, onReconnectExhausted); + } + + /** + * Internal method to connect to a WebSocket URL. + */ + private connectToUrl( + url: string, + onMessage: (data: ArrayBuffer) => void, + onError?: (error: Event) => void, + onClose?: (event: CloseEvent) => void, + onReconnectExhausted?: () => void, + ): void { + if (this.ws?.readyState === WebSocket.OPEN) { + console.warn('FluteClient: Already connected'); + return; + } + + // Store callbacks for potential reconnection + this.currentCallbacks = { onMessage, onError, onClose, onReconnectExhausted }; + + this.ws = new WebSocket(url); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => { + console.log('FluteClient: WebSocket connected'); + this.reconnectAttempts = 0; + }; + + this.ws.onmessage = (event) => { + if (event.data instanceof ArrayBuffer) { + onMessage(event.data); + } + }; + + this.ws.onerror = (error) => { + console.error('FluteClient: WebSocket error', error); + onError?.(error); + }; + + this.ws.onclose = (event) => { + console.log('FluteClient: WebSocket closed', event.code, event.reason); + onClose?.(event); + + // Auto-reconnect on unexpected close + if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log(`FluteClient: Reconnecting (attempt ${this.reconnectAttempts})`); + setTimeout( + () => this.connectToUrl(url, onMessage, onError, onClose, onReconnectExhausted), + 1000 * this.reconnectAttempts, + ); + } else if (!event.wasClean && this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('FluteClient: Max reconnection attempts exhausted'); + onReconnectExhausted?.(); + } + }; + } + + /** + * Send text for streaming synthesis. + * Must be connected first via connect() or connectWithSession(). + * + * @param text - Text to synthesize + * @param voice - Optional voice identifier + * @throws {Error} If not connected to WebSocket + */ + sendText(text: string, voice?: string): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('FluteClient: Not connected. Call connect() or connectWithSession() first.'); + } + this.ws.send(JSON.stringify({ type: 'text', text, voice })); + } + + /** + * Disconnect WebSocket connection. + */ + disconnect(): void { + if (this.ws) { + this.ws.close(1000, 'Client disconnect'); + this.ws = null; + } + this.currentCallbacks = {}; + } + + /** + * Check if WebSocket is connected. + */ + get isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } +} + +/** + * Play audio data through Web Audio API. + * Utility function for client-side audio playback. + * + * @warning Browser-only - AudioContext not available in Node.js/SSR + * @param audioData - ArrayBuffer containing audio data (WAV format) + * @throws {Error} If audio decoding fails or AudioContext unavailable + */ +export async function playAudio(audioData: ArrayBuffer): Promise { + const audioContext = new AudioContext(); + try { + const audioBuffer = await audioContext.decodeAudioData(audioData); + const source = audioContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioContext.destination); + source.start(0); + + return new Promise((resolve, reject) => { + source.onended = () => { + audioContext.close(); + resolve(); + }; + // Note: AudioBufferSourceNode doesn't have onerror, but decode errors + // are caught by the outer try/catch + }); + } catch (error) { + await audioContext.close(); + throw error; + } +} + +// Singleton instance for convenience +let defaultClient: FluteClient | null = null; + +/** + * Get the default FluteClient singleton instance. + * Useful for simple use cases without session management. + * + * @returns Shared FluteClient instance + */ +export function getFluteClient(): FluteClient { + if (!defaultClient) { + defaultClient = new FluteClient(); + } + return defaultClient; +} diff --git a/pmoves/ui/lib/presign.ts b/pmoves/ui/lib/presign.ts index 5d486834d8..cab4774f17 100644 --- a/pmoves/ui/lib/presign.ts +++ b/pmoves/ui/lib/presign.ts @@ -59,21 +59,21 @@ export async function callPresignService(options: PresignOptions): Promise) => fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', - headers: authHeaders, + headers, body: JSON.stringify(payload), }); - // Try once with configured secret; if unauthorized and a common default is expected, retry with 'change_me' - let response = await doFetch(headers); - if ((response.status === 401 || response.status === 403) && (!sharedSecret || sharedSecret !== 'change_me')) { - const retryHeaders = { ...headers, authorization: 'Bearer change_me' }; - response = await doFetch(retryHeaders); - } - if (!response.ok) { const text = await response.text(); + // Provide actionable error for auth failures + if (response.status === 401 || response.status === 403) { + throw new Error( + `Presign service authentication failed (${response.status}). ` + + 'Ensure PRESIGN_SHARED_SECRET is configured correctly.' + ); + } throw new Error(text || `Presign service request failed (${response.status})`); } diff --git a/pmoves/ui/next.config.mjs b/pmoves/ui/next.config.mjs index 06ffd9c99d..2787ff013f 100644 --- a/pmoves/ui/next.config.mjs +++ b/pmoves/ui/next.config.mjs @@ -5,6 +5,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); /** @type {import('next').NextConfig} */ const nextConfig = { + output: 'standalone', reactStrictMode: true, outputFileTracingRoot: __dirname, images: {