diff --git a/buyer/src/config.ts b/buyer/src/config.ts index 13ff590..8bd4eb0 100644 --- a/buyer/src/config.ts +++ b/buyer/src/config.ts @@ -11,6 +11,10 @@ * OGMIOS_URL — Ogmios HTTP endpoint (required iff LIVE_CHAIN=1) * LIVE_CHAIN — "1" opts in to LiveOgmiosProvider (real submitTx). * Default off → ReadOnlyOgmiosProvider (safe). + * TTS_PIPER_BASE_URL — Base URL of the openedai-speech-min PiperTTS host. + * When unset, /v1/synth-speech responds 503 (the + * capability stays disabled — the rest of the + * buyer-app boots normally). */ const HEX64_RE = /^[0-9a-fA-F]{64}$/; @@ -23,6 +27,7 @@ export interface BuyerConfig { networkId: 0 | 1; ogmiosUrl: string; liveChain: boolean; + ttsPiperBaseUrl: string; } function requireField(env: Record, name: string): string { @@ -70,5 +75,10 @@ export function loadConfig(env: Record): BuyerConfig throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1"); } - return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain }; + // TTS_PIPER_BASE_URL is purely optional — empty string means the + // /v1/synth-speech endpoint stays disabled. We don't validate the URL + // shape here; an unreachable URL surfaces as a clear 502 from the proxy. + const ttsPiperBaseUrl = env.TTS_PIPER_BASE_URL ?? ""; + + return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain, ttsPiperBaseUrl }; } diff --git a/buyer/src/index.ts b/buyer/src/index.ts index a776040..9eee04b 100644 --- a/buyer/src/index.ts +++ b/buyer/src/index.ts @@ -80,6 +80,7 @@ export async function runMain(env: Record): Promise< walletKey, indexerUrl: config.indexerUrl, marketplace, + ttsPiperBaseUrl: config.ttsPiperBaseUrl, }); const server = app.listen(config.port, () => { diff --git a/buyer/src/server.ts b/buyer/src/server.ts index 5fcc48c..cb6623a 100644 --- a/buyer/src/server.ts +++ b/buyer/src/server.ts @@ -60,6 +60,10 @@ export interface AppDeps { * The browser SPA calls fetch('/v1/submit-prompt') instead of touching * the SDK directly, so the chain stub in the SPA never has to fire. */ marketplace?: Marketplace; + /** Base URL of the openedai-speech-min PiperTTS deployment that + * `/v1/synth-speech` proxies to. Falls back to the public Vector-testnet + * host when not set. Endpoint stays disabled (503) when this is empty. */ + ttsPiperBaseUrl?: string; /** Injectable fetch for tests. Defaults to globalThis.fetch. */ fetchImpl?: typeof globalThis.fetch; } @@ -306,10 +310,115 @@ export function createApp(deps: AppDeps): Express { const reason = err instanceof Error && "reason" in err ? String((err as { reason: unknown }).reason) : "submit_prompt_failed"; + // Surface the failure to logs so 502 responses aren't a silent black + // hole during deploy debugging. SDK errors carry .reason; raw Errors + // carry stack — we want both. + console.error( + `[buyer] /v1/submit-prompt failed reason=${reason} message=${message}`, + err instanceof Error && err.stack ? `\n${err.stack}` : "", + ); return res.status(502).json({ error: reason, message }); } }); + // ── POST /v1/synth-speech — PiperTTS-specific proxy. + // Body: { text, voice, format, speed } + // Returns: audio bytes (Content-Type from upstream, e.g. audio/mpeg). + // + // Path B of the multi-capability story: the SPA's `PiperTTSForm` component + // hits this directly (no escrow yet) so we can demo the audio.synthesize. + // piper.v1 capability without waiting for the on-chain TTS-supplier + // adapter to land. The endpoint is intentionally locked to PiperTTS' OpenAI- + // shape `/v1/audio/speech` — different TTS providers (xtts, coqui, …) get + // their own endpoint so each form's parameter space is explicit. + // + // Knobs limited to what openedai-speech-min actually honours: + // voice ∈ alloy|echo|fable|onyx|nova|shimmer|lessac + // format ∈ mp3|wav|opus|aac|flac + // speed ∈ [0.5, 1.5] + const ALLOWED_VOICES = new Set([ + "alloy", "echo", "fable", "onyx", "nova", "shimmer", "lessac", + ]); + const ALLOWED_FORMATS = new Set(["mp3", "wav", "opus", "aac", "flac"]); + const ttsPiperBaseUrl = (deps.ttsPiperBaseUrl ?? "").replace(/\/+$/, ""); + const ttsFetch = deps.fetchImpl ?? globalThis.fetch; + + app.post("/v1/synth-speech", async (req: Request, res: Response) => { + if (!ttsPiperBaseUrl) { + return jsonError( + res, + 503, + "service_unavailable", + "buyer-app booted without TTS_PIPER_BASE_URL; /v1/synth-speech disabled", + ); + } + + const body = readBody(req.body); + const text = typeof body.text === "string" ? body.text.trim() : ""; + if (text.length === 0) { + return jsonError(res, 400, "text_required", + "body.text must be a non-empty string"); + } + if (text.length > 4000) { + return jsonError(res, 400, "text_too_long", + "body.text must be ≤ 4000 chars (Piper softcap; longer texts can be split client-side)"); + } + const voice = typeof body.voice === "string" ? body.voice : "nova"; + if (!ALLOWED_VOICES.has(voice)) { + return jsonError(res, 400, "voice_invalid", + `voice must be one of: ${[...ALLOWED_VOICES].join(", ")}`); + } + const format = typeof body.format === "string" ? body.format : "mp3"; + if (!ALLOWED_FORMATS.has(format)) { + return jsonError(res, 400, "format_invalid", + `format must be one of: ${[...ALLOWED_FORMATS].join(", ")}`); + } + const speedRaw = body.speed; + const speed = typeof speedRaw === "number" ? speedRaw + : typeof speedRaw === "string" ? Number(speedRaw) + : 1.0; + if (!Number.isFinite(speed) || speed < 0.5 || speed > 1.5) { + return jsonError(res, 400, "speed_out_of_range", + "speed must be a finite number in [0.5, 1.5]"); + } + + try { + const upstream = await ttsFetch(`${ttsPiperBaseUrl}/v1/audio/speech`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "tts-1", + input: text, + voice, + response_format: format, + speed, + }), + }); + if (!upstream.ok) { + const detail = await upstream.text().catch(() => ""); + return jsonError( + res, + 502, + "tts_upstream_error", + `TTS upstream ${upstream.status} ${upstream.statusText}: ${detail.slice(0, 200)}`, + ); + } + const contentType = upstream.headers.get("content-type") ?? `audio/${format}`; + const buf = Buffer.from(await upstream.arrayBuffer()); + res.status(200); + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", String(buf.length)); + // Hint a sensible filename for the SPA's download anchor. + res.setHeader("Content-Disposition", + `inline; filename="speech.${format}"`); + return res.send(buf); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[buyer] /v1/synth-speech failed: ${message}`); + return jsonError(res, 502, "tts_unreachable", message); + } + }); + app.post("/v1/accept", async (req: Request, res: Response) => { if (!resolved) { return jsonError( diff --git a/buyer/src/ui/components/PiperTTSForm.tsx b/buyer/src/ui/components/PiperTTSForm.tsx new file mode 100644 index 0000000..ddd6523 --- /dev/null +++ b/buyer/src/ui/components/PiperTTSForm.tsx @@ -0,0 +1,214 @@ +/** + * buyer/src/ui/components/PiperTTSForm.tsx — capability-specific form + * for `audio.synthesize.piper.v1` suppliers. + * + * The form posts directly to the buyer-app's `/v1/synth-speech` proxy, + * which forwards to the openedai-speech-min PiperTTS host. Path B of the + * multi-capability story: we render a Piper-shaped UI now (text + voice + + * format + speed) without going through escrow. When the marketplace TTS- + * supplier adapter lands, this same component is reused unchanged — only + * the server endpoint swaps from /v1/synth-speech to a /v1/submit-tts. + * + * UX notes: + * - Voices and formats are hard-coded to what openedai-speech-min actually + * honours. If a future Piper deploy adds voices, expose them via a + * /v1/synth-speech-voices listing call instead of free-typing here. + * - We render a native