1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
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,
};
}
|