studio-engine 0.119.1 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ // studio/voice_profile: the voice row on /profile (studio/profiles/_voice_section).
2
+ // The signed-in person reads three short phrases; each take is recorded, turned
3
+ // into a speaker embedding ON THIS DEVICE, and thrown away. Save posts only the
4
+ // vectors; the server averages them into one voice on the person's own record
5
+ // (Studio::VoiceMatch). No audio leaves the device for this.
6
+ //
7
+ // <section data-studio-controller="voice-profile" data-voice-state="idle"
8
+ // data-voice-save-url="/profile/voice" data-voice-embedder='{"name":"wespeaker",...}'>
9
+ //
10
+ // The controller (studio/controllers/voice_profile_controller) calls
11
+ // mountVoiceProfile(element) and destroys it on disconnect. Ported from
12
+ // Voicebox's voice setup (McRitchie-Studio/voicebox#10), where the embedder, the
13
+ // level meter and the silence threshold were measured.
14
+ //
15
+ // STATES, on data-voice-state: idle (the summary) -> ready (setup open, phrase
16
+ // n waiting) -> recording -> thinking -> ready ... -> complete -> saving. Each
17
+ // phrase carries data-phrase-state: waiting, current or done. A take the meter
18
+ // heard nothing in is refused (data-voice-last-take="silent") and the phrase
19
+ // stays current. While recording, --studio-voice-level (0..1) on the root drives
20
+ // the ring around the record button.
21
+ //
22
+ // EMBEDDERS, named by the page (Studio::VoiceMatch.browser_config):
23
+ // wespeaker WeSpeaker ResNet34 (VoxCeleb), int8, through transformers.js on
24
+ // onnxruntime-web, from jsDelivr and Hugging Face at a pinned
25
+ // revision. Loaded when the setup opens; the browser caches it.
26
+ // fake Tests only: a fixed vector per voice name (window.studioFakeVoice),
27
+ // because Chrome's fake microphone cannot play chosen audio.
28
+ //
29
+ // The level meter, the silence rule and the recording format are the engine's
30
+ // one copy (studio/level_meter, studio/voice_recorder; docs/VOICE_CAPTURE.md),
31
+ // re-exported here so callers of this module keep their names. The model
32
+ // runtime is a dynamic import of the transformers.js URL the page names
33
+ // (Studio::VoiceMatch::TRANSFORMERS_URL), made only once someone opens the
34
+ // setup. test/javascript/voice_profile.test.mjs loads it with its studio/
35
+ // imports inlined (test/javascript/support/studio_module.mjs).
36
+ import { SILENCE_RMS, rootMeanSquare, peakWindowRms, meterLevel, takeWasHeard, createLevelMeter } from "studio/level_meter"
37
+ import { pickMimeType } from "studio/voice_recorder"
38
+
39
+ export { SILENCE_RMS, rootMeanSquare, peakWindowRms, meterLevel, takeWasHeard, pickMimeType }
40
+
41
+ // The model hears 16 kHz mono; a take is cut to its first MAX_SECONDS.
42
+ export const SAMPLE_RATE = 16000
43
+ export const MAX_SECONDS = 10
44
+
45
+ // A phrase is about four seconds; the take stops itself after TAKE_MS.
46
+ export const TAKE_MS = 6000
47
+
48
+ export const COPY = {
49
+ idle: ["", ""],
50
+ ready: ["Record phrase {n}", "Tap Record, then read phrase {n} out loud."],
51
+ recording: ["Stop", "Listening... read phrase {n}, then tap Stop."],
52
+ thinking: ["One moment...", "Learning your voice..."],
53
+ complete: ["All three done", "All three done. Tap Save."],
54
+ saving: ["Saving...", "Saving..."]
55
+ }
56
+
57
+ export const MESSAGES = {
58
+ silent: "I couldn't hear that one. Check your microphone and read it again.",
59
+ failed: "That one didn't work. Tap Record and read it again.",
60
+ denied: "Allow the microphone to set up your voice.",
61
+ unsupported: "This browser can't record here. Try Safari or Chrome.",
62
+ network: "We couldn't reach the server. Tap Save again.",
63
+ notSaved: "That didn't save. Tap Save again."
64
+ }
65
+
66
+ export class SilentTakeError extends Error {}
67
+
68
+ // The words for a state, with {n} the phrase being read (1-based).
69
+ export function copyFor(state, step) {
70
+ const [button, status] = COPY[state] || ["Try again", ""]
71
+ const n = String(step + 1)
72
+ return { button: button.replace("{n}", n), status: status.replace("{n}", n) }
73
+ }
74
+
75
+ // Each phrase's state for a step (how many takes are kept).
76
+ export function phraseStates(total, step) {
77
+ return Array.from({ length: total }, (_, i) => (i < step ? "done" : (i === step ? "current" : "waiting")))
78
+ }
79
+
80
+ // mulberry32: a small seeded generator, so the fake's vectors are the same in
81
+ // every run and every browser.
82
+ function seededRandom(seed) {
83
+ let a = seed >>> 0
84
+ return () => {
85
+ a = (a + 0x6D2B79F5) >>> 0
86
+ let t = a
87
+ t = Math.imul(t ^ (t >>> 15), t | 1)
88
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
89
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
90
+ }
91
+ }
92
+
93
+ function hashString(text) {
94
+ let h = 2166136261
95
+ for (let i = 0; i < text.length; i++) {
96
+ h ^= text.charCodeAt(i)
97
+ h = Math.imul(h, 16777619)
98
+ }
99
+ return h >>> 0
100
+ }
101
+
102
+ // The fake embedder's vector for a voice name: the same name always gives the
103
+ // same vector, and two names give nearly unrelated ones (cosine near 0).
104
+ export function fakeVector(voice, size = 256) {
105
+ const random = seededRandom(hashString(String(voice)))
106
+ return Array.from({ length: size }, () => random() * 2 - 1)
107
+ }
108
+
109
+ // A recorded Blob as 16 kHz mono floats: the browser decodes the container
110
+ // (webm/opus or mp4/aac) and resamples it while rendering offline.
111
+ export async function decodeToMono16k(blob, win = globalThis) {
112
+ const bytes = await blob.arrayBuffer()
113
+ const Offline = win.OfflineAudioContext || win.webkitOfflineAudioContext
114
+ const probe = new Offline(1, 1, SAMPLE_RATE)
115
+ const decoded = await probe.decodeAudioData(bytes)
116
+ const seconds = Math.min(decoded.duration, MAX_SECONDS)
117
+ const length = Math.max(1, Math.ceil(seconds * SAMPLE_RATE))
118
+ const offline = new Offline(1, length, SAMPLE_RATE)
119
+ const source = offline.createBufferSource()
120
+ source.buffer = decoded
121
+ source.connect(offline.destination) // a mono destination downmixes
122
+ source.start()
123
+ const rendered = await offline.startRendering()
124
+ return rendered.getChannelData(0)
125
+ }
126
+
127
+ class WespeakerEmbedder {
128
+ constructor(config) { this.config = config }
129
+
130
+ get name() { return this.config.name }
131
+ get id() { return this.config.id }
132
+
133
+ // Starts the download (runtime ~4.3 MB, model ~6.7 MB, cached after the first
134
+ // time) without waiting for it. Safe to call more than once; a failed load
135
+ // can be retried.
136
+ warm() {
137
+ if (!this.loading) {
138
+ this.loading = (async () => {
139
+ // By full URL (the page's config.runtime), never an importmap pin.
140
+ const transformers = await import(this.config.runtime)
141
+ // The model comes from the Hugging Face hub at a pinned revision, never
142
+ // from this app's own /models path.
143
+ transformers.env.allowLocalModels = false
144
+ const options = { revision: this.config.revision }
145
+ const [processor, model] = await Promise.all([
146
+ transformers.AutoProcessor.from_pretrained(this.config.model, options),
147
+ transformers.AutoModel.from_pretrained(this.config.model, { ...options, dtype: this.config.dtype })
148
+ ])
149
+ return { processor, model }
150
+ })()
151
+ this.loading.catch(() => { this.loading = null })
152
+ }
153
+ return this.loading
154
+ }
155
+
156
+ async embed(blob) {
157
+ const samples = await decodeToMono16k(blob)
158
+ if (peakWindowRms(samples) < SILENCE_RMS) throw new SilentTakeError("silent take")
159
+ const { processor, model } = await this.warm()
160
+ const inputs = await processor(samples)
161
+ const output = await model(inputs)
162
+ const tensor = output.embeddings || output.last_hidden_state || Object.values(output)[0]
163
+ return Array.from(tensor.data)
164
+ }
165
+ }
166
+
167
+ class FakeEmbedder {
168
+ constructor(config, win) {
169
+ this.config = config
170
+ this.win = win
171
+ }
172
+
173
+ get name() { return this.config.name }
174
+ get id() { return this.config.id }
175
+
176
+ warm() { return Promise.resolve() }
177
+
178
+ async embed(blob) {
179
+ if (!blob || blob.size === 0) throw new SilentTakeError("empty take")
180
+ return fakeVector((this.win && this.win.studioFakeVoice) || "default", this.config.size)
181
+ }
182
+ }
183
+
184
+ const EMBEDDERS = { wespeaker: WespeakerEmbedder, fake: FakeEmbedder }
185
+
186
+ // The embedder the page names, or null when it names none this script knows.
187
+ export function embedderFor(config, win = globalThis) {
188
+ if (!config || !config.name) return null
189
+ const Embedder = EMBEDDERS[config.name]
190
+ return Embedder ? new Embedder(config, win) : null
191
+ }
192
+
193
+ function parseJson(text) {
194
+ try { return JSON.parse(text || "null") } catch (_) { return null }
195
+ }
196
+
197
+ // Binds the voice row. Returns { destroy }.
198
+ export function mountVoiceProfile(root, win = window) {
199
+ const doc = root.ownerDocument
200
+ const phrases = () => Array.from(root.querySelectorAll("[data-voice-phrase]"))
201
+ const one = (selector) => root.querySelector(selector)
202
+ const embedder = embedderFor(parseJson(root.getAttribute("data-voice-embedder")), win)
203
+ const saveUrl = root.getAttribute("data-voice-save-url")
204
+
205
+ let vectors = []
206
+ let state = "idle"
207
+ let stream = null
208
+ let recorder = null
209
+ let chunks = []
210
+ let timer = null
211
+ let meter = null
212
+ let starting = false
213
+ let destroyed = false
214
+
215
+ function setState(next, message = null) {
216
+ state = next
217
+ root.dataset.voiceState = next
218
+ const states = phraseStates(phrases().length, vectors.length)
219
+ phrases().forEach((phrase, i) => { phrase.dataset.phraseState = states[i] })
220
+ const words = copyFor(next, vectors.length)
221
+ const label = one("[data-voice-record-label]")
222
+ if (label && words.button) label.textContent = words.button
223
+ const status = one("[data-voice-status]")
224
+ if (status) status.textContent = message || words.status
225
+ const button = one("[data-voice-record]")
226
+ if (button) {
227
+ button.disabled = !["ready", "recording"].includes(next)
228
+ button.setAttribute("aria-pressed", next === "recording" ? "true" : "false")
229
+ }
230
+ const save = one("[data-voice-save]")
231
+ if (save) save.disabled = next !== "complete"
232
+ }
233
+
234
+ function settle(message = null) {
235
+ setState(vectors.length >= phrases().length ? "complete" : "ready", message)
236
+ }
237
+
238
+ function supported() {
239
+ return !!(win.MediaRecorder && win.navigator && win.navigator.mediaDevices && win.navigator.mediaDevices.getUserMedia)
240
+ }
241
+
242
+ function open() {
243
+ if (state !== "idle") return
244
+ vectors = []
245
+ const setup = one("[data-voice-setup]")
246
+ if (setup) setup.hidden = false
247
+ const summary = one("[data-voice-summary]")
248
+ if (summary) summary.hidden = true
249
+ if (!embedder || !supported()) {
250
+ setState("ready", MESSAGES.unsupported)
251
+ const button = one("[data-voice-record]")
252
+ if (button) button.disabled = true
253
+ return
254
+ }
255
+ embedder.warm().catch(() => {})
256
+ setState("ready")
257
+ }
258
+
259
+ function cancel() {
260
+ stopTake(false)
261
+ vectors = []
262
+ const setup = one("[data-voice-setup]")
263
+ if (setup) setup.hidden = true
264
+ const summary = one("[data-voice-summary]")
265
+ if (summary) summary.hidden = false
266
+ setState("idle")
267
+ }
268
+
269
+ function paintLevel(level) {
270
+ root.style.setProperty("--studio-voice-level", level.toFixed(3))
271
+ root.dataset.voiceLevel = level.toFixed(2)
272
+ }
273
+
274
+ // The engine's one meter (studio/level_meter) on the take's own stream.
275
+ function startMeter(source) {
276
+ root.dataset.voiceHeard = "false"
277
+ root.dataset.voicePeak = "0"
278
+ let peak = 0
279
+ meter = createLevelMeter(source, {
280
+ win,
281
+ onPaint: paintLevel,
282
+ onSample: (rms) => {
283
+ if (rms > peak) {
284
+ peak = rms
285
+ root.dataset.voicePeak = rms.toFixed(4)
286
+ }
287
+ if (rms >= SILENCE_RMS) root.dataset.voiceHeard = "true"
288
+ }
289
+ })
290
+ }
291
+
292
+ function closeMeter() {
293
+ if (meter) meter.close()
294
+ meter = null
295
+ paintLevel(0)
296
+ }
297
+
298
+ function release() {
299
+ if (stream) stream.getTracks().forEach((track) => track.stop())
300
+ stream = null
301
+ }
302
+
303
+ async function startTake() {
304
+ try {
305
+ stream = await win.navigator.mediaDevices.getUserMedia({ audio: true })
306
+ } catch (error) {
307
+ setState("ready", error && error.name === "NotAllowedError" ? MESSAGES.denied : MESSAGES.failed)
308
+ return
309
+ }
310
+ if (destroyed || state !== "ready") { release(); return }
311
+ chunks = []
312
+ try {
313
+ startMeter(stream)
314
+ const MediaRecorderClass = win.MediaRecorder
315
+ const mimeType = pickMimeType(MediaRecorderClass.isTypeSupported && MediaRecorderClass.isTypeSupported.bind(MediaRecorderClass))
316
+ try {
317
+ recorder = mimeType ? new MediaRecorderClass(stream, { mimeType }) : new MediaRecorderClass(stream)
318
+ } catch (_) {
319
+ recorder = new MediaRecorderClass(stream)
320
+ }
321
+ recorder.addEventListener("dataavailable", (event) => {
322
+ if (event.data && event.data.size > 0) chunks.push(event.data)
323
+ })
324
+ recorder.addEventListener("stop", () => finishTake())
325
+ recorder.start(250)
326
+ } catch (_) {
327
+ closeMeter()
328
+ release()
329
+ setState("ready", MESSAGES.failed)
330
+ return
331
+ }
332
+ setState("recording")
333
+ timer = win.setTimeout(() => stopTake(true), TAKE_MS)
334
+ }
335
+
336
+ function stopTake(keep = true) {
337
+ if (timer) win.clearTimeout(timer)
338
+ timer = null
339
+ if (!recorder || recorder.state === "inactive") return
340
+ if (!keep) recorder.discard = true
341
+ recorder.stop()
342
+ }
343
+
344
+ async function finishTake() {
345
+ const discarded = recorder && recorder.discard
346
+ if (meter) meter.sample()
347
+ const heard = meter ? meter.heard() : true
348
+ closeMeter()
349
+ release()
350
+ const type = (recorder && recorder.mimeType) || "audio/webm"
351
+ const blob = new Blob(chunks, { type })
352
+ chunks = []
353
+ recorder = null
354
+ if (discarded || destroyed) return
355
+ if (!heard || blob.size === 0) {
356
+ root.dataset.voiceLastTake = "silent"
357
+ settle(MESSAGES.silent)
358
+ return
359
+ }
360
+ setState("thinking")
361
+ try {
362
+ const vector = await embedder.embed(blob)
363
+ if (destroyed || state !== "thinking") return
364
+ vectors.push(vector)
365
+ root.dataset.voiceLastTake = "kept"
366
+ settle()
367
+ } catch (error) {
368
+ if (destroyed || state !== "thinking") return
369
+ const silent = error instanceof SilentTakeError
370
+ root.dataset.voiceLastTake = silent ? "silent" : "failed"
371
+ settle(silent ? MESSAGES.silent : MESSAGES.failed)
372
+ }
373
+ }
374
+
375
+ function toggle() {
376
+ if (state === "recording") stopTake(true)
377
+ else if (state === "ready" && !starting) {
378
+ starting = true
379
+ startTake().finally(() => { starting = false })
380
+ }
381
+ }
382
+
383
+ function csrfToken() {
384
+ const meta = doc.querySelector("meta[name='csrf-token']")
385
+ return meta ? meta.content : ""
386
+ }
387
+
388
+ async function save() {
389
+ if (state !== "complete") return
390
+ setState("saving")
391
+ let response
392
+ let body
393
+ try {
394
+ response = await win.fetch(saveUrl, {
395
+ method: "POST",
396
+ credentials: "same-origin",
397
+ headers: { "Accept": "application/json", "Content-Type": "application/json", "X-CSRF-Token": csrfToken() },
398
+ body: JSON.stringify({ voice: { model: embedder.id, embeddings: vectors } })
399
+ })
400
+ body = await response.json().catch(() => null)
401
+ } catch (_) {
402
+ setState("complete", MESSAGES.network)
403
+ return
404
+ }
405
+ // fetch resolves on a 4xx or 5xx: only an ok answer with a url is saved.
406
+ if (!response.ok || !body || !body.url) {
407
+ setState("complete", (body && body.error) || MESSAGES.notSaved)
408
+ return
409
+ }
410
+ vectors = []
411
+ root.dataset.voiceSaved = "true"
412
+ win.location.assign(body.url)
413
+ }
414
+
415
+ const handlers = {
416
+ open: () => open(),
417
+ cancel: () => cancel(),
418
+ record: () => toggle(),
419
+ save: () => save()
420
+ }
421
+
422
+ function onClick(event) {
423
+ const target = event.target.closest && event.target.closest("[data-voice-action]")
424
+ if (!target || !root.contains(target)) return
425
+ const handler = handlers[target.getAttribute("data-voice-action")]
426
+ if (!handler) return
427
+ event.preventDefault()
428
+ handler()
429
+ }
430
+
431
+ root.addEventListener("click", onClick)
432
+ root.dataset.voiceReady = "true"
433
+ setState("idle")
434
+
435
+ return {
436
+ destroy() {
437
+ destroyed = true
438
+ root.removeEventListener("click", onClick)
439
+ stopTake(false)
440
+ closeMeter()
441
+ release()
442
+ }
443
+ }
444
+ }