From 8b68950f19af2ab76ea22bee6fd64d6bc8edb0ec Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 10:25:26 -0400 Subject: [PATCH] Add producer-gated Pruttivox command --- system/netlify.toml | 5 +- system/netlify/functions/say.js | 120 +++++++++++++++- .../aesthetic.computer/disks/pruttivox.mjs | 135 ++++++++++++++++++ .../public/aesthetic.computer/lib/speech.mjs | 33 ++++- 4 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 system/public/aesthetic.computer/disks/pruttivox.mjs diff --git a/system/netlify.toml b/system/netlify.toml index 2ddcc349b0..644453b590 100644 --- a/system/netlify.toml +++ b/system/netlify.toml @@ -74,8 +74,9 @@ included_env_vars = [] external_node_modules = ["@aws-sdk/client-s3", "@atproto/api", "mongodb", "mongodb-connection-string-url"] included_env_vars = ["USER_ENDPOINT", "ART_KEY", "ART_SECRET", "USER_SPACE_NAME", "CONTEXT", "ADMIN_SUB", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_SECRET", "MONGODB_CONNECTION_STRING", "MONGODB_NAME", "REDIS_CONNECTION_STRING", "NETLIFY_DEV", "PDS_URL", "PDS_ADMIN_PASSWORD"] [functions.say] -external_node_modules = ["@google-cloud/text-to-speech"] -included_env_vars = ["GCP_EMAIL", "GCP_TTS_KEY_URL", "CONTEXT", "ART_ENDPOINT", "ART_KEY", "ART_SECRET", "ART_SPACE_NAME", "OPENAI_API_KEY"] +external_node_modules = ["@google-cloud/text-to-speech", "mongodb", "mongodb-connection-string-url", "got", "redis"] +included_files = ["backend/**/*.mjs"] +included_env_vars = ["GCP_EMAIL", "GCP_TTS_KEY_URL", "CONTEXT", "ART_ENDPOINT", "ART_KEY", "ART_SECRET", "ART_SPACE_NAME", "OPENAI_API_KEY", "ELEVENLABS_API_KEY", "PRUTTI_ELEVENLABS_VOICE_ID", "ADMIN_SUB", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_SECRET", "MONGODB_CONNECTION_STRING", "MONGODB_NAME", "REDIS_CONNECTION_STRING"] [functions.mood] external_node_modules = ["got", "firebase-admin", "obscenity", "@atproto/api", "sharp", "mongodb", "mongodb-connection-string-url"] included_env_vars = ["CONTEXT", "ADMIN_SUB", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_SECRET", "MONGODB_CONNECTION_STRING", "MONGODB_NAME", "REDIS_CONNECTION_STRING", "NETLIFY_DEV", "GCM_FIREBASE_CONFIG_URL", "PDS_URL", "PDS_ADMIN_PASSWORD"] diff --git a/system/netlify/functions/say.js b/system/netlify/functions/say.js index 4b68ada034..b0109b746b 100644 --- a/system/netlify/functions/say.js +++ b/system/netlify/functions/say.js @@ -29,12 +29,16 @@ const CDN_URL = "https://art.aesthetic.computer"; const CACHE_PREFIX = "tts-cache/"; // Generate cache key from provider + voice + text + instructions -// Custom voice clones (jeffrey) get their own subfolder so all utterances -// from that voice are easy to list / browse on the CDN. +// Consented voice clones get their own subfolders so their utterance catalogs +// stay distinct from stock voices. function getCacheKey(provider, voiceId, text, instructions) { const parts = `${provider}:${voiceId}:${text}${instructions ? `:${instructions}` : ""}`; const hash = crypto.createHash("sha256").update(parts).digest("hex"); - const subfolder = provider === "jeffrey" ? "jeffrey/" : ""; + const subfolder = provider === "jeffrey" + ? "jeffrey/" + : provider === "prutti" + ? "pruttivox/" + : ""; return `${CACHE_PREFIX}${subfolder}${hash}.mp3`; } @@ -184,6 +188,60 @@ async function generateElevenLabs(text, gender, set, scream) { }; } +// Pruttivox uses Prutti's consented IVC. Arbitrary text is deliberately +// producer-gated below; the public chat lane remains message-id-only in +// `netlify/functions/pruttivox.mjs`. +async function generatePrutti(text) { + const voiceId = process.env.PRUTTI_ELEVENLABS_VOICE_ID; + if (!voiceId || !process.env.ELEVENLABS_API_KEY) { + const error = new Error("Pruttivox is not configured."); + error.statusCode = 503; + throw error; + } + + const response = await fetch( + `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}`, + { + method: "POST", + headers: { + "xi-api-key": process.env.ELEVENLABS_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text, + model_id: "eleven_multilingual_v2", + voice_settings: { + stability: 0.38, + similarity_boost: 0.9, + style: 0.48, + use_speaker_boost: true, + speed: 0.98, + }, + }), + }, + ); + + if (!response.ok) { + const detail = (await response.text()).slice(0, 300); + throw new Error(`ElevenLabs (Pruttivox) ${response.status}: ${detail}`); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + voiceId: "prutti-ivc", + }; +} + +async function authorizePruttivox(event) { + const { authorize, handleFor, hasAdmin } = await import("../../backend/authorization.mjs"); + const user = await authorize(event.headers); + if (!user?.sub || !user.email_verified) return null; + + const handle = String(await handleFor(user.sub) || "").toLowerCase(); + const allowed = handle === "prutti" || await hasAdmin(user, "aesthetic"); + return allowed ? `@${handle}` : null; +} + // ── Jeffrey: Professional Voice Clone (PVC) ────────────────────────── // Trained on multiple public lectures/talks by @jeffrey. Same voice // used in the LACMA 2026 grant pitch video. @@ -326,11 +384,47 @@ exports.handler = async (event) => { const set = parseInt(body.voice?.split(":")[1]) || 0; const gender = body.voice?.split(":")[0]?.toLowerCase() || "neutral"; - // Provider: "jeffrey" (default PVC), "openai", "google", "eleven" + // Provider: "jeffrey" (default PVC), "openai", "google", "eleven", + // or producer-gated "prutti". // Can be set via body.provider; falls back to Jeffrey for parity // with the `say` piece default. const provider = body.provider || "jeffrey"; + let requester = null; + if (provider === "prutti") { + if (typeof body.from !== "string" || !body.from.trim()) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: "Pruttivox needs text to speak." }), + }; + } + try { + requester = await authorizePruttivox(event); + } catch (error) { + console.error("Pruttivox authorization failed:", error); + return { + statusCode: 503, + headers, + body: JSON.stringify({ message: "Pruttivox authorization is unavailable." }), + }; + } + if (!requester) { + return { + statusCode: 403, + headers, + body: JSON.stringify({ message: "Pruttivox is available to its approved producers." }), + }; + } + if (utterance.length > 1200) { + return { + statusCode: 413, + headers, + body: JSON.stringify({ message: "Pruttivox chunks must be 1200 characters or fewer." }), + }; + } + } + // Instructions for gpt-4o-mini-tts emotional/style control (OpenAI only) const instructions = provider === "openai" ? (body.instructions || null) : null; @@ -401,6 +495,10 @@ exports.handler = async (event) => { await recordSaying({ text, provider, + requester, + requestId: body.requestId || null, + chunkIndex: Number.isInteger(body.chunkIndex) ? body.chunkIndex : null, + chunkCount: Number.isInteger(body.chunkCount) ? body.chunkCount : null, voice: null, // unknown on cache hit; cacheKey ties it to the original voiceSpec, scream, @@ -431,6 +529,8 @@ exports.handler = async (event) => { result = await generateGoogle(text, gender, set, isSSML); } else if (provider === "eleven") { result = await generateElevenLabs(text, gender, set, scream); + } else if (provider === "prutti") { + result = await generatePrutti(text); } else if (provider === "jeffrey") { result = await generateJeffrey(text, scream, speed, styleOverride, stabilityOverride, similarityOverride, withTimestamps); } else { @@ -467,6 +567,10 @@ exports.handler = async (event) => { await recordSaying({ text, provider, + requester, + requestId: body.requestId || null, + chunkIndex: Number.isInteger(body.chunkIndex) ? body.chunkIndex : null, + chunkCount: Number.isInteger(body.chunkCount) ? body.chunkCount : null, voice: voiceId, voiceSpec, scream, @@ -496,6 +600,10 @@ exports.handler = async (event) => { await recordSaying({ text, provider, + requester, + requestId: body.requestId || null, + chunkIndex: Number.isInteger(body.chunkIndex) ? body.chunkIndex : null, + chunkCount: Number.isInteger(body.chunkCount) ? body.chunkCount : null, voice: voiceId, voiceSpec, scream, @@ -528,7 +636,7 @@ exports.handler = async (event) => { } catch (error) { console.error("TTS generation failed:", error); return { - statusCode: 500, + statusCode: error.statusCode || 500, headers, body: JSON.stringify({ message: "An error has occurred.", error: error.message }), }; @@ -553,6 +661,6 @@ function corsHeaders(event) { "Access-Control-Allow-Origin": allowedOrigin, "Access-Control-Allow-Credentials": true, "Access-Control-Allow-Headers": - "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization", }; } diff --git a/system/public/aesthetic.computer/disks/pruttivox.mjs b/system/public/aesthetic.computer/disks/pruttivox.mjs new file mode 100644 index 0000000000..70dd2f91ae --- /dev/null +++ b/system/public/aesthetic.computer/disks/pruttivox.mjs @@ -0,0 +1,135 @@ +// Pruttivox, 26.09.09.09.29 +// Read a reviewed text in Prutti's consented synthetic voice. + +const CHUNK_LIMIT = 1100; +const MAX_TEXT_LENGTH = 12000; +const SYNTHETIC_TAG = "Pruttivox. Syntetisk stemme."; + +let text = ""; +let chunks = []; +let chunkIndex = 0; +let requestId = null; +let status = "idle"; + +function splitPoint(value, limit) { + const window = value.slice(0, limit + 1); + const minimum = Math.floor(limit * 0.4); + let point = window.lastIndexOf("\n\n"); + + const sentenceMatches = [...window.matchAll(/[.!?…]["'”’)]*\s+/g)]; + const sentence = sentenceMatches.at(-1); + if (sentence && sentence.index + sentence[0].length > point) { + point = sentence.index + sentence[0].length; + } + + if (point < minimum) point = window.lastIndexOf("\n"); + if (point < minimum) point = window.lastIndexOf(" "); + return point >= minimum ? point : limit; +} + +function chunkText(value, limit = CHUNK_LIMIT) { + const result = []; + let remaining = value.trim(); + + while (remaining.length > limit) { + const point = splitPoint(remaining, limit); + const chunk = remaining.slice(0, point).trim(); + if (chunk) result.push(chunk); + remaining = remaining.slice(point).trim(); + } + + if (remaining) result.push(remaining); + return result; +} + +function boot({ params, hud }) { + text = params.join(" ").trim(); + chunks = text.length <= MAX_TEXT_LENGTH ? chunkText(text) : []; + hud.label("pruttivox", "magenta"); +} + +function queueChunk(speak) { + const queue = [...chunks, SYNTHETIC_TAG]; + speak(queue[chunkIndex], "neutral:0", "cloud", { + provider: "prutti", + volume: 1, + requestId, + chunkIndex, + chunkCount: queue.length, + }); +} + +function start(speak) { + if (!text || text.length > MAX_TEXT_LENGTH || status === "speaking") return; + requestId = globalThis.crypto?.randomUUID?.() + || `pruttivox-${Date.now()}-${Math.random().toString(36).slice(2)}`; + chunks = chunkText(text); + chunkIndex = 0; + status = "speaking"; + queueChunk(speak); +} + +function paint({ wipe, ink, screen }) { + wipe(24, 12, 30); + + ink(255, 210, 238).write("SYNTETISK STEMME", { center: "x", screen, y: 42 }); + + if (!text) { + ink(190, 160, 185).write("pruttivox ", { center: "xy", screen }); + } else if (text.length > MAX_TEXT_LENGTH) { + ink(255, 110, 110).write(`TEXT IS OVER ${MAX_TEXT_LENGTH} CHARACTERS`, { + center: "xy", + screen, + }); + } else { + const preview = text.replace(/\s+/g, " "); + const visible = preview.length > 520 ? `${preview.slice(0, 519)}…` : preview; + ink(255, 245, 250).write(visible, { + x: 24, + y: 84, + }, undefined, Math.max(120, screen.width - 48), true); + } + + let footer = "TAP TO SPEAK"; + let color = [190, 160, 185]; + if (status === "speaking") { + footer = `SPEAKING ${Math.min(chunkIndex + 1, chunks.length + 1)} / ${chunks.length + 1}`; + color = [190, 255, 100]; + } else if (status === "done") { + footer = "COMPLETE · TAP TO REPLAY"; + color = [190, 255, 100]; + } else if (status === "error") { + footer = "UNAVAILABLE · SIGN IN AS AN APPROVED PRODUCER"; + color = [255, 110, 110]; + } + ink(...color).write(footer, { center: "x", screen, y: screen.height - 24 }); +} + +function act({ event: e, speak }) { + if ( + e.is("touch") + || e.is("keyboard:down:space") + || e.is("keyboard:down:enter") + ) { + start(speak); + } + + if (e.is("speech:completed") && status === "speaking") { + chunkIndex += 1; + if (chunkIndex <= chunks.length) queueChunk(speak); + else status = "done"; + } + + if (e.is("speech:error") && e.content?.provider === "prutti") { + status = "error"; + } +} + +function meta() { + return { + title: "Pruttivox", + desc: "Read reviewed text in Prutti's consented synthetic voice.", + }; +} + +export { act, boot, chunkText, meta, paint }; diff --git a/system/public/aesthetic.computer/lib/speech.mjs b/system/public/aesthetic.computer/lib/speech.mjs index 377a42526d..a6769695da 100644 --- a/system/public/aesthetic.computer/lib/speech.mjs +++ b/system/public/aesthetic.computer/lib/speech.mjs @@ -202,24 +202,36 @@ function speak(words, voice, mode = "local", opts = {}) { if (opts.instructions) payload.instructions = opts.instructions; if (opts.scream) payload.scream = true; + if (opts.requestId) payload.requestId = opts.requestId; + if (Number.isInteger(opts.chunkIndex)) payload.chunkIndex = opts.chunkIndex; + if (Number.isInteger(opts.chunkCount)) payload.chunkCount = opts.chunkCount; // Create a promise that resolves when the fetch completes let fetchResolve; const fetchPromise = new Promise(resolve => { fetchResolve = resolve; }); pendingFetches.set(label, fetchPromise); - function fetchSpeech(retryCount = 0) { + async function fetchSpeech(retryCount = 0) { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // Increased timeout to 15s + const timeoutMs = opts.provider === "prutti" ? 120000 : 15000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const host = ``; //window.acDEBUG // ? `` // Just use current host, via `netlify.toml`. // : "https://ai.aesthetic.computer"; + const headers = { "Content-Type": "application/json" }; + if (opts.provider === "prutti") { + try { + const token = window.acTOKEN || await window.auth0Client?.getTokenSilently?.(); + if (token) headers.Authorization = `Bearer ${token}`; + } catch { + // The server returns the authoritative access error. + } + } + fetch(`${host}/api/say`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify(payload), signal: controller.signal, }) @@ -238,7 +250,8 @@ function speak(words, voice, mode = "local", opts = {}) { play(); } else { console.log("🗣️ Speech fetch failure, status:", res.status, "retry:", retryCount); - if (retryCount < 3) { + const retryable = res.status === 429 || res.status >= 500; + if (retryable && retryCount < 3) { setTimeout(() => { fetchSpeech(retryCount + 1); }, 1000 * (retryCount + 1)); // Exponential backoff @@ -246,6 +259,10 @@ function speak(words, voice, mode = "local", opts = {}) { console.error("🗣️ Max retries reached for:", label); pendingFetches.delete(label); fetchResolve(null); // Resolve with null on failure + window.acSEND({ + type: "speech:error", + content: { status: res.status, provider: opts.provider || "openai" }, + }); } } }) @@ -260,6 +277,10 @@ function speak(words, voice, mode = "local", opts = {}) { console.error("🗣️ Giving up on:", label, "after", retryCount, "retries"); pendingFetches.delete(label); fetchResolve(null); // Resolve with null on failure + window.acSEND({ + type: "speech:error", + content: { status: 0, provider: opts.provider || "openai" }, + }); } }); } -- 2.51.2