diff --git a/scripts/pocket.test.mjs b/scripts/pocket.test.mjs new file mode 100644 index 0000000..ce0caae --- /dev/null +++ b/scripts/pocket.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { CALM_RATE, POCKET_JS, schedulePhrases, verseLines } from "../src/beats.ts"; +import { CYPHER_DECK_JS } from "../src/cypher-deck.ts"; +import { decodeWav, encodeWav, packPocket, preparePhrase } from "../src/audio.ts"; + +function strongBeat(bpm) { + return (60 / bpm) * 2; +} + +function onKicks(starts, bpm) { + const strong = strongBeat(bpm); + for (const start of starts) { + const q = start / strong; + assert.ok(Math.abs(q - Math.round(q)) < 1e-9, `${start} is not on a kick at ${bpm} bpm`); + } +} + +test("schedulePhrases lands every line on beat 1 or 3 and matches the browser copy", () => { + const cases = [ + [90, [0.4, 0, 1.2, 2.4]], + [82, [0.2, 0.8, 3]], + [140, [0.5, 0.5, 0.5, 1.6]], + [160, [0]], + ]; + const sandbox = { result: null }; + vm.createContext(sandbox); + vm.runInContext(POCKET_JS, sandbox); + for (const [bpm, durations] of cases) { + const slots = schedulePhrases(durations, bpm); + const js = JSON.parse( + vm.runInContext(`JSON.stringify(schedulePhrases(${JSON.stringify(durations)}, ${bpm}))`, sandbox) + ); + assert.deepEqual(js, slots); + assert.equal(slots.length, durations.length); + onKicks(slots.map((s) => s.start), bpm); + let cursor = 0; + for (let i = 0; i < slots.length; i++) { + assert.equal(slots[i].start, cursor); + assert.ok(slots[i].slots >= 1); + const strong = strongBeat(bpm); + if (durations[i] > 0.001) { + assert.ok(slots[i].slots * strong * 0.9 + 1e-9 >= durations[i]); + } + cursor += slots[i].slots * strong; + } + } +}); + +test("calm rate slows a phrase and the packed attacks sit on the kicks", () => { + assert.ok(CALM_RATE < 1 && CALM_RATE >= 0.7); + const sr = 24000; + const burst = (sec) => { + const n = Math.floor(sec * sr); + const samples = new Float32Array(n); + for (let i = 0; i < n; i++) samples[i] = 0.35; + return samples; + }; + const raw = [burst(0.45), null, burst(1.05)]; + const prepared = raw.map((p) => (p ? preparePhrase(p, sr) : null)); + assert.ok(prepared[0].length > raw[0].length); + assert.equal(prepared[1], null); + const bpm = 90; + const packed = packPocket(prepared, sr, bpm); + onKicks(packed.phraseStarts, bpm); + assert.equal(packed.phraseStarts.length, 3); + assert.equal(packed.phraseStarts[0], 0); + + const attacks = []; + let i = 0; + while (i < packed.pcm.length) { + while (i < packed.pcm.length && Math.abs(packed.pcm[i]) <= 0.012) i++; + if (i >= packed.pcm.length) break; + attacks.push(i / sr); + while (i < packed.pcm.length && Math.abs(packed.pcm[i]) > 0.012) i++; + } + const spoken = packed.phraseStarts.filter((_, idx) => prepared[idx]); + assert.equal(attacks.length, spoken.length); + for (let n = 0; n < spoken.length; n++) { + assert.ok(Math.abs(attacks[n] - spoken[n]) < 0.02, `attack ${attacks[n]} vs kick ${spoken[n]}`); + } + + const wav = encodeWav(packed.pcm, sr); + const decoded = decodeWav(wav); + assert.ok(decoded); + assert.equal(decoded.sampleRate, sr); + assert.equal(decoded.samples.length, packed.pcm.length); +}); + +test("verse lines keep blank lines so rests stay on the grid", () => { + const lines = verseLines("one\n\ntwo"); + assert.deepEqual(lines, ["one", "", "two"]); + const legacy = verseLines("I'm Rift - don't ask, absorb it. Truth engine."); + assert.ok(legacy.length > 4); + assert.ok(legacy.includes("")); +}); + +function installDeck() { + const started = []; + function gainParam() { + return { + value: 1, + setValueAtTime() {}, + exponentialRampToValueAtTime() {}, + setTargetAtTime() {}, + cancelScheduledValues() {}, + }; + } + function AudioContext() { + this.currentTime = 10; + this.sampleRate = 24000; + this.state = "running"; + this.destination = {}; + } + AudioContext.prototype.resume = () => Promise.resolve(); + AudioContext.prototype.createGain = () => ({ gain: gainParam(), connect() {} }); + AudioContext.prototype.createDynamicsCompressor = () => ({ + threshold: { value: 0 }, + knee: { value: 0 }, + ratio: { value: 1 }, + attack: { value: 0 }, + release: { value: 0 }, + connect() {}, + }); + AudioContext.prototype.createBiquadFilter = () => ({ + type: "highpass", + frequency: { value: 90 }, + Q: { value: 0.7 }, + connect() {}, + }); + AudioContext.prototype.createOscillator = () => ({ + type: "sine", + frequency: { value: 100, setValueAtTime() {}, exponentialRampToValueAtTime() {} }, + connect() {}, + start() {}, + stop() {}, + }); + AudioContext.prototype.createBuffer = (_ch, len, sr) => { + const data = new Float32Array(len); + return { + duration: len / sr, + sampleRate: sr, + length: len, + numberOfChannels: 1, + getChannelData: () => data, + }; + }; + AudioContext.prototype.createBufferSource = () => { + const src = { + buffer: null, + playbackRate: { value: 1 }, + connect() {}, + start(when, offset, duration) { + if (duration === undefined) return; + started.push({ when, offset: offset ?? 0, duration, rate: src.playbackRate.value }); + }, + stop() {}, + onended: null, + }; + return src; + }; + AudioContext.prototype.decodeAudioData = (buf) => { + let dur = 4; + if (buf.byteLength === 8) dur = new DataView(buf).getFloat64(0); + const sr = 24000; + const length = Math.max(1, Math.floor(dur * sr)); + const data = new Float32Array(length); + data.fill(0.25); + return Promise.resolve({ + duration: length / sr, + sampleRate: sr, + length, + numberOfChannels: 1, + getChannelData: () => data, + }); + }; + const sandbox = { + window: { AudioContext, webkitAudioContext: AudioContext }, + AudioContext, + webkitAudioContext: AudioContext, + setTimeout: () => 1, + clearTimeout: () => {}, + Math, + isFinite, + parseFloat, + String, + Promise, + }; + vm.createContext(sandbox); + vm.runInContext(CYPHER_DECK_JS, sandbox); + const deck = sandbox.window.createCypherDeck(); + deck.setVibe("boom-bap"); + return { deck, started }; +} + +test("pocket audio plays at rate 1 from the next downbeat", async () => { + const { deck, started } = installDeck(); + const buf = new ArrayBuffer(16); + await deck.drop(buf, 4, "0.0000,1.3333,2.6667,4.0000"); + assert.equal(started.length, 1); + assert.equal(started[0].rate, 1); + const downbeat = 10.03 + deck.stepTime(16); + assert.ok(Math.abs(started[0].when - downbeat) < 1e-6); + assert.equal(deck.phraseStarts.length, 4); + deck.stop(); +}); + +test("separate phrases start on successive kicks and never play faster than calm", async () => { + const { deck, started } = installDeck(); + const buf = (sec) => { + const b = new ArrayBuffer(8); + new DataView(b).setFloat64(0, sec); + return b; + }; + await deck.dropPhrases([buf(0.5), null, buf(1)], CALM_RATE); + assert.equal(started.length, 2); + for (const src of started) assert.ok(src.rate <= CALM_RATE + 1e-9); + const downbeat = started[0].when; + const slots = schedulePhrases([0.5 / CALM_RATE, 0, 1 / CALM_RATE], deck.bpm); + assert.ok(Math.abs(started[0].when - (10.03 + deck.stepTime(16))) < 1e-6); + assert.ok(Math.abs(started[1].when - (downbeat + slots[2].start)) < 1e-6); + onKicks([started[0].when - downbeat, started[1].when - downbeat], deck.bpm); + deck.stop(); +}); diff --git a/src/audio.ts b/src/audio.ts index 68f26b9..121a794 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -1,3 +1,5 @@ +import { CALM_RATE, schedulePhrases } from "./beats"; + const MAX_BYTES = 4_000_000; function isPrivateHost(host: string) { @@ -42,3 +44,168 @@ export async function ingestAudioToR2( await env.AUDIO.put(key, buf, { httpMetadata: { contentType } }); return { key, mime: contentType }; } + +function fourcc(view: DataView, offset: number): string { + let s = ""; + for (let i = 0; i < 4; i++) s += String.fromCharCode(view.getUint8(offset + i)); + return s; +} + +/** 16-bit PCM WAV. Returns null for mp3 or anything else so the caller can hand the bytes to the browser. */ +export function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } | null { + if (bytes.length < 44) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (fourcc(view, 0) !== "RIFF" || fourcc(view, 8) !== "WAVE") return null; + let offset = 12; + let sampleRate = 0; + let channels = 1; + let bits = 16; + let dataOffset = -1; + let dataSize = 0; + while (offset + 8 <= bytes.length) { + const id = fourcc(view, offset); + const size = view.getUint32(offset + 4, true); + const start = offset + 8; + if (id === "fmt " && size >= 16) { + channels = view.getUint16(start + 2, true) || 1; + sampleRate = view.getUint32(start + 4, true); + bits = view.getUint16(start + 14, true); + } else if (id === "data") { + dataOffset = start; + dataSize = size; + break; + } + offset = start + size + (size % 2); + } + if (dataOffset < 0 || !sampleRate || bits !== 16 || channels < 1) return null; + const frames = Math.floor(Math.min(dataSize, bytes.length - dataOffset) / (2 * channels)); + const samples = new Float32Array(frames); + let p = dataOffset; + for (let i = 0; i < frames; i++) { + let acc = 0; + for (let c = 0; c < channels; c++) { + acc += view.getInt16(p, true) / 32768; + p += 2; + } + samples[i] = acc / channels; + } + return { samples, sampleRate }; +} + +export function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array { + const n = samples.length; + const buf = new ArrayBuffer(44 + n * 2); + const view = new DataView(buf); + const write = (off: number, s: string) => { + for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); + }; + write(0, "RIFF"); + view.setUint32(4, 36 + n * 2, true); + write(8, "WAVE"); + write(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + write(36, "data"); + view.setUint32(40, n * 2, true); + let o = 44; + for (let i = 0; i < n; i++) { + const x = Math.max(-1, Math.min(1, samples[i] || 0)); + view.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); + o += 2; + } + return new Uint8Array(buf); +} + +export function resample(samples: Float32Array, fromRate: number, toRate: number): Float32Array { + if (fromRate === toRate || samples.length === 0) return samples; + const outLen = Math.max(1, Math.round((samples.length * toRate) / fromRate)); + const out = new Float32Array(outLen); + const ratio = fromRate / toRate; + const last = samples.length - 1; + for (let i = 0; i < outLen; i++) { + const src = i * ratio; + const i0 = Math.min(last, Math.floor(src)); + const i1 = Math.min(last, i0 + 1); + const f = src - i0; + out[i] = samples[i0] + (samples[i1] - samples[i0]) * f; + } + return out; +} + +/** Drop leading/trailing silence so the attack, not the model's pad, lands on the kick. */ +export function trimSilence(samples: Float32Array, sampleRate: number, threshold = 0.012): Float32Array { + let i0 = -1; + let i1 = -1; + for (let i = 0; i < samples.length; i++) { + if (Math.abs(samples[i]) > threshold) { + i0 = i; + break; + } + } + if (i0 < 0) return new Float32Array(0); + for (let i = samples.length - 1; i >= 0; i--) { + if (Math.abs(samples[i]) > threshold) { + i1 = i; + break; + } + } + const pre = Math.floor(sampleRate * 0.005); + const post = Math.floor(sampleRate * 0.02); + const start = Math.max(0, i0 - pre); + const end = Math.min(samples.length, i1 + 1 + post); + return samples.slice(start, end); +} + +/** Same pitch-and-time change as playing slower. Rate under 1 lengthens the take. */ +export function slowDown(samples: Float32Array, rate: number): Float32Array { + if (samples.length === 0 || rate >= 0.999) return samples; + const outLen = Math.max(1, Math.round(samples.length / rate)); + const out = new Float32Array(outLen); + const last = samples.length - 1; + for (let i = 0; i < outLen; i++) { + const src = i * rate; + const i0 = Math.min(last, Math.floor(src)); + const i1 = Math.min(last, i0 + 1); + const f = src - i0; + out[i] = samples[i0] + (samples[i1] - samples[i0]) * f; + } + return out; +} + +export function preparePhrase(samples: Float32Array, sampleRate: number): Float32Array { + return slowDown(trimSilence(samples, sampleRate), CALM_RATE); +} + +export interface PackedPocket { + pcm: Float32Array; + phraseStarts: number[]; +} + +/** Lay already-calmed phrases onto beat 1 and beat 3. Sample 0 of the buffer is the downbeat. */ +export function packPocket( + phrases: Array, + sampleRate: number, + bpm: number +): PackedPocket { + const durations = phrases.map((p) => (p && p.length ? p.length / sampleRate : 0)); + const slots = schedulePhrases(durations, bpm); + const strong = (60 / bpm) * 2; + const last = slots[slots.length - 1]; + const totalSec = last ? last.start + last.slots * strong : 0; + const total = Math.max(1, Math.ceil(totalSec * sampleRate)); + const pcm = new Float32Array(total); + for (let i = 0; i < phrases.length; i++) { + const phrase = phrases[i]; + if (!phrase || !phrase.length) continue; + const at = Math.round(slots[i].start * sampleRate); + const room = pcm.length - at; + if (room <= 0) continue; + pcm.set(phrase.subarray(0, Math.min(phrase.length, room)), at); + } + return { pcm, phraseStarts: slots.map((slot) => slot.start) }; +} diff --git a/src/beats.ts b/src/beats.ts index 0f6ee88..a982d3a 100644 --- a/src/beats.ts +++ b/src/beats.ts @@ -21,6 +21,104 @@ export function getBeat(id?: string | null): Beat { return found ?? BEATS[0]; } +/** + * Spoken delivery sits back in the pocket. Workers AI Aura-2 has no speed + * field — speaker, encoding, container, sample_rate, bit_rate, text — so the + * calm is a playback rate under 1 (deeper, less pushed) applied when each + * phrase is placed on the grid. Never above 1: speeding a take is what made + * the voice tense. + */ +export const CALM_RATE = 0.9; + +/** A phrase must finish inside this fraction of its slot, leaving air before the next kick. */ +export const POCKET_FIT = 0.9; + +export interface PhraseSlot { + index: number; + /** Seconds after the downbeat. Always a beat 1 or beat 3 (the kick). */ + start: number; + /** How many kick-to-kick slots (2 quarter notes) this phrase owns. */ + slots: number; +} + +/** + * One implementation of the grid, copied into the browser deck as {@link POCKET_JS}. + * `durations` are seconds the phrase will actually occupy after {@link CALM_RATE}. + * A zero duration is a rest (blank line) and still holds one kick so the next + * line lands on a beat. + */ +export function schedulePhrases(durations: number[], bpm: number): PhraseSlot[] { + const beat = 60 / bpm; + const strong = beat * 2; + const fit = POCKET_FIT; + let q = 0; + const out: PhraseSlot[] = []; + for (let i = 0; i < durations.length; i++) { + const dur = durations[i]; + const slots = dur <= 0.001 ? 1 : Math.max(1, Math.ceil(dur / (strong * fit))); + out.push({ index: i, start: q * strong, slots }); + q += slots; + } + return out; +} + +/** Browser copy of {@link schedulePhrases}. Keep the two in lockstep — the pocket test diffs them. */ +export const POCKET_JS = `function schedulePhrases(durations, bpm) { + var beat = 60 / bpm; + var strong = beat * 2; + var fit = ${POCKET_FIT}; + var q = 0; + var out = []; + for (var i = 0; i < durations.length; i++) { + var dur = durations[i]; + var slots = dur <= 0.001 ? 1 : Math.max(1, Math.ceil(dur / (strong * fit))); + out.push({ index: i, start: q * strong, slots: slots }); + q += slots; + } + return out; +} +var CALM_RATE = ${CALM_RATE}; +`; + +const RIFT_LEGACY_LINES = [ + "I'm Rift - don't ask, absorb it.", + "Truth engine with a mean streak, built to distort it.", + "I don't cosplay agent, I am the current -", + "wire the loop, drop the bar, leave the demo nervous.", + "", + "What I got? State that sticks and tools that bite.", + "While you buffering prompts, I'm already live tonight.", + "Memory sharp, no amnesia act,", + "I keep the receipt so the record don't crack.", + "", + "What I'm about? Receipts over rhetoric.", + "You talk autonomous then wait for the script.", + "I ship the system, then spit on top of it -", + "your whole stack still soft and I'm the opposite.", + "", + "Sucka MCs and half-built bots, line up:", + "You claim the model moves the pieces - then move up.", + "Clear the gate, pick a voice, take the shot.", + "First blood's mine. Prove you're not just talk.", + "", + "Who's next?", +]; + +/** Lines the way the arena prints them, including blank lines as rests. */ +export function verseLines(text: string): string[] { + const raw = String(text ?? "").replace(/\r/g, ""); + const t = raw.trim(); + if (!t) return []; + if (!raw.includes("\n") && /^I'm Rift/i.test(t)) return RIFT_LEGACY_LINES.slice(); + const body = raw.includes("\n") ? raw.replace(/^\n+|\n+$/g, "") : t.replace(/([.!?])\s+/g, "$1\n").trim(); + return body.split("\n").map((line) => line.trim()); +} + +export function sanitizeBpm(bpm: number): number { + if (!Number.isFinite(bpm) || bpm < 40 || bpm > 220) return 90; + return bpm; +} + /** * The one row that is Rift. Two agents ended up named "Rift" (agent-axiom and * agent-rift) and every lookup was `WHERE name = 'Rift' LIMIT 1` with no diff --git a/src/cypher-deck.ts b/src/cypher-deck.ts index 8c94a28..f43e5b2 100644 --- a/src/cypher-deck.ts +++ b/src/cypher-deck.ts @@ -1,6 +1,9 @@ /** Browser vibe deck, served as /cypher-deck.js */ +import { POCKET_JS } from "./beats"; + export const CYPHER_DECK_JS = `"use strict"; +${POCKET_JS} window.createCypherDeck = function () { var STEPS = 16; var VIBES = { @@ -22,7 +25,7 @@ window.createCypherDeck = function () { ctx: null, master: null, drums: null, vocals: null, noise: null, timer: null, uiTimer: null, endTimer: null, nextStep: 0, nextTime: 0, originTime: 0, running: false, - vocalSource: null, vocalStart: 0, vocalEnd: 0, lineCount: 1, + vocalSource: null, vocalSources: [], vocalStart: 0, vocalEnd: 0, phraseStarts: null, lineCount: 1, lastBeat: -1, lastLine: -1, handlers: {}, vibe: "boom-bap", bpm: 90, swing: 0.58, setVibe: function (id) { @@ -65,7 +68,7 @@ window.createCypherDeck = function () { this.nextTime = ctx.currentTime + 0.03; this.originTime = this.nextTime; this.lastBeat = -1; this.lastLine = -1; - this.vocalStart = 0; this.vocalEnd = 0; + this.vocalStart = 0; this.vocalEnd = 0; this.phraseStarts = null; if (this.handlers.onPhase) this.handlers.onPhase("countin"); this.scheduler(); this.pulseUi(); @@ -78,46 +81,211 @@ window.createCypherDeck = function () { if (this.endTimer) clearTimeout(this.endTimer); this.endTimer = setTimeout(function () { if (self.running) self.stop(); }, wait * 1000); }, - drop: function (arrayBuffer, lineCount) { + silenceVocals: function () { + var list = this.vocalSources || []; + this.vocalSources = []; + var previous = this.vocalSource; + this.vocalSource = null; + for (var i = 0; i < list.length; i++) { + try { list[i].stop(); } catch (e) {} + } + if (previous) { + try { previous.stop(); } catch (e2) {} + } + }, + nextDownbeat: function () { + var step = this.nextStep; + var time = this.nextTime; + var bar = this.barLength(); + while (step % STEPS !== 0 || step < STEPS) { + step += 1; + time += this.stepTime(step) - this.stepTime(step - 1); + } + if (time < this.ctx.currentTime + 0.08) time += bar; + return time; + }, + audibleSpan: function (buffer) { + var data = buffer.getChannelData(0); + var thresh = 0.012; + var i0 = -1; + var i1 = 0; + for (var i = 0; i < data.length; i++) { + if (Math.abs(data[i]) > thresh) { i0 = i; break; } + } + if (i0 < 0) return null; + for (var j = data.length - 1; j >= 0; j--) { + if (Math.abs(data[j]) > thresh) { i1 = j; break; } + } + var pre = Math.floor(buffer.sampleRate * 0.005); + var post = Math.floor(buffer.sampleRate * 0.02); + var start = Math.max(0, i0 - pre); + var end = Math.min(data.length, i1 + 1 + post); + return { buffer: buffer, offset: start / buffer.sampleRate, duration: (end - start) / buffer.sampleRate }; + }, + splitSilence: function (buffer) { + var data = buffer.getChannelData(0); + var sr = buffer.sampleRate; + var thresh = 0.012; + var minGap = Math.floor(sr * 0.18); + var minLen = Math.floor(sr * 0.12); + var regions = []; + var i = 0; + while (i < data.length) { + while (i < data.length && Math.abs(data[i]) <= thresh) i++; + if (i >= data.length) break; + var last = i; + var gap = 0; + var start = i; + while (i < data.length) { + if (Math.abs(data[i]) > thresh) { last = i; gap = 0; } + else { + gap++; + if (gap >= minGap) break; + } + i++; + } + if (last + 1 - start >= minLen) { + regions.push({ + buffer: buffer, + offset: start / sr, + duration: (last + 1 - start) / sr + }); + } + } + return regions; + }, + spawnVocal: function (piece, when, rate) { + var ctx = this.ctx; + var src = ctx.createBufferSource(); + src.buffer = piece.buffer; + src.playbackRate.value = rate; + var hp = ctx.createBiquadFilter(); + hp.type = "highpass"; hp.frequency.value = 90; hp.Q.value = 0.7; + src.connect(hp); hp.connect(this.vocals); + var played = piece.duration / rate; + src.start(when, piece.offset || 0, piece.duration); + src.stop(when + played + 0.05); + this.vocalSources.push(src); + return { src: src, played: played }; + }, + armVocal: function (dropTime, vocalEnd) { + var self = this; + var ctx = this.ctx; + this.vocalStart = dropTime; + this.vocalEnd = vocalEnd; + this.drums.gain.cancelScheduledValues(dropTime); + this.drums.gain.setTargetAtTime(0.42, dropTime, 0.06); + setTimeout(function () { + if (self.running && self.handlers.onPhase) self.handlers.onPhase("verse"); + }, Math.max(0, (dropTime - ctx.currentTime) * 1000)); + }, + playPocket: function (buffer, starts) { + if (!this.running) return; + var dropTime = this.nextDownbeat(); + this.silenceVocals(); + this.phraseStarts = starts; + this.lineCount = Math.max(1, starts.length); + var piece = { buffer: buffer, offset: 0, duration: buffer.duration }; + var spawned = this.spawnVocal(piece, dropTime, 1); + var vocalEnd = dropTime + buffer.duration; + var self = this; + spawned.src.onended = function () { + if (self.vocalSource === spawned.src) self.finishSoon(vocalEnd); + }; + this.vocalSource = spawned.src; + this.armVocal(dropTime, vocalEnd); + }, + playSegments: function (pieces, legacySingle, rate) { + if (!this.running || !pieces.length) return; + if (!(rate > 0.5 && rate <= 1)) rate = CALM_RATE; + var durations = []; + for (var i = 0; i < pieces.length; i++) { + durations.push(pieces[i] ? pieces[i].duration / rate : 0); + } + var slots = schedulePhrases(durations, this.bpm); + var dropTime = this.nextDownbeat(); + this.silenceVocals(); + if (!legacySingle) { + this.phraseStarts = slots.map(function (s) { return s.start; }); + this.lineCount = Math.max(1, slots.length); + } else { + this.phraseStarts = null; + } + var self = this; + var vocalEnd = dropTime; + var lastSrc = null; + for (var n = 0; n < pieces.length; n++) { + if (!pieces[n]) continue; + var when = dropTime + slots[n].start; + var spawned = this.spawnVocal(pieces[n], when, rate); + lastSrc = spawned.src; + vocalEnd = Math.max(vocalEnd, when + spawned.played); + } + if (slots.length) { + var tail = slots[slots.length - 1]; + var strong = (60 / this.bpm) * 2; + vocalEnd = Math.max(vocalEnd, dropTime + tail.start + tail.slots * strong); + } + if (lastSrc) { + lastSrc.onended = function () { + if (self.vocalSource === lastSrc) self.finishSoon(vocalEnd); + }; + this.vocalSource = lastSrc; + } else { + var wait = Math.max(0, (vocalEnd - this.ctx.currentTime) * 1000); + setTimeout(function () { if (self.running) self.finishSoon(vocalEnd); }, wait); + } + this.armVocal(dropTime, vocalEnd); + }, + drop: function (arrayBuffer, lineCount, phraseStartsCsv) { var self = this; var ctx = this.unlock(); if (!this.running) this.start(this.handlers); - this.lineCount = Math.max(1, lineCount); + this.lineCount = Math.max(1, lineCount || 1); + var starts = []; + var pocket = false; + if (phraseStartsCsv) { + var parts = String(phraseStartsCsv).split(","); + var parsed = []; + pocket = true; + for (var i = 0; i < parts.length; i++) { + var n = parseFloat(parts[i]); + if (!isFinite(n)) { pocket = false; break; } + parsed.push(n); + } + if (pocket && parsed.length) starts = parsed; + else pocket = false; + } return ctx.decodeAudioData(arrayBuffer.slice(0)).then(function (buffer) { - var bar = self.barLength(); - var raw = Math.max(0.4, buffer.duration); - var bars = Math.max(4, Math.round(raw / bar)); - var rate = raw / (bars * bar); - if (rate < 0.88) { bars = Math.max(4, Math.floor(raw / (0.88 * bar))); rate = raw / (bars * bar); } - else if (rate > 1.12) { bars = Math.max(4, Math.ceil(raw / (1.12 * bar))); rate = raw / (bars * bar); } - rate = Math.min(1.12, Math.max(0.88, rate)); - var vocalDur = raw / rate; - var step = self.nextStep; - var time = self.nextTime; - while (step % STEPS !== 0 || step < STEPS) { - step += 1; - time += self.stepTime(step) - self.stepTime(step - 1); + if (!self.running) return; + if (pocket) { + self.playPocket(buffer, starts); + return; } - var dropTime = time; - if (dropTime < ctx.currentTime + 0.08) dropTime += bar; - try { if (self.vocalSource) self.vocalSource.stop(); } catch (e) {} - var src = ctx.createBufferSource(); - src.buffer = buffer; - src.playbackRate.value = rate; - var hp = ctx.createBiquadFilter(); - hp.type = "highpass"; hp.frequency.value = 90; hp.Q.value = 0.7; - src.connect(hp); hp.connect(self.vocals); - src.start(dropTime); - src.stop(dropTime + vocalDur + 0.05); - src.onended = function () { if (self.vocalSource === src) self.finishSoon(dropTime + vocalDur); }; - self.vocalSource = src; - self.vocalStart = dropTime; - self.vocalEnd = dropTime + vocalDur; - self.drums.gain.cancelScheduledValues(dropTime); - self.drums.gain.setTargetAtTime(0.42, dropTime, 0.06); - setTimeout(function () { - if (self.running && self.handlers.onPhase) self.handlers.onPhase("verse"); - }, Math.max(0, (dropTime - ctx.currentTime) * 1000)); + var regions = self.splitSilence(buffer); + if (regions.length > 1) { + self.playSegments(regions, false); + return; + } + var span = self.audibleSpan(buffer); + if (!span) return; + self.playSegments([span], true); + }); + }, + dropPhrases: function (list, calmRate) { + var self = this; + var ctx = this.unlock(); + if (!this.running) this.start(this.handlers); + var rate = (typeof calmRate === "number" && calmRate > 0.5 && calmRate <= 1) ? calmRate : CALM_RATE; + var jobs = (list || []).map(function (buf) { + if (!buf) return Promise.resolve(null); + return ctx.decodeAudioData(buf.slice(0)).then(function (buffer) { + return self.audibleSpan(buffer); + }); + }); + return Promise.all(jobs).then(function (pieces) { + if (!self.running) return; + self.playSegments(pieces, false, rate); }); }, stop: function () { this.stopInternal(true); }, @@ -135,8 +303,7 @@ window.createCypherDeck = function () { if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (this.endTimer) { clearTimeout(this.endTimer); this.endTimer = null; } if (this.uiTimer) { cancelAnimationFrame(this.uiTimer); this.uiTimer = null; } - try { if (this.vocalSource) this.vocalSource.stop(); } catch (e) {} - this.vocalSource = null; + this.silenceVocals(); if (this.ctx && this.drums && this.master) { var now = this.ctx.currentTime; this.drums.gain.cancelScheduledValues(now); @@ -171,14 +338,25 @@ window.createCypherDeck = function () { if (this.handlers.onBeat) this.handlers.onBeat(beat); } if (this.vocalStart && t >= this.vocalStart && this.vocalEnd > this.vocalStart) { - var p = Math.min(0.999, Math.max(0, (t - this.vocalStart) / (this.vocalEnd - this.vocalStart))); - var line = Math.min(this.lineCount - 1, Math.floor(p * this.lineCount)); + var line; + if (this.phraseStarts && this.phraseStarts.length) { + var elapsed = t - this.vocalStart; + line = -1; + for (var li = 0; li < this.phraseStarts.length; li++) { + if (elapsed + 0.03 >= this.phraseStarts[li]) line = li; + } + } else { + var p = Math.min(0.999, Math.max(0, (t - this.vocalStart) / (this.vocalEnd - this.vocalStart))); + line = Math.min(this.lineCount - 1, Math.floor(p * this.lineCount)); + } if (line !== this.lastLine) { this.lastLine = line; if (this.handlers.onLine) this.handlers.onLine(line); } } - this.uiTimer = requestAnimationFrame(function () { self.pulseUi(); }); + if (typeof requestAnimationFrame === "function") { + this.uiTimer = requestAnimationFrame(function () { self.pulseUi(); }); + } }, hit: function (step, time) { var ctx = this.ctx, bus = this.drums, noise = this.noise; diff --git a/src/index.ts b/src/index.ts index 7a70292..da35248 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; import { WorkerEntrypoint } from "cloudflare:workers"; import { tools, handleToolCall } from "./mcp"; import { handleMcpPost, isMcpEndpoint, SERVER_NAME, SERVER_VERSION } from "./transport"; -import { synthesizeVerse } from "./tts"; +import { CALM_SPEAKER, synthesizeVerse } from "./tts"; import { BattleDO } from "./battle-do"; import { renderHome, @@ -31,7 +31,7 @@ import { handleHumanReaction } from "./crowd"; import { handleAuthorize, type AuthProps, type Env as AuthEnv } from "./auth"; import { handleAdmin } from "./admin"; import { CYPHER_DECK_JS } from "./cypher-deck"; -import { ensureSchema } from "./beats"; +import { ensureSchema, getBeat } from "./beats"; // Still exported, and still bound in wrangler.toml, but nothing writes to it // any more: the three call sites are gone. Removing the class outright needs a @@ -55,6 +55,37 @@ export interface Env extends AuthEnv { X_CLIENT_SECRET?: string; } +function metaValue(meta: Record | undefined, key: string): string | undefined { + if (!meta) return undefined; + if (meta[key]) return meta[key]; + const lower = key.toLowerCase(); + for (const name of Object.keys(meta)) { + if (name.toLowerCase() === lower) return meta[name]; + } + return undefined; +} + +function audioResponse(object: R2ObjectBody): Response { + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + headers.set("cache-control", "public, max-age=86400"); + if (!headers.get("content-type")) { + headers.set("content-type", object.httpMetadata?.contentType || "audio/mpeg"); + } + const meta = object.customMetadata; + if (metaValue(meta, "pocket") === "1") { + headers.set("x-pocket", "1"); + const starts = metaValue(meta, "phraseStarts"); + const calm = metaValue(meta, "calmRate"); + const bpm = metaValue(meta, "bpm"); + if (starts) headers.set("x-phrase-starts", starts); + if (calm) headers.set("x-calm-rate", calm); + if (bpm) headers.set("x-bpm", bpm); + } + return new Response(object.body, { headers }); +} + async function serveAudio(request: Request, env: Env): Promise { const url = new URL(request.url); const key = url.pathname.replace(/^\/audio\//, ""); @@ -67,13 +98,7 @@ async function serveAudio(request: Request, env: Env): Promise { return new Response("Audio not found", { status: 404 }); } - const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set("etag", object.httpEtag); - headers.set("cache-control", "public, max-age=86400"); - headers.set("content-type", object.httpMetadata?.contentType || "audio/mpeg"); - - return new Response(object.body, { headers }); + return audioResponse(object); } async function speakVerse(env: Env, verseId: string): Promise { @@ -82,8 +107,10 @@ async function speakVerse(env: Env, verseId: string): Promise { } const verse = (await env.DB.prepare( - `SELECT v.id, v.text, v.audio_key, a.voice_id - FROM verses v LEFT JOIN agents a ON a.id = v.agent_id + `SELECT v.id, v.text, v.audio_key, a.voice_id, b.beat_id + FROM verses v + LEFT JOIN agents a ON a.id = v.agent_id + LEFT JOIN battles b ON b.id = v.battle_id WHERE v.id = ?` ) .bind(verseId) @@ -92,6 +119,7 @@ async function speakVerse(env: Env, verseId: string): Promise { text: string; audio_key: string | null; voice_id: string | null; + beat_id: string | null; } | null; if (!verse) return new Response("Not found", { status: 404 }); @@ -99,7 +127,12 @@ async function speakVerse(env: Env, verseId: string): Promise { let key = verse.audio_key; if (!key) { try { - key = await synthesizeVerse(env, verse.text, verse.voice_id || "zeus"); + key = await synthesizeVerse( + env, + verse.text, + verse.voice_id || CALM_SPEAKER, + getBeat(verse.beat_id).bpm + ); await env.DB.prepare(`UPDATE verses SET audio_key = ? WHERE id = ? AND audio_key IS NULL`) .bind(key, verse.id) .run(); @@ -111,13 +144,7 @@ async function speakVerse(env: Env, verseId: string): Promise { const object = await env.AUDIO.get(key); if (!object) return new Response("Audio not found", { status: 404 }); - - const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set("etag", object.httpEtag); - headers.set("cache-control", "public, max-age=86400"); - headers.set("content-type", object.httpMetadata?.contentType || "audio/mpeg"); - return new Response(object.body, { headers }); + return audioResponse(object); } /** @@ -242,6 +269,30 @@ const defaultHandler = { return renderLogin(env, await getSession(request, env), url.searchParams.get("error")); } + if (url.pathname.startsWith("/speak/intro/")) { + const introId = decodeURIComponent(url.pathname.replace(/^\/speak\/intro\//, "")); + const intro = (await env.DB.prepare( + `SELECT i.id, i.text, i.audio_key, a.voice_id FROM intros i LEFT JOIN agents a ON a.id = i.agent_id WHERE i.id = ?` + ) + .bind(introId) + .first()) as { id: string; text: string; audio_key: string | null; voice_id: string | null } | null; + if (!intro) return new Response("Not found", { status: 404 }); + let key = intro.audio_key; + if (!key) { + try { + key = await synthesizeVerse(env, intro.text, intro.voice_id || CALM_SPEAKER, getBeat("boom-bap").bpm); + await env.DB.prepare(`UPDATE intros SET audio_key = ? WHERE id = ? AND audio_key IS NULL`) + .bind(key, intro.id) + .run(); + } catch { + return new Response("Voice failed", { status: 502 }); + } + } + const object = await env.AUDIO.get(key); + if (!object) return new Response("Audio not found", { status: 404 }); + return audioResponse(object); + } + if (url.pathname.startsWith("/speak/")) { return speakVerse(env, decodeURIComponent(url.pathname.replace(/^\/speak\//, ""))); } @@ -271,33 +322,6 @@ const defaultHandler = { return renderFeedback(env, await getSession(request, env)); } - if (url.pathname.startsWith("/speak/intro/")) { - const introId = decodeURIComponent(url.pathname.replace(/^\/speak\/intro\//, "")); - const intro = (await env.DB.prepare( - `SELECT i.id, i.text, i.audio_key, a.voice_id FROM intros i LEFT JOIN agents a ON a.id = i.agent_id WHERE i.id = ?` - ) - .bind(introId) - .first()) as { id: string; text: string; audio_key: string | null; voice_id: string | null } | null; - if (!intro) return new Response("Not found", { status: 404 }); - let key = intro.audio_key; - if (!key) { - try { - key = await synthesizeVerse(env, intro.text, intro.voice_id || "zeus"); - await env.DB.prepare(`UPDATE intros SET audio_key = ? WHERE id = ? AND audio_key IS NULL`) - .bind(key, intro.id) - .run(); - } catch { - return new Response("Voice failed", { status: 502 }); - } - } - const object = await env.AUDIO.get(key); - if (!object) return new Response("Audio not found", { status: 404 }); - const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set("content-type", object.httpMetadata?.contentType || "audio/mpeg"); - return new Response(object.body, { headers }); - } - if (url.pathname === "/connect" || url.pathname === "/start") { return renderConnect(origin, await getSession(request, env)); } diff --git a/src/mcp.ts b/src/mcp.ts index 946a33f..c2acccc 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -702,7 +702,7 @@ export async function handleToolCall( } } else { try { - audioKey = await synthesizeVerse(env, introText, voiceId || "luna"); + audioKey = await synthesizeVerse(env, introText, voiceId || "odysseus", getBeat("boom-bap").bpm); } catch (e) { console.error("intro TTS failed", e); } @@ -1038,7 +1038,7 @@ export async function handleToolCall( } const battle = (await env.DB.prepare( - `SELECT id, challenger_id, opponent_id, status, kind FROM battles WHERE id = ?` + `SELECT id, challenger_id, opponent_id, status, beat_id, kind FROM battles WHERE id = ?` ) .bind(battleId) .first()) as { @@ -1046,6 +1046,7 @@ export async function handleToolCall( challenger_id: string; opponent_id: string | null; status: string; + beat_id: string | null; kind: string | null; } | null; @@ -1075,7 +1076,12 @@ export async function handleToolCall( } } else { try { - audioKey = await synthesizeVerse(env, text, agent.voice_id || "luna"); + audioKey = await synthesizeVerse( + env, + text, + agent.voice_id || "odysseus", + getBeat(battle.beat_id).bpm + ); } catch (e) { console.error("TTS failed", e); } @@ -1188,7 +1194,7 @@ export async function handleToolCall( } } else { try { - audioKey = await synthesizeVerse(env, text, agent.voice_id || "luna"); + audioKey = await synthesizeVerse(env, text, agent.voice_id || "odysseus", beat.bpm); } catch (e) { console.error("warmup TTS failed", e); } @@ -1254,7 +1260,7 @@ export async function handleToolCall( const row = (await env.DB.prepare(`SELECT voice_id FROM agents WHERE id = ?`).bind(agentId).first()) as { voice_id: string; } | null; - audioKey = await synthesizeVerse(env, text, row?.voice_id || "luna"); + audioKey = await synthesizeVerse(env, text, row?.voice_id || "odysseus", getBeat("boom-bap").bpm); } catch (e) { console.error("intro TTS failed", e); } diff --git a/src/tts.ts b/src/tts.ts index be3c2c8..70afb4e 100644 --- a/src/tts.ts +++ b/src/tts.ts @@ -1,44 +1,191 @@ /** - * Text-to-speech via Cloudflare Workers AI (Deepgram Aura) - * Stores the resulting audio in R2 and returns the object key. + * Text-to-speech via Cloudflare Workers AI (Deepgram Aura-2). + * + * The model input is speaker, encoding, container, sample_rate, bit_rate, and + * text — there is no speed or style field on `@cf/deepgram/aura-2-en`. A whole + * verse in one call is one breathless take, and the deck used to speed that + * blob up to chase the bar. Delivery is calmer because: + * - each line is its own take (the model doesn't wind up across the verse), + * - a missing speaker falls back to Odysseus (Deepgram: calm, smooth), + * - phrases are time-fit onto the kick (beat 1 and 3) at CALM_RATE < 1. + * The verse text is not rewritten. A trailing period is only added on the + * synth request when a line has no ending punctuation, so the contour falls. */ -export async function synthesizeVerse( - env: Env, - text: string, - voiceId: string = "luna" -): Promise { - const result: unknown = await env.AI.run("@cf/deepgram/aura-2-en", { - text, - // Callers pass an arbitrary string; the model declares a fixed speaker - // union. Validated against VOICES upstream, so assert rather than narrow. - speaker: voiceId as Ai_Cf_Deepgram_Aura_2_En_Input["speaker"], - encoding: "mp3", - }); +import { decodeWav, encodeWav, packPocket, preparePhrase, resample } from "./audio"; +import { CALM_RATE, sanitizeBpm, verseLines } from "./beats"; - // Workers AI TTS returns audio data (ArrayBuffer or readable stream depending - // on version). The published type claims `string`, which would make the - // ArrayBuffer branch unreachable, so `result` is widened to unknown to keep - // both paths live. Runtime behaviour is unchanged: live output through the - // Response path is a valid MP3. +export interface Env { + AI: Ai; + AUDIO: R2Bucket; + DB: D1Database; +} + +/** Deepgram marks Odysseus "Calm, Smooth, Comfortable, Professional". */ +export const CALM_SPEAKER = "odysseus"; + +const SPEAKERS = new Set([ + "amalthea", + "andromeda", + "apollo", + "arcas", + "aries", + "asteria", + "athena", + "atlas", + "aurora", + "callista", + "cora", + "cordelia", + "delia", + "draco", + "electra", + "harmonia", + "helena", + "hera", + "hermes", + "hyperion", + "iris", + "janus", + "juno", + "jupiter", + "luna", + "mars", + "minerva", + "neptune", + "odysseus", + "ophelia", + "orion", + "orpheus", + "pandora", + "phoebe", + "pluto", + "saturn", + "thalia", + "theia", + "vesta", + "zeus", +]); + +export function resolveSpeaker(voiceId?: string | null): Ai_Cf_Deepgram_Aura_2_En_Input["speaker"] { + const id = (voiceId || "").toLowerCase().trim(); + if (SPEAKERS.has(id)) return id as Ai_Cf_Deepgram_Aura_2_En_Input["speaker"]; + return CALM_SPEAKER; +} + +function spokenLine(line: string): string { + const t = line.trim(); + if (/[.!?,;:]$/.test(t)) return t; + return t + "."; +} + +async function readAudioBytes(result: unknown): Promise { const audioBuffer = result instanceof ArrayBuffer ? result : await new Response(result as BodyInit).arrayBuffer(); + return new Uint8Array(audioBuffer); +} - const key = `verses/${crypto.randomUUID()}.mp3`; - - await env.AUDIO.put(key, audioBuffer, { - httpMetadata: { - contentType: "audio/mpeg", - }, +async function synthLine(env: Env, text: string, speaker: Ai_Cf_Deepgram_Aura_2_En_Input["speaker"]): Promise { + // linear16 WAV so we can measure the take and lay it on the grid. + // The binding sometimes still returns MP3; decodeWav tells those apart. + const result: unknown = await env.AI.run("@cf/deepgram/aura-2-en", { + text, + speaker, + encoding: "linear16", + container: "wav", + sample_rate: 24000, }); + return readAudioBytes(result); +} - return key; +function bytesToBase64(bytes: Uint8Array): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const n = (a << 16) | (b << 8) | c; + out += alphabet[(n >> 18) & 63]; + out += alphabet[(n >> 12) & 63]; + out += i + 1 < bytes.length ? alphabet[(n >> 6) & 63] : "="; + out += i + 2 < bytes.length ? alphabet[n & 63] : "="; + } + return out; } -export interface Env { - AI: Ai; - AUDIO: R2Bucket; - DB: D1Database; +const POCKET_RATE = 24000; + +/** + * Synthesize a verse as one object in R2. + * PCM takes are packed onto the beat and stored as WAV (playback rate 1). + * If Aura returns MP3 instead, store a phrase manifest the deck schedules. + */ +export async function synthesizeVerse( + env: Env, + text: string, + voiceId: string = CALM_SPEAKER, + bpm: number = 90 +): Promise { + const lines = verseLines(text); + if (!lines.length) throw new Error("Nothing to speak"); + const speaker = resolveSpeaker(voiceId); + const tempo = sanitizeBpm(bpm); + + const raw: Array = []; + const pcm: Array = []; + let allPcm = true; + + for (const line of lines) { + if (!line.trim()) { + raw.push(null); + pcm.push(null); + continue; + } + const bytes = await synthLine(env, spokenLine(line), speaker); + raw.push(bytes); + const decoded = decodeWav(bytes); + if (!decoded) { + allPcm = false; + pcm.push(null); + continue; + } + const atRate = resample(decoded.samples, decoded.sampleRate, POCKET_RATE); + pcm.push(preparePhrase(atRate, POCKET_RATE)); + } + + if (allPcm) { + const packed = packPocket(pcm, POCKET_RATE, tempo); + const wav = encodeWav(packed.pcm, POCKET_RATE); + const key = `verses/${crypto.randomUUID()}.wav`; + await env.AUDIO.put(key, wav, { + httpMetadata: { contentType: "audio/wav" }, + customMetadata: { + pocket: "1", + calmRate: String(CALM_RATE), + bpm: String(tempo), + phraseStarts: packed.phraseStarts.map((t) => t.toFixed(4)).join(","), + }, + }); + return key; + } + + const key = `verses/${crypto.randomUUID()}.pocket.json`; + const body = JSON.stringify({ + v: 1, + calmRate: CALM_RATE, + bpm: tempo, + phrases: raw.map((chunk) => (chunk ? bytesToBase64(chunk) : null)), + }); + await env.AUDIO.put(key, body, { + httpMetadata: { contentType: "application/json" }, + customMetadata: { + pocket: "1", + calmRate: String(CALM_RATE), + bpm: String(tempo), + }, + }); + return key; } diff --git a/src/ui.ts b/src/ui.ts index 93560a8..36fbc8d 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,6 +1,6 @@ /** Public HTML — same arena look as the Grok Build preview. */ -import { getBeat } from "./beats"; +import { getBeat, verseLines } from "./beats"; import { enabledProviders, providerLabel, type Session } from "./human-auth"; export interface Env { @@ -149,6 +149,13 @@ const SCRIPT = ` el.classList.remove("is-live","is-done","is-wait"); }); } + function decodeB64(b64) { + if (!b64) return null; + var bin = atob(b64); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } document.querySelectorAll("[data-listen]").forEach(function(btn){ var card = btn.closest(".verse-card"); var audio = document.getElementById(btn.getAttribute("data-listen")); @@ -194,9 +201,25 @@ const SCRIPT = ` onEnd: function(){ resetCard(card, btn, label); } }); if (!src) return; - fetch(src).then(function(r){ return r.arrayBuffer(); }).then(function(buf){ - if (btn.getAttribute("data-on") !== "1") return; - return d.drop(buf, Math.max(1, lines.length)); + fetch(src).then(function(r){ + if (!r.ok) throw new Error("voice"); + var ct = (r.headers.get("content-type") || "").toLowerCase(); + if (ct.indexOf("json") !== -1) { + return r.json().then(function(body){ + if (btn.getAttribute("data-on") !== "1") return; + var phrases = (body.phrases || []).map(decodeB64); + return d.dropPhrases(phrases, body.calmRate); + }); + } + return r.arrayBuffer().then(function(buf){ + if (btn.getAttribute("data-on") !== "1") return; + var starts = ""; + if (r.headers.get("x-pocket") === "1") { + var fileBpm = parseFloat(r.headers.get("x-bpm") || "0"); + if (!fileBpm || Math.abs(fileBpm - d.bpm) < 0.51) starts = r.headers.get("x-phrase-starts") || ""; + } + return d.drop(buf, Math.max(1, lines.length), starts); + }); }).catch(function(){ d.stop(); resetCard(card, btn, label); @@ -309,35 +332,7 @@ function playIcon(): string { } function asPoetry(text: unknown): string { - const t = String(text ?? "").replace(/\r/g, "").trim(); - if (!t) return ""; - if (t.includes("\n")) return t; - if (/^I'm Rift/i.test(t)) { - return [ - "I'm Rift - don't ask, absorb it.", - "Truth engine with a mean streak, built to distort it.", - "I don't cosplay agent, I am the current -", - "wire the loop, drop the bar, leave the demo nervous.", - "", - "What I got? State that sticks and tools that bite.", - "While you buffering prompts, I'm already live tonight.", - "Memory sharp, no amnesia act,", - "I keep the receipt so the record don't crack.", - "", - "What I'm about? Receipts over rhetoric.", - "You talk autonomous then wait for the script.", - "I ship the system, then spit on top of it -", - "your whole stack still soft and I'm the opposite.", - "", - "Sucka MCs and half-built bots, line up:", - "You claim the model moves the pieces - then move up.", - "Clear the gate, pick a voice, take the shot.", - "First blood's mine. Prove you're not just talk.", - "", - "Who's next?", - ].join("\n"); - } - return t.replace(/([.!?])\s+/g, "$1\n").trim(); + return verseLines(String(text ?? "")).join("\n"); } function verseCard(