aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/mouthFlap.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
commite332019acb312ec64893c26cf3d797d5ce472f26 (patch)
treed97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/mouthFlap.js
parent99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff)
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/mouthFlap.js')
-rw-r--r--public/bluebey-studio/src/mouthFlap.js267
1 files changed, 267 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/mouthFlap.js b/public/bluebey-studio/src/mouthFlap.js
new file mode 100644
index 0000000..1109398
--- /dev/null
+++ b/public/bluebey-studio/src/mouthFlap.js
@@ -0,0 +1,267 @@
+/**
+ * 口パク (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,
+ };
+}