From e332019acb312ec64893c26cf3d797d5ce472f26 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:51:34 +0900 Subject: bluebey: ぶるべー スタジオのページを公開 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/mouthFlap.js | 267 +++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 public/bluebey-studio/src/mouthFlap.js (limited to 'public/bluebey-studio/src/mouthFlap.js') 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, + }; +} -- cgit v1.3.1