diff --git a/marketing/klokkentales/SCORE.md b/marketing/klokkentales/SCORE.md index e9a3653b31..6bcd07d090 100644 --- a/marketing/klokkentales/SCORE.md +++ b/marketing/klokkentales/SCORE.md @@ -72,6 +72,9 @@ node bin/buzzsprout.mjs publish summer-so-far-2026 # Pruttivox: one-off read-aloud of a community text in the Prutti IVC. node bin/pruttivox.mjs "teksten her" --from @snakes --publish + +# A long text comes from a file and is spoken in stitched pieces. +node bin/pruttivox.mjs --file interview.txt --slug interview --from @prutti ``` ## Pruttivox @@ -89,6 +92,14 @@ messages @prutti himself typed — the text comes from the database by message id, never from the caller — and caches each render on the CDN with word timings for the karaoke highlight. +The same chip, in blue, plays a sound *linked* in any message (mp3, wav, ogg, +m4a, webm — the formats `net.preload` decodes). That one renders nothing and +speaks for nobody; it just plays the file at the url. A message carrying a +link plays the link, not its words. This is the working lane for linked audio: +`sfx ` from the prompt still kills the renderer on a long remote file +(reproduced three times on a 174-second mp3, cause not yet found), so send +people to the chip, not to the piece. + ## Release gates 1. Confirm the date window and evidence snapshot. diff --git a/marketing/klokkentales/bin/pruttivox.mjs b/marketing/klokkentales/bin/pruttivox.mjs index 79ee5fc6d7..3545e23fa6 100644 --- a/marketing/klokkentales/bin/pruttivox.mjs +++ b/marketing/klokkentales/bin/pruttivox.mjs @@ -6,6 +6,7 @@ // // node bin/pruttivox.mjs "Hej klokken, god torsdag" --from @snakes // node bin/pruttivox.mjs "..." --slug god-torsdag --publish +// node bin/pruttivox.mjs --file interview.txt --slug interview --from prutti import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; @@ -28,19 +29,41 @@ const voiceId = env.PRUTTI_ELEVENLABS_VOICE_ID || (existsSync(receiptPath) : null); const argv = process.argv.slice(2); -const text = argv.find((arg) => !arg.startsWith("--")); const flag = (name) => { const index = argv.indexOf(`--${name}`); return index >= 0 ? argv[index + 1] : null; }; +const valued = new Set([flag("from"), flag("slug"), flag("file")].filter(Boolean)); +const file = flag("file"); +const text = (file + ? readFileSync(resolve(file), "utf8") + : argv.find((arg) => !arg.startsWith("--") && !valued.has(arg)) || "").trim(); if (!voiceId) throw new Error("Prutti voice has not been created; see voice.mjs"); if (!env.ELEVENLABS_API_KEY) throw new Error("ELEVENLABS_API_KEY is missing"); -if (!text || !text.trim()) { - throw new Error('usage: pruttivox.mjs "the text" [--from @handle] [--slug name] [--publish] [--force]'); +if (!text) { + throw new Error('usage: pruttivox.mjs "the text" | --file text.txt [--from @handle] [--slug name] [--publish] [--force]'); } -if (text.length > 1200) { - throw new Error(`text is ${text.length} characters; keep pruttivox clips under 1200`); + +// the voice gets brittle past a page, so a long text is spoken in pieces and +// stitched — seams land on paragraph or sentence ends, where a breath belongs. +function pieces(body, limit = 900) { + const out = []; + const fit = (parts, join) => { + for (const part of parts) { + const last = out[out.length - 1]; + if (last && last.length + join.length + part.length <= limit) out[out.length - 1] = last + join + part; + else out.push(part); + } + }; + for (const paragraph of body.split(/\n\s*\n/).map((p) => p.replace(/\s+/g, " ").trim()).filter(Boolean)) { + if (paragraph.length <= limit) fit([paragraph], "\n\n"); + else for (const sentence of paragraph.split(/(?<=[.!?])\s+/)) { + if (sentence.length <= limit) fit([sentence], " "); + else fit(sentence.match(new RegExp(`.{1,${limit}}(\\s|$)`, "g")).map((s) => s.trim()), " "); + } + } + return out; } const slug = (flag("slug") || text.trim().toLowerCase() @@ -75,12 +98,19 @@ async function speak(line, destination) { mkdirSync(outRoot, { recursive: true }); if (!existsSync(tagPath)) await speak(tagText, tagPath); -const bodyPath = resolve(outRoot, `${slug}.body.mp3`); -await speak(text, bodyPath); +const bodies = pieces(text); +const bodyPaths = []; +for (const [index, body] of bodies.entries()) { + const bodyPath = resolve(outRoot, bodies.length > 1 ? `${slug}.body-${index + 1}.mp3` : `${slug}.body.mp3`); + await speak(body, bodyPath); + bodyPaths.push(bodyPath); +} +const pad = bodyPaths.map((_, i) => `[${i}:a]apad=pad_dur=0.4[a${i}]`).join(";"); +const chain = bodyPaths.map((_, i) => `[a${i}]`).join(""); execFileSync("ffmpeg", [ - "-y", "-i", bodyPath, "-i", tagPath, - "-filter_complex", "[0:a]apad=pad_dur=0.4[a0];[a0][1:a]concat=n=2:v=0:a=1", + "-y", ...bodyPaths.flatMap((path) => ["-i", path]), "-i", tagPath, + "-filter_complex", `${pad};${chain}[${bodyPaths.length}:a]concat=n=${bodyPaths.length + 1}:v=0:a=1`, "-ar", "44100", "-b:a", "192k", "-metadata", "artist=Pruttivox (syntetisk stemme / synthetic voice)", "-metadata", "album=Klokkentales", diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index a4b9c5efd2..fd8a9282c6 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -287,6 +287,17 @@ function voxable(message) { ); } +// 🔊 A linked sound gets the same chip — the difference is only what it plays, +// prutti's rendered voice or the file someone posted. The extensions are the +// ones `net.preload` decodes; flac is deliberately absent, it would load a +// chip that can only fail. +const VOX_LINK = /https?:\/\/[^\s]+\.(?:mp3|wav|ogg|m4a|webm)(?:\?[^\s]*)?/i; + +function voxLink(message) { + if (message.deleted || !message.id) return null; + return message.text?.match(VOX_LINK)?.[0] || null; +} + // Whitespace tokens with char offsets — same split as the server tokenizer. function voxTokenize(text) { const tokens = []; @@ -319,21 +330,27 @@ async function voxToggle(message, api) { return; } voxStop(); + const link = voxLink(message); const my = (vox = { messageId: message.id, phase: "loading", wordIndex: -1, + linked: !!link, // A linked file plays as itself — no words to follow. prefix: (message.from || "").length + 1, // fullMessage = from + " " + text }); try { - const res = await fetch(`/api/pruttivox?id=${encodeURIComponent(message.id)}`); - if (!res.ok) throw new Error(`pruttivox ${res.status}`); - const data = await res.json(); - if (vox !== my) return; // Superseded while loading. - my.words = data.words || []; - my.tokens = voxTokenize(message.text); - my.duration = data.duration || 0; - const sfx = await api.net.preload(data.audio); + let audio = link; + if (!audio) { + const res = await fetch(`/api/pruttivox?id=${encodeURIComponent(message.id)}`); + if (!res.ok) throw new Error(`pruttivox ${res.status}`); + const data = await res.json(); + if (vox !== my) return; // Superseded while loading. + my.words = data.words || []; + my.tokens = voxTokenize(message.text); + my.duration = data.duration || 0; + audio = data.audio; + } + const sfx = await api.net.preload(audio); if (vox !== my) return; my.playing = api.sound.play(sfx, undefined, { kill: () => { @@ -342,7 +359,7 @@ async function voxToggle(message, api) { }); my.startedAt = performance.now(); my.phase = "playing"; - console.log("🗣️ Vox playing:", message.id, my.words.length, "words"); + console.log("🗣️ Vox playing:", message.id, link || `${my.words.length} words`); } catch (err) { console.warn("🗣️ Vox failed:", err); if (vox === my) vox = null; @@ -1633,6 +1650,7 @@ function paint( let voxColor; if (active && vox.phase === "loading") voxColor = [255, 235, 180]; else if (active) voxColor = [190, 255, 80]; + else if (layout.vox.linked) voxColor = layout.vox.over ? [150, 240, 255] : [80, 185, 220]; else voxColor = layout.vox.over ? [255, 220, 120] : [255, 170, 60]; voxIcon(ink, voxColor, voxX + VOX_HIT_PAD, timestampY); } @@ -4474,7 +4492,9 @@ function sim({ api, num, send, net, store }) { // never answers (it fails for the stock `sfx` piece too), and for short // clips at speed 1.0 the word timestamps need no correction. The kill // callback clears at the exact audio end; the wall clock is the backstop. - if (vox?.phase === "playing") { + // A linked file has no word table and no known duration, so it is left to + // the kill callback — the wall-clock backstop below would cut it at 1.5s. + if (vox?.phase === "playing" && !vox.linked) { const t = (performance.now() - vox.startedAt) / 1000; if (t > (vox.duration || 0) + 1.5) { vox = null; @@ -5451,13 +5471,15 @@ function computeMessagesLayout({ screen, text, typeface }, chat, defaultTypeface }; // 🗣️ The vox chip rides just past the timestamp; paint refines `x`/`y` // from the widths it actually renders (hearts shift the whole tail). - const voxChip = voxable(msg) + const linked = voxLink(msg); + const voxChip = (linked || voxable(msg)) ? { x: timestamp.x + timestamp.width + VOX_CHIP_GAP, y: timestamp.y, width: VOX_ICON_W + VOX_HIT_PAD * 2, height: 8, // The full MatrixChunky8 row, not just the 7px glyph. over: msg.lastLayout?.vox?.over || false, + linked: !!linked, // Paint reads this; a tap re-reads the message. } : undefined; let timestampColor = [100 / 1.3, 100 / 1.3, 145 / 1.3]; diff --git a/system/public/aesthetic.computer/disks/sfx.mjs b/system/public/aesthetic.computer/disks/sfx.mjs index fcc4758f77..b8d6f1e397 100644 --- a/system/public/aesthetic.computer/disks/sfx.mjs +++ b/system/public/aesthetic.computer/disks/sfx.mjs @@ -33,20 +33,38 @@ let sfxData; let playingSample; let progress; -const { round } = Math; +const { round, max } = Math; -async function boot({ net: { preload }, play, ui, params, sound }) { +async function boot({ net: { preload }, num, ui, params, sound }) { + // A full url plays whatever was linked — `sfx https://…/clip.mp3`. The + // button wears the filename; the whole url would be a button the width of + // three screens. const name = params[0] || "startup"; + const linked = /^https?:\/\//.test(name); + const label = linked + ? decodeURIComponent(name.split("?")[0].split("/").pop()) + : name; console.log("Sound name:", name); sfx = await preload(name); - btn = new ui.TextButton(`Play "${name}"`); + btn = new ui.TextButton(`Play "${label}"`); speedBtn = new ui.TextButton(speedLabel()); fromBtn = new ui.TextButton(fromLabel()); toBtn = new ui.TextButton(toLabel()); + // A linked file is for listening, not inspecting, so it skips the waveform + // rather than haul minutes of samples across the worker boundary. Note this + // does NOT yet make `sfx ` usable: a 174-second mp3 still takes the + // renderer down with the sample read removed, and the real cause is unfound. + // Linked audio plays from the chat chip today (marketing/klokkentales). + if (linked) return; + sound.getSampleData(sfx).then((data) => { - sfxData = data; - // console.log("🔴 Sample Data:", sfxData); + // Compressed once, to a fixed width, rather than every paint. The count + // passed to `arrCompress` is a stride — it keeps every nth sample — so the + // old constant 256 asked a long file for a 30,000-point polyline sixty + // times a second, and the piece never drew a frame. + const stride = max(1, round(data.length / 256)); + sfxData = { peak: num.arrMax(data), wave: num.arrCompress(data, stride) }; }); } @@ -59,8 +77,8 @@ function paint({ api, wipe, ink, screen, num }) { if (sfxData) { paintSound( api, - num.arrMax(sfxData), - num.arrCompress(sfxData, 256), // 🔴 TODO: This could be made much faster. + sfxData.peak, + sfxData.wave, 0, 0, screen.width,