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.
@@ -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
- // It imports nothing statically; the model runtime is a dynamic import of the
30
- // transformers.js URL the page names (Studio::VoiceMatch::TRANSFORMERS_URL), made
31
- // only once someone opens the setup. test/javascript/voice_profile.test.mjs
32
- // loads it as a data: module.
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 audioContext = null
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
- function sampleLevel() {
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
- paintLevel(0)
349
- const AudioContextClass = win.AudioContext || win.webkitAudioContext
350
- if (!AudioContextClass) return
351
- try {
352
- audioContext = new AudioContextClass()
353
- const input = audioContext.createMediaStreamSource(source)
354
- analyser = audioContext.createAnalyser()
355
- analyser.fftSize = 1024
356
- levelBuffer = new Float32Array(analyser.fftSize)
357
- input.connect(analyser)
358
- if (audioContext.state === "suspended" && audioContext.resume) audioContext.resume().catch(() => {})
359
- } catch (_) {
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 (meterFrame) win.cancelAnimationFrame(meterFrame)
374
- if (meterTimer) win.clearInterval(meterTimer)
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
- sampleLevel()
432
- const heard = takeWasHeard(peakRms, frames)
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"