/** * 口パク (mouth flap): open and close the mouth as if talking, with no sound. * * WHY no speech: the studio used to read the caption out with the Web Speech * API, but that voice belongs to the device (it is the OS's own speech engine), * so a recording of it is not something we are free to hand out. What a clip * actually needs is the *mouth motion*, and that needs no voice at all: * `createMouthEnvelope` builds a smooth, seeded signal in the 3-6 * syllable-per-second band, and `createMouthFlap` ticks it at about 30 Hz for * as long as the line would take to say. * * Nothing here touches `speechSynthesis`, so there is no permission prompt, no * device dependency, and a recording of the animation is the studio's own work. */ const TAU = Math.PI * 2; /** The mouth is sampled at ~30 Hz: enough for an animation, cheap to run. */ const LEVEL_HZ = 30; /** How long the envelope takes to close after `stop()`, in seconds. */ const CLOSE_SECONDS = 0.3; const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); const clamp01 = (value) => clamp(value, 0, 1); /** * mulberry32, the same tiny generator `handDrawn.js` uses. The mouth must be * reproducible for a seed, so `Math.random` is not an option. * * @param {number} seed * @returns {() => number} values in [0, 1) */ function mulberry32(seed) { let a = seed >>> 0; return function next() { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * The mouth source: a deterministic, smooth mouth opening in 0..1. * * A syllable is one closed -> open -> closed cycle, so the signal is a cosine * pulse whose frequency is slowly modulated between 3 and 6 Hz and shaped by a * slower amplitude wobble. The syllable range and the modulation constants come * from the seed, so two envelopes with the same seed are exactly identical, and * the signal is continuous with a bounded slope: it never jumps, which is what * keeps the mouth from flickering. * * @param {{ seed?: number }} [options] * @returns {{ value: (t: number) => number, start: (at?: number) => void, stop: () => void }} */ export function createMouthEnvelope({ seed = 1 } = {}) { const random = mulberry32(seed); const phase0 = random() * TAU; const phase1 = random() * TAU; // 4.2..4.8 syllables/s on average, swung by up to 1.2 either way, then kept // inside 3..6 so the read stays in the range of human speech. const rateMean = 4.2 + random() * 0.6; const rateSwing = Math.min(rateMean - 3, 6 - rateMean, 0.7 + random() * 0.8); const rateMod = 0.21 + random() * 0.25; const ampMod = 0.4 + random() * 0.3; let startAt = 0; let stopAt = Infinity; let lastAt = 0; /** The envelope at `u` seconds after the start, ignoring start and stop. */ function raw(u) { // The integral of the modulated rate, so theta stays continuous (a // modulated sine would kink whenever the rate changed). const theta = TAU * (rateMean * u + (rateSwing / (TAU * rateMod)) * (Math.cos(phase0) - Math.cos(TAU * rateMod * u + phase0))); const pulse = 0.5 - 0.5 * Math.cos(theta); const amp = 0.55 + 0.2 * Math.sin(TAU * ampMod * u + phase1); return clamp01(amp * pulse); } return { /** * The mouth opening at `t`, in the caller's own time base. Before `start` * it is 0, and after `stop()` it fades to 0 within `CLOSE_SECONDS`. * * @param {number} t * @returns {number} 0..1 */ value(t) { const time = Number.isFinite(t) ? t : 0; if (time > lastAt) lastAt = time; if (time < startAt) return 0; const fading = time - stopAt; if (fading > 0) { const fade = 1 - fading / CLOSE_SECONDS; if (fade <= 0) return 0; return raw(time - startAt) * fade; } return raw(time - startAt); }, /** Begin (or restart) the envelope at `at`, opening from closed. */ start(at = 0) { startAt = Number.isFinite(at) ? at : 0; stopAt = Infinity; lastAt = startAt; }, /** Stop: the envelope decays to 0 from wherever it is. */ stop() { stopAt = lastAt; }, }; } /** * Map a 0..1 level to the app's mouth-opening range. * * WHY not `level` straight through: a half-open mouth is the readable one, and * a full gape looks like a shout. 0 stays 0 (a closed mouth must stay closed) * and 1 lands at 0.55. `gain` lets a caller push the mouth wider for a loud * passage, but the result is clamped to 0..0.9 so it is never a full gape. * * @param {number} level * @param {number} [gain=1] * @returns {number} 0..0.9 */ export function levelToMouth(level, gain = 1) { if (!Number.isFinite(level)) return 0; const scale = Number.isFinite(gain) ? gain : 1; return clamp(clamp01(level) * 0.55 * scale, 0, 0.9); } /** * Roughly how long a line takes to say, in seconds **at rate 1**. * * A Japanese syllable is about 0.155 s, so the count of characters is a good * enough clock - and it is the only clock available once there is no voice to * listen to. An empty line still gets a few seconds, so the button never looks * like it did nothing. * * @param {string} text * @returns {number} seconds, 1.1..40 (or 3 for an empty line) */ export function speakingSeconds(text) { const chars = [...String(text ?? '').trim()].length; if (chars === 0) return 3; return clamp(0.55 + chars * 0.155, 1.1, 40); } /** * The mouth-flap animation. * * `start(text, { rate })` opens the mouth in the seeded syllable rhythm and * stops itself once the line would have been said, then reports `onEnd`. A * second `start` restarts it, and `stop()` fades the mouth shut early - which is * what the panel's button does while it is running. * * @param {{ * onLevel?: (level: number) => void, * onEnd?: () => void, * }} [options] */ export function createMouthFlap({ onLevel, onEnd } = {}) { const envelope = createMouthEnvelope(); let handlers = { onLevel, onEnd }; let timer = null; let closing = false; let running = false; let startedAt = 0; let lastEmit = 0; let currentRate = 1; /** The envelope's own time at which the line is over. */ let limitAt = Infinity; const nowSeconds = () => (globalThis.performance?.now?.() ?? Date.now()) / 1000; const rafSupported = typeof globalThis.requestAnimationFrame === 'function'; const schedule = (fn) => (rafSupported ? globalThis.requestAnimationFrame(fn) : globalThis.setTimeout(fn, 1000 / LEVEL_HZ)); const unschedule = (id) => { if (id == null) return; if (rafSupported) globalThis.cancelAnimationFrame(id); else globalThis.clearTimeout(id); }; /** * One level sample, about 30 times a second. Once the line has run its * length the loop stops itself, and while the moth is closing it keeps going * until the mouth is shut - so no timer is ever left behind. */ function tick() { timer = null; const stamp = nowSeconds(); const elapsed = (stamp - startedAt) * currentRate; if (!closing && elapsed >= limitAt) { closing = true; envelope.stop(); } const level = envelope.value(elapsed); if (closing || stamp - lastEmit >= 1 / LEVEL_HZ - 0.001) { lastEmit = stamp; handlers.onLevel?.(level); } if (closing && level <= 0) { running = false; handlers.onEnd?.(); return; } timer = schedule(tick); } /** * Start flapping. `seconds` overrides the length guessed from the text. * * @param {string} text * @param {{ rate?: number, seconds?: number }} [options] * @returns {number} how long the flap will run, in seconds */ function start(text, { rate = 1, seconds = null } = {}) { stopNow(); currentRate = clamp(Number.isFinite(rate) ? rate : 1, 0.2, 4); limitAt = Number.isFinite(seconds) && seconds > 0 ? seconds : speakingSeconds(text); startedAt = nowSeconds(); lastEmit = 0; closing = false; running = true; envelope.start(0); timer = schedule(tick); return limitAt / currentRate; } /** Clear the timer immediately (no fade). */ function stopNow() { unschedule(timer); timer = null; closing = false; running = false; envelope.stop(); } /** Fade the mouth shut and let the loop clean itself up. */ function stop() { if (timer == null) { running = false; envelope.stop(); return; } closing = true; envelope.stop(); } function dispose() { stopNow(); handlers = { onLevel: null, onEnd: null }; } return { get running() { return running; }, start, stop, dispose, }; }