studio-engine 0.120.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +2 -0
- data/app/controllers/studio/voice_takes_controller.rb +93 -0
- data/app/javascript/studio/controllers/microphone_chip_controller.js +21 -0
- data/app/javascript/studio/controllers/voice_recorder_controller.js +29 -0
- data/app/javascript/studio/level_meter.js +167 -0
- data/app/javascript/studio/microphone.js +250 -0
- data/app/javascript/studio/microphone_chip.js +370 -0
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/javascript/studio/voice_profile.js +29 -114
- data/app/javascript/studio/voice_recorder.js +352 -0
- data/app/views/studio/voice/_microphone_chip.html.erb +45 -0
- data/app/views/studio/voice/_microphone_help.html.erb +63 -0
- data/app/views/studio/voice/_recorder.html.erb +34 -0
- data/app/views/studio/voice/_styles.html.erb +90 -0
- data/lib/studio/engine.rb +3 -1
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_capture.rb +95 -0
- data/lib/studio/voice_transcription.rb +404 -0
- data/lib/studio.rb +30 -0
- metadata +15 -2
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// studio/microphone_chip: the microphone chip (studio/voice/_microphone_chip).
|
|
2
|
+
// It names the microphone the page records from and, tapped, lists the others;
|
|
3
|
+
// picking one remembers it on this device (studio/microphone), and the next
|
|
4
|
+
// take opens it. Ported from Voicebox's microphone_controller.js
|
|
5
|
+
// (docs/VOICE_CAPTURE.md).
|
|
6
|
+
//
|
|
7
|
+
// It listens and never opens the microphone itself: the recorder
|
|
8
|
+
// (studio/voice_recorder) announces what it opened (EVENTS.opened, with the
|
|
9
|
+
// track's own label), each frame's level while a take records (EVENTS.level,
|
|
10
|
+
// painted as the dot), and when it let go. It re-lists on devicechange, so a
|
|
11
|
+
// headset plugged in or pulled out shows up at once.
|
|
12
|
+
//
|
|
13
|
+
// The list shows every microphone with the one in use marked ("In use").
|
|
14
|
+
// Picking another switches on the page, by device id, and opens the help
|
|
15
|
+
// dialog (studio/voice/_microphone_help) with "Switched to X" on top: Chrome's
|
|
16
|
+
// site settings are where the microphone is ALLOWED, never how the page
|
|
17
|
+
// switches. The dialog also opens on a refused microphone, on the second
|
|
18
|
+
// silent take in a row on one microphone, and from the help link;
|
|
19
|
+
// data-microphone-help says which (switched, denied, silent, help) while open.
|
|
20
|
+
//
|
|
21
|
+
// On the root: data-microphone-state (prompt: not allowed yet, no names;
|
|
22
|
+
// ready; denied; unsupported), data-microphone-label (the name shown),
|
|
23
|
+
// data-microphone-live ("true" while a take records), data-microphone-heard
|
|
24
|
+
// (once a frame of that take cleared SILENCE_RMS), --studio-mic-level (0..1).
|
|
25
|
+
import {
|
|
26
|
+
EVENTS, audioInputs, permissionGranted, pickerChoices, displayedLabel, configureMicrophone,
|
|
27
|
+
rememberedDeviceId, rememberDevice, announce, trimLabel, browserFamily
|
|
28
|
+
} from "studio/microphone"
|
|
29
|
+
import { meterLevel, SILENCE_RMS } from "studio/level_meter"
|
|
30
|
+
|
|
31
|
+
function parseJson(text) {
|
|
32
|
+
try { return JSON.parse(text || "null") } catch (_) { return null }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Binds one chip. Returns { refresh, destroy }.
|
|
36
|
+
export function mountMicrophoneChip(root, { win = window } = {}) {
|
|
37
|
+
const doc = root.ownerDocument
|
|
38
|
+
const one = (selector) => root.querySelector(selector)
|
|
39
|
+
const copy = parseJson(root.getAttribute("data-microphone-copy")) || {}
|
|
40
|
+
const key = root.getAttribute("data-microphone-storage-key")
|
|
41
|
+
if (key) configureMicrophone({ storageKey: key })
|
|
42
|
+
const media = win.navigator && win.navigator.mediaDevices
|
|
43
|
+
const chip = one("[data-microphone-chip]")
|
|
44
|
+
const panel = one("[data-microphone-panel]")
|
|
45
|
+
const dialog = one("[data-microphone-dialog]")
|
|
46
|
+
const browser = browserFamily(win.navigator && win.navigator.userAgent)
|
|
47
|
+
root.dataset.microphoneBrowser = browser
|
|
48
|
+
|
|
49
|
+
let inputs = []
|
|
50
|
+
let active = null
|
|
51
|
+
let denied = false
|
|
52
|
+
let unsupported = false
|
|
53
|
+
let fellBack = false
|
|
54
|
+
let silentRun = 0
|
|
55
|
+
let silentLabel = null
|
|
56
|
+
let lastTakeSilent = false
|
|
57
|
+
let helpSession = null
|
|
58
|
+
let permission = null
|
|
59
|
+
let destroyed = false
|
|
60
|
+
|
|
61
|
+
// ---- What the recorder says ----------------------------------------------
|
|
62
|
+
|
|
63
|
+
function opened(detail) {
|
|
64
|
+
// A take that was heard breaks the silent run.
|
|
65
|
+
if (!lastTakeSilent) silentRun = 0
|
|
66
|
+
lastTakeSilent = false
|
|
67
|
+
denied = false
|
|
68
|
+
active = detail.label ? detail : null
|
|
69
|
+
fellBack = !!detail.fellBack
|
|
70
|
+
root.dataset.microphoneLive = "true"
|
|
71
|
+
root.dataset.microphoneHeard = "false"
|
|
72
|
+
paintLevel(0)
|
|
73
|
+
// Allowing the microphone is what gives the devices their names.
|
|
74
|
+
refresh()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function closed() {
|
|
78
|
+
root.dataset.microphoneLive = "false"
|
|
79
|
+
paintLevel(0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function paintLevel(rms) {
|
|
83
|
+
const level = meterLevel(rms)
|
|
84
|
+
root.style.setProperty("--studio-mic-level", level.toFixed(3))
|
|
85
|
+
root.dataset.microphoneLevel = level.toFixed(2)
|
|
86
|
+
if (rms >= SILENCE_RMS) root.dataset.microphoneHeard = "true"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One silent take is often a pause or a quiet room: the recorder's line
|
|
90
|
+
// names the microphone and that is enough. Two in a row on the same
|
|
91
|
+
// microphone means it is not hearing anything, and the help opens.
|
|
92
|
+
function silentTake(label) {
|
|
93
|
+
lastTakeSilent = true
|
|
94
|
+
const name = trimLabel(label)
|
|
95
|
+
silentRun = name === silentLabel ? silentRun + 1 : 1
|
|
96
|
+
silentLabel = name
|
|
97
|
+
if (silentRun < 2) return
|
|
98
|
+
silentRun = 0
|
|
99
|
+
openHelp("silent", label)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const listeners = [
|
|
103
|
+
[EVENTS.opened, (event) => opened(event.detail || {})],
|
|
104
|
+
[EVENTS.level, (event) => paintLevel((event.detail || {}).rms)],
|
|
105
|
+
[EVENTS.closed, () => closed()],
|
|
106
|
+
[EVENTS.denied, () => { denied = true; render(); openHelp("denied") }],
|
|
107
|
+
[EVENTS.silent, (event) => silentTake((event.detail || {}).label)]
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
// ---- The list ------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
// The browser's own answer, where it gives one: "denied" outlasts a reload,
|
|
113
|
+
// and a person who allows it in the site settings sees the chip catch up.
|
|
114
|
+
async function watchPermission() {
|
|
115
|
+
try {
|
|
116
|
+
if (!win.navigator.permissions || !win.navigator.permissions.query) return
|
|
117
|
+
const status = await win.navigator.permissions.query({ name: "microphone" })
|
|
118
|
+
if (destroyed) return
|
|
119
|
+
permission = status
|
|
120
|
+
const apply = () => { denied = status.state === "denied"; refresh() }
|
|
121
|
+
status.onchange = apply
|
|
122
|
+
if (status.state === "denied") apply()
|
|
123
|
+
} catch (_) { /* Firefox before 131 cannot be asked; the takes tell us */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function refresh() {
|
|
127
|
+
if (!media || !media.enumerateDevices) {
|
|
128
|
+
unsupported = true
|
|
129
|
+
render()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
inputs = audioInputs(await media.enumerateDevices())
|
|
134
|
+
} catch (_) {
|
|
135
|
+
inputs = []
|
|
136
|
+
}
|
|
137
|
+
if (destroyed) return
|
|
138
|
+
// The device last recorded from was unplugged: stop naming it.
|
|
139
|
+
if (active && active.deviceId && permissionGranted(inputs) &&
|
|
140
|
+
!inputs.some((device) => device.deviceId === active.deviceId)) {
|
|
141
|
+
active = null
|
|
142
|
+
}
|
|
143
|
+
render()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function state() {
|
|
147
|
+
if (unsupported) return "unsupported"
|
|
148
|
+
if (denied) return "denied"
|
|
149
|
+
return permissionGranted(inputs) || active ? "ready" : "prompt"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fill(template, name) {
|
|
153
|
+
return String(template || "").replace("%{microphone}", name)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function render() {
|
|
157
|
+
const now = state()
|
|
158
|
+
const words = copy[now] || {}
|
|
159
|
+
const name = now === "ready" ? displayedLabel(inputs, rememberedDeviceId(), active && active.label) : null
|
|
160
|
+
root.dataset.microphoneState = now
|
|
161
|
+
root.dataset.microphoneLabel = name || ""
|
|
162
|
+
root.dataset.microphoneFellBack = fellBack ? "true" : "false"
|
|
163
|
+
const label = one("[data-microphone-label]")
|
|
164
|
+
if (label) {
|
|
165
|
+
label.textContent = now === "ready"
|
|
166
|
+
? (name ? fill(words.chip || "%{microphone}", name) : (copy.prompt || {}).chip || "")
|
|
167
|
+
: words.chip || ""
|
|
168
|
+
}
|
|
169
|
+
if (chip) {
|
|
170
|
+
if (name && words.aria) chip.setAttribute("aria-label", fill(words.aria, name))
|
|
171
|
+
else chip.removeAttribute("aria-label")
|
|
172
|
+
}
|
|
173
|
+
const note = one("[data-microphone-note]")
|
|
174
|
+
if (note) {
|
|
175
|
+
const fell = fellBack && name && copy.fell_back ? fill(copy.fell_back, name) : null
|
|
176
|
+
note.textContent = fell || words.note || ""
|
|
177
|
+
}
|
|
178
|
+
renderList(now, note)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// One button per microphone, the one in use marked with a check and an
|
|
182
|
+
// "In use" pill, the rest plain options. Labels go in as text, never as
|
|
183
|
+
// markup: a device names itself.
|
|
184
|
+
function renderList(now, note) {
|
|
185
|
+
const list = one("[data-microphone-list]")
|
|
186
|
+
if (!list) return
|
|
187
|
+
const choices = now === "ready" ? pickerChoices(inputs) : []
|
|
188
|
+
const current = currentDeviceId(choices)
|
|
189
|
+
list.replaceChildren(...choices.map((choice) => {
|
|
190
|
+
const inUse = choice.deviceId === current
|
|
191
|
+
const item = doc.createElement("li")
|
|
192
|
+
const button = doc.createElement("button")
|
|
193
|
+
button.type = "button"
|
|
194
|
+
button.className = "studio-mic-option"
|
|
195
|
+
button.dataset.microphoneOption = choice.deviceId
|
|
196
|
+
button.setAttribute("aria-pressed", inUse ? "true" : "false")
|
|
197
|
+
if (inUse) button.setAttribute("aria-current", "true")
|
|
198
|
+
const mark = doc.createElement("span")
|
|
199
|
+
mark.className = "studio-mic-check"
|
|
200
|
+
mark.setAttribute("aria-hidden", "true")
|
|
201
|
+
mark.textContent = inUse ? "✓" : ""
|
|
202
|
+
const label = doc.createElement("span")
|
|
203
|
+
label.className = "studio-mic-option-label"
|
|
204
|
+
label.textContent = choice.label
|
|
205
|
+
button.append(mark, label)
|
|
206
|
+
if (inUse) {
|
|
207
|
+
const pill = doc.createElement("span")
|
|
208
|
+
pill.className = "studio-mic-in-use"
|
|
209
|
+
pill.textContent = copy.in_use || "In use"
|
|
210
|
+
button.append(pill)
|
|
211
|
+
}
|
|
212
|
+
item.append(button)
|
|
213
|
+
return item
|
|
214
|
+
}))
|
|
215
|
+
if (now === "ready" && choices.length === 0 && note) note.textContent = copy.empty || ""
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The picked device, else the one last recorded from, else the system
|
|
219
|
+
// default's twin among the real devices.
|
|
220
|
+
function currentDeviceId(choices) {
|
|
221
|
+
const ids = choices.map((choice) => choice.deviceId)
|
|
222
|
+
const remembered = rememberedDeviceId()
|
|
223
|
+
if (remembered && ids.includes(remembered)) return remembered
|
|
224
|
+
if (active && ids.includes(active.deviceId)) return active.deviceId
|
|
225
|
+
const name = displayedLabel(inputs, null, active && active.label)
|
|
226
|
+
const twin = choices.find((choice) => choice.label === name)
|
|
227
|
+
return twin ? twin.deviceId : null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toggle() {
|
|
231
|
+
if (!panel) return
|
|
232
|
+
if (panel.hidden) openPanel()
|
|
233
|
+
else closePanel()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function openPanel() {
|
|
237
|
+
panel.hidden = false
|
|
238
|
+
if (chip) chip.setAttribute("aria-expanded", "true")
|
|
239
|
+
refresh()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function closePanel() {
|
|
243
|
+
if (!panel) return
|
|
244
|
+
panel.hidden = true
|
|
245
|
+
if (chip) chip.setAttribute("aria-expanded", "false")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function pick(button) {
|
|
249
|
+
const deviceId = button.dataset.microphoneOption
|
|
250
|
+
if (!deviceId) return
|
|
251
|
+
if (button.getAttribute("aria-current") === "true") {
|
|
252
|
+
closePanel()
|
|
253
|
+
if (chip) chip.focus()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
rememberDevice(deviceId)
|
|
257
|
+
// The chip now names the pick; the next take opens it and confirms.
|
|
258
|
+
active = null
|
|
259
|
+
fellBack = false
|
|
260
|
+
announce(EVENTS.chosen, { deviceId }, win)
|
|
261
|
+
const choice = pickerChoices(inputs).find((c) => c.deviceId === deviceId)
|
|
262
|
+
render()
|
|
263
|
+
closePanel()
|
|
264
|
+
openHelp("switched", choice ? choice.label : "", chip)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- The help dialog -----------------------------------------------------
|
|
268
|
+
|
|
269
|
+
// kind: switched, denied, silent, or help (no lead line). returnTo: where
|
|
270
|
+
// focus goes when it closes, else wherever it was when it opened.
|
|
271
|
+
function openHelp(kind, name = "", returnTo = null) {
|
|
272
|
+
if (!dialog) return
|
|
273
|
+
const help = copy.help || {}
|
|
274
|
+
const chrome = browser === "chrome"
|
|
275
|
+
const microphone = trimLabel(name) || help.unnamed || "the microphone"
|
|
276
|
+
const lead = {
|
|
277
|
+
switched: [help.switched, chrome ? help.switched_tip_chrome : help.switched_tip_other],
|
|
278
|
+
silent: [help.silent, help.silent_tip],
|
|
279
|
+
denied: [help.denied, help.denied_tip]
|
|
280
|
+
}[kind]
|
|
281
|
+
const leadBox = one("[data-microphone-help-lead]")
|
|
282
|
+
if (leadBox) leadBox.hidden = !lead
|
|
283
|
+
if (lead) {
|
|
284
|
+
const headline = one("[data-microphone-help-headline]")
|
|
285
|
+
const tip = one("[data-microphone-help-tip]")
|
|
286
|
+
if (headline) headline.textContent = fill(lead[0], microphone)
|
|
287
|
+
if (tip) tip.textContent = lead[1] || ""
|
|
288
|
+
}
|
|
289
|
+
const title = one("[data-microphone-help-title]")
|
|
290
|
+
if (title) title.textContent = (chrome ? help.title_chrome : help.title_other) || ""
|
|
291
|
+
const chromeSteps = one("[data-microphone-help-chrome]")
|
|
292
|
+
if (chromeSteps) chromeSteps.hidden = !chrome
|
|
293
|
+
const other = one("[data-microphone-help-other]")
|
|
294
|
+
if (other) other.hidden = chrome
|
|
295
|
+
root.dataset.microphoneHelp = kind
|
|
296
|
+
|
|
297
|
+
if (dialog.open) return
|
|
298
|
+
helpSession = { returnFocus: returnTo || doc.activeElement, done: false }
|
|
299
|
+
if (typeof dialog.showModal === "function") dialog.showModal()
|
|
300
|
+
else dialog.setAttribute("open", "")
|
|
301
|
+
const close = one("[data-microphone-help-close]")
|
|
302
|
+
if (close) close.focus()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Esc, Close or a backdrop click: focus goes back where it came from.
|
|
306
|
+
//
|
|
307
|
+
// The close event is queued, so one from an earlier opening can land late:
|
|
308
|
+
// while the dialog is open again (ignored), or after the newer opening has
|
|
309
|
+
// closed too. Each opening is a session that hands focus back once.
|
|
310
|
+
function helpClosed() {
|
|
311
|
+
const session = helpSession
|
|
312
|
+
if (!session || session.done || dialog.open) return
|
|
313
|
+
session.done = true
|
|
314
|
+
delete root.dataset.microphoneHelp
|
|
315
|
+
const back = session.returnFocus
|
|
316
|
+
const target = back && back.isConnected && back.offsetParent !== null ? back : chip
|
|
317
|
+
if (target) target.focus()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- Wiring --------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function onClick(event) {
|
|
323
|
+
const target = event.target
|
|
324
|
+
if (!target.closest) return
|
|
325
|
+
// A click on the backdrop lands on the dialog element itself.
|
|
326
|
+
if (dialog && target === dialog) { dialog.close(); return }
|
|
327
|
+
const option = target.closest("[data-microphone-option]")
|
|
328
|
+
if (option && root.contains(option)) { pick(option); return }
|
|
329
|
+
if (target.closest("[data-microphone-help-link]")) {
|
|
330
|
+
openHelp("help", "", target.closest("[data-microphone-help-link]"))
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (chip && target.closest("[data-microphone-chip]") === chip) toggle()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Esc closes the list, but an Esc inside the help dialog is the dialog's: it
|
|
337
|
+
// shuts the dialog and leaves the list where it was.
|
|
338
|
+
function onKey(event) {
|
|
339
|
+
if (event.key !== "Escape") return
|
|
340
|
+
if (dialog && dialog.contains(event.target)) return
|
|
341
|
+
closePanel()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const onDeviceChange = () => refresh()
|
|
345
|
+
const onDialogClose = () => helpClosed()
|
|
346
|
+
|
|
347
|
+
listeners.forEach(([name, fn]) => win.addEventListener(name, fn))
|
|
348
|
+
root.addEventListener("click", onClick)
|
|
349
|
+
root.addEventListener("keydown", onKey)
|
|
350
|
+
if (dialog) dialog.addEventListener("close", onDialogClose)
|
|
351
|
+
if (media && media.addEventListener) media.addEventListener("devicechange", onDeviceChange)
|
|
352
|
+
watchPermission()
|
|
353
|
+
refresh()
|
|
354
|
+
root.dataset.microphoneReady = "true"
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
refresh,
|
|
358
|
+
destroy() {
|
|
359
|
+
destroyed = true
|
|
360
|
+
listeners.forEach(([name, fn]) => win.removeEventListener(name, fn))
|
|
361
|
+
root.removeEventListener("click", onClick)
|
|
362
|
+
root.removeEventListener("keydown", onKey)
|
|
363
|
+
if (dialog) dialog.removeEventListener("close", onDialogClose)
|
|
364
|
+
if (media && media.removeEventListener) media.removeEventListener("devicechange", onDeviceChange)
|
|
365
|
+
if (permission) permission.onchange = null
|
|
366
|
+
if (dialog && dialog.open) dialog.close()
|
|
367
|
+
delete root.dataset.microphoneReady
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -60,6 +60,9 @@ export const schema = {
|
|
|
60
60
|
// carries data-studio-controller-failed="agent-chat". So is the voice row: a
|
|
61
61
|
// row whose controller failed keeps its saved state and its Delete, and says
|
|
62
62
|
// that recording did not load (data-studio-controller-failed="voice-profile").
|
|
63
|
+
// So are the voice capture pair (docs/VOICE_CAPTURE.md): a chip whose controller
|
|
64
|
+
// failed still shows its prompt line, and a recorder whose controller failed
|
|
65
|
+
// says recording did not load (data-studio-controller-failed="voice-recorder").
|
|
63
66
|
export const LAZY = {
|
|
64
67
|
"board": () => import("studio/controllers/board_controller"),
|
|
65
68
|
"geo-settings": () => import("studio/controllers/geo_settings_controller"),
|
|
@@ -70,7 +73,9 @@ export const LAZY = {
|
|
|
70
73
|
"survey": () => import("studio/controllers/survey_controller"),
|
|
71
74
|
"knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
|
|
72
75
|
"agent-chat": () => import("studio/controllers/agent_chat_controller"),
|
|
73
|
-
"voice-profile": () => import("studio/controllers/voice_profile_controller")
|
|
76
|
+
"voice-profile": () => import("studio/controllers/voice_profile_controller"),
|
|
77
|
+
"microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
|
|
78
|
+
"voice-recorder": () => import("studio/controllers/voice_recorder_controller")
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
export const application = Application.start(document.documentElement, schema)
|
|
@@ -26,31 +26,25 @@
|
|
|
26
26
|
// fake Tests only: a fixed vector per voice name (window.studioFakeVoice),
|
|
27
27
|
// because Chrome's fake microphone cannot play chosen audio.
|
|
28
28
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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 }
|
|
33
40
|
|
|
34
41
|
// The model hears 16 kHz mono; a take is cut to its first MAX_SECONDS.
|
|
35
42
|
export const SAMPLE_RATE = 16000
|
|
36
43
|
export const MAX_SECONDS = 10
|
|
37
44
|
|
|
38
|
-
// The loudest frame (RMS, 0..1) a take must reach to count as heard: 0.01 is
|
|
39
|
-
// -40 dBFS. A muted microphone sits at 0, a quiet room near 0.002-0.006, and
|
|
40
|
-
// speech at arm's length peaks well above 0.05.
|
|
41
|
-
export const SILENCE_RMS = 0.01
|
|
42
|
-
|
|
43
45
|
// A phrase is about four seconds; the take stops itself after TAKE_MS.
|
|
44
46
|
export const TAKE_MS = 6000
|
|
45
47
|
|
|
46
|
-
const MIME_CANDIDATES = [
|
|
47
|
-
"audio/webm;codecs=opus",
|
|
48
|
-
"audio/webm",
|
|
49
|
-
"audio/mp4;codecs=mp4a.40.2",
|
|
50
|
-
"audio/mp4",
|
|
51
|
-
"audio/ogg;codecs=opus"
|
|
52
|
-
]
|
|
53
|
-
|
|
54
48
|
export const COPY = {
|
|
55
49
|
idle: ["", ""],
|
|
56
50
|
ready: ["Record phrase {n}", "Tap Record, then read phrase {n} out loud."],
|
|
@@ -71,50 +65,6 @@ export const MESSAGES = {
|
|
|
71
65
|
|
|
72
66
|
export class SilentTakeError extends Error {}
|
|
73
67
|
|
|
74
|
-
export function rootMeanSquare(samples) {
|
|
75
|
-
if (!samples || samples.length === 0) return 0
|
|
76
|
-
let sum = 0
|
|
77
|
-
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]
|
|
78
|
-
return Math.sqrt(sum / samples.length)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// The loudest 50 ms window's RMS, 0..1, at 16 kHz.
|
|
82
|
-
export function peakWindowRms(samples, windowSize = 800) {
|
|
83
|
-
let peak = 0
|
|
84
|
-
if (!samples) return peak
|
|
85
|
-
for (let start = 0; start < samples.length; start += windowSize) {
|
|
86
|
-
const end = Math.min(samples.length, start + windowSize)
|
|
87
|
-
let sum = 0
|
|
88
|
-
for (let i = start; i < end; i++) sum += samples[i] * samples[i]
|
|
89
|
-
const rms = Math.sqrt(sum / Math.max(1, end - start))
|
|
90
|
-
if (rms > peak) peak = rms
|
|
91
|
-
}
|
|
92
|
-
return peak
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// The meter's reading, 0..1: logarithmic from -40 dBFS to -10 dBFS, where
|
|
96
|
-
// speech lives.
|
|
97
|
-
export function meterLevel(rms) {
|
|
98
|
-
if (!(rms > 0)) return 0
|
|
99
|
-
const db = 20 * Math.log10(rms)
|
|
100
|
-
return Math.min(1, Math.max(0, (db + 40) / 30))
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Heard, unless the meter measured the take and it stayed under SILENCE_RMS. A
|
|
104
|
-
// take the meter never measured (no Web Audio, a context that never ran) is
|
|
105
|
-
// let through, and the embedder's own check on the decoded samples decides.
|
|
106
|
-
export function takeWasHeard(peakRms, framesMeasured) {
|
|
107
|
-
if (!framesMeasured) return true
|
|
108
|
-
return peakRms >= SILENCE_RMS
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function pickMimeType(isTypeSupported) {
|
|
112
|
-
if (typeof isTypeSupported !== "function") return ""
|
|
113
|
-
return MIME_CANDIDATES.find((type) => {
|
|
114
|
-
try { return isTypeSupported(type) } catch (_) { return false }
|
|
115
|
-
}) || ""
|
|
116
|
-
}
|
|
117
|
-
|
|
118
68
|
// The words for a state, with {n} the phrase being read (1-based).
|
|
119
69
|
export function copyFor(state, step) {
|
|
120
70
|
const [button, status] = COPY[state] || ["Try again", ""]
|
|
@@ -258,13 +208,7 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
258
208
|
let recorder = null
|
|
259
209
|
let chunks = []
|
|
260
210
|
let timer = null
|
|
261
|
-
let
|
|
262
|
-
let analyser = null
|
|
263
|
-
let levelBuffer = null
|
|
264
|
-
let meterFrame = null
|
|
265
|
-
let meterTimer = null
|
|
266
|
-
let peakRms = 0
|
|
267
|
-
let frames = 0
|
|
211
|
+
let meter = null
|
|
268
212
|
let starting = false
|
|
269
213
|
let destroyed = false
|
|
270
214
|
|
|
@@ -327,56 +271,27 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
327
271
|
root.dataset.voiceLevel = level.toFixed(2)
|
|
328
272
|
}
|
|
329
273
|
|
|
330
|
-
|
|
331
|
-
if (!analyser || !audioContext || audioContext.state !== "running") return
|
|
332
|
-
analyser.getFloatTimeDomainData(levelBuffer)
|
|
333
|
-
const rms = rootMeanSquare(levelBuffer)
|
|
334
|
-
frames += 1
|
|
335
|
-
if (rms > peakRms) {
|
|
336
|
-
peakRms = rms
|
|
337
|
-
root.dataset.voicePeak = rms.toFixed(4)
|
|
338
|
-
}
|
|
339
|
-
if (rms >= SILENCE_RMS) root.dataset.voiceHeard = "true"
|
|
340
|
-
paintLevel(meterLevel(rms))
|
|
341
|
-
}
|
|
342
|
-
|
|
274
|
+
// The engine's one meter (studio/level_meter) on the take's own stream.
|
|
343
275
|
function startMeter(source) {
|
|
344
|
-
peakRms = 0
|
|
345
|
-
frames = 0
|
|
346
276
|
root.dataset.voiceHeard = "false"
|
|
347
277
|
root.dataset.voicePeak = "0"
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
closeMeter()
|
|
361
|
-
return
|
|
362
|
-
}
|
|
363
|
-
const frame = () => {
|
|
364
|
-
sampleLevel()
|
|
365
|
-
meterFrame = win.requestAnimationFrame(frame)
|
|
366
|
-
}
|
|
367
|
-
meterFrame = win.requestAnimationFrame(frame)
|
|
368
|
-
// A background tab stops animation frames; the timer keeps measuring.
|
|
369
|
-
meterTimer = win.setInterval(sampleLevel, 100)
|
|
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
|
+
})
|
|
370
290
|
}
|
|
371
291
|
|
|
372
292
|
function closeMeter() {
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
meterFrame = null
|
|
376
|
-
meterTimer = null
|
|
377
|
-
analyser = null
|
|
378
|
-
if (audioContext && audioContext.state !== "closed") audioContext.close().catch(() => {})
|
|
379
|
-
audioContext = null
|
|
293
|
+
if (meter) meter.close()
|
|
294
|
+
meter = null
|
|
380
295
|
paintLevel(0)
|
|
381
296
|
}
|
|
382
297
|
|
|
@@ -428,8 +343,8 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
428
343
|
|
|
429
344
|
async function finishTake() {
|
|
430
345
|
const discarded = recorder && recorder.discard
|
|
431
|
-
|
|
432
|
-
const heard =
|
|
346
|
+
if (meter) meter.sample()
|
|
347
|
+
const heard = meter ? meter.heard() : true
|
|
433
348
|
closeMeter()
|
|
434
349
|
release()
|
|
435
350
|
const type = (recorder && recorder.mimeType) || "audio/webm"
|