From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:09:40 +0900 Subject: bluebey-studio: public/ の外へ移動し非公開化 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 deletions(-) delete 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 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, - }; -} -- cgit v1.3.1