aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/mouthFlap.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/mouthFlap.js')
-rw-r--r--public/bluebey-studio/src/mouthFlap.js267
1 files changed, 0 insertions, 267 deletions
diff --git a/public/bluebey-studio/src/mouthFlap.js b/public/bluebey-studio/src/mouthFlap.js
deleted file mode 100644
index 1109398..0000000
--- a/public/bluebey-studio/src/mouthFlap.js
+++ /dev/null
@@ -1,267 +0,0 @@
-/**
- * 口パク (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,
- };
-}