studio-engine 0.120.0 → 0.122.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.
@@ -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."],
@@ -64,6 +58,7 @@ export const MESSAGES = {
64
58
  silent: "I couldn't hear that one. Check your microphone and read it again.",
65
59
  failed: "That one didn't work. Tap Record and read it again.",
66
60
  denied: "Allow the microphone to set up your voice.",
61
+ busy: "Your microphone is busy in another app. Close it there, then try again.",
67
62
  unsupported: "This browser can't record here. Try Safari or Chrome.",
68
63
  network: "We couldn't reach the server. Tap Save again.",
69
64
  notSaved: "That didn't save. Tap Save again."
@@ -71,48 +66,13 @@ export const MESSAGES = {
71
66
 
72
67
  export class SilentTakeError extends Error {}
73
68
 
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
- }) || ""
69
+ // Why the microphone would not open: refused, held by another app
70
+ // (NotReadableError; Chrome's older TrackStartError), or anything else.
71
+ export function openFailure(error) {
72
+ const name = error && error.name
73
+ if (name === "NotAllowedError") return MESSAGES.denied
74
+ if (name === "NotReadableError" || name === "TrackStartError") return MESSAGES.busy
75
+ return MESSAGES.failed
116
76
  }
117
77
 
118
78
  // The words for a state, with {n} the phrase being read (1-based).
@@ -258,13 +218,7 @@ export function mountVoiceProfile(root, win = window) {
258
218
  let recorder = null
259
219
  let chunks = []
260
220
  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
221
+ let meter = null
268
222
  let starting = false
269
223
  let destroyed = false
270
224
 
@@ -327,56 +281,27 @@ export function mountVoiceProfile(root, win = window) {
327
281
  root.dataset.voiceLevel = level.toFixed(2)
328
282
  }
329
283
 
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
-
284
+ // The engine's one meter (studio/level_meter) on the take's own stream.
343
285
  function startMeter(source) {
344
- peakRms = 0
345
- frames = 0
346
286
  root.dataset.voiceHeard = "false"
347
287
  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)
288
+ let peak = 0
289
+ meter = createLevelMeter(source, {
290
+ win,
291
+ onPaint: paintLevel,
292
+ onSample: (rms) => {
293
+ if (rms > peak) {
294
+ peak = rms
295
+ root.dataset.voicePeak = rms.toFixed(4)
296
+ }
297
+ if (rms >= SILENCE_RMS) root.dataset.voiceHeard = "true"
298
+ }
299
+ })
370
300
  }
371
301
 
372
302
  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
303
+ if (meter) meter.close()
304
+ meter = null
380
305
  paintLevel(0)
381
306
  }
382
307
 
@@ -389,7 +314,7 @@ export function mountVoiceProfile(root, win = window) {
389
314
  try {
390
315
  stream = await win.navigator.mediaDevices.getUserMedia({ audio: true })
391
316
  } catch (error) {
392
- setState("ready", error && error.name === "NotAllowedError" ? MESSAGES.denied : MESSAGES.failed)
317
+ setState("ready", openFailure(error))
393
318
  return
394
319
  }
395
320
  if (destroyed || state !== "ready") { release(); return }
@@ -428,8 +353,8 @@ export function mountVoiceProfile(root, win = window) {
428
353
 
429
354
  async function finishTake() {
430
355
  const discarded = recorder && recorder.discard
431
- sampleLevel()
432
- const heard = takeWasHeard(peakRms, frames)
356
+ if (meter) meter.sample()
357
+ const heard = meter ? meter.heard() : true
433
358
  closeMeter()
434
359
  release()
435
360
  const type = (recorder && recorder.mimeType) || "audio/webm"
@@ -0,0 +1,363 @@
1
+ // studio/voice_recorder: one record button that captures a take and hands the
2
+ // finished audio to the page. It never uploads: the page listens for the take
3
+ // event and decides where the audio goes (a chat message, a transcript, an
4
+ // embedding). Ported from Voicebox's recorder_controller.js; the contract is in
5
+ // docs/VOICE_CAPTURE.md.
6
+ //
7
+ // <div data-studio-controller="voice-recorder" data-voice-recorder-state="idle"
8
+ // data-voice-recorder-max-ms="60000" data-voice-recorder-mode="toggle"
9
+ // data-voice-recorder-copy='{"idle":{"button":"Talk"}}'>
10
+ // <button type="button" data-voice-recorder-button>
11
+ // <span data-voice-recorder-label>Record</span></button>
12
+ // <span data-voice-recorder-timer>0:00</span>
13
+ // <p data-voice-recorder-status role="status"></p>
14
+ // </div>
15
+ //
16
+ // STATES, on data-voice-recorder-state: idle -> starting -> recording ->
17
+ // captured | silent | failed, plus denied (the microphone was refused) and
18
+ // unsupported (no MediaRecorder). Any of idle, captured, silent, failed and
19
+ // denied takes a new press.
20
+ //
21
+ // EVENTS, dispatched on the root (they bubble):
22
+ // studio:voice-recorder:take detail { blob, mimeType, durationMs, peakRms, microphone: { label, deviceId } }
23
+ // studio:voice-recorder:silent detail { durationMs, microphone } (nothing heard: no blob is handed over)
24
+ // studio:voice-recorder:state detail { state }
25
+ // and, on window, studio/microphone's EVENTS, which the chip listens to.
26
+ //
27
+ // MODES: "toggle" (tap to start, tap to stop) or "hold" (press and hold to
28
+ // talk; letting go stops). While it records, --studio-recorder-level (0..1) on
29
+ // the root drives the ring (studio/level_meter), and data-voice-recorder-heard
30
+ // turns "true" once a frame clears SILENCE_RMS.
31
+ import { openMicrophone, announce, silentMessage, EVENTS as MICROPHONE_EVENTS } from "studio/microphone"
32
+ import { createLevelMeter, SILENCE_RMS } from "studio/level_meter"
33
+
34
+ export const EVENTS = {
35
+ take: "studio:voice-recorder:take",
36
+ silent: "studio:voice-recorder:silent",
37
+ state: "studio:voice-recorder:state"
38
+ }
39
+
40
+ // What to ask MediaRecorder for, in order: webm/opus on Chrome and Firefox,
41
+ // mp4/aac on iOS Safari. The first the browser supports wins.
42
+ export const MIME_CANDIDATES = [
43
+ "audio/webm;codecs=opus",
44
+ "audio/webm",
45
+ "audio/mp4;codecs=mp4a.40.2",
46
+ "audio/mp4",
47
+ "audio/ogg;codecs=opus"
48
+ ]
49
+
50
+ export const READY = ["idle", "captured", "silent", "failed", "denied"]
51
+
52
+ // How long a take may stay silent before the status asks whether the
53
+ // microphone is on.
54
+ export const QUIET_HINT_MS = 2500
55
+
56
+ export const COPY = {
57
+ idle: { button: "Record", status: "" },
58
+ starting: { button: "Starting...", status: "" },
59
+ recording: { button: "Stop", status: "Listening...", quiet: "I can't hear anything yet. Is your microphone on?", heard: "Listening..." },
60
+ captured: { button: "Record again", status: "Got it." },
61
+ silent: { button: "Record", status: "I couldn't hear anything. Check your microphone and try again.",
62
+ named: "I couldn't hear anything from %{microphone}. Pick another microphone and try again." },
63
+ failed: { button: "Try again", status: "That didn't record. Try again.", start: "I couldn't start recording. Try again.",
64
+ missing: "I couldn't find a microphone. Plug one in and try again.",
65
+ busy: "Your microphone is busy in another app. Close it there, then try again." },
66
+ denied: { button: "Record", status: "Allow the microphone to record." },
67
+ unsupported: { button: "Record", status: "This browser can't record here. Try Safari or Chrome." }
68
+ }
69
+
70
+ export function pickMimeType(isTypeSupported) {
71
+ if (typeof isTypeSupported !== "function") return ""
72
+ return MIME_CANDIDATES.find((type) => {
73
+ try { return isTypeSupported(type) } catch (_) { return false }
74
+ }) || ""
75
+ }
76
+
77
+ export function formatElapsed(ms) {
78
+ const seconds = Math.max(0, Math.floor(ms / 1000))
79
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
80
+ }
81
+
82
+ // The line for a microphone that would not open (NotAllowedError is "denied",
83
+ // not this). NotReadableError is a microphone another app holds (Chrome's
84
+ // older name, TrackStartError); everything else reads as no microphone found.
85
+ export const BUSY_ERRORS = ["NotReadableError", "TrackStartError"]
86
+
87
+ export function failedLine(error, copy = COPY) {
88
+ const busy = !!error && BUSY_ERRORS.includes(error.name)
89
+ return busy ? (copy.failed.busy || COPY.failed.busy) : copy.failed.missing
90
+ }
91
+
92
+ // The page's words over the defaults, one state at a time.
93
+ export function mergeCopy(overrides) {
94
+ const merged = {}
95
+ Object.keys(COPY).forEach((state) => { merged[state] = { ...COPY[state] } })
96
+ if (overrides && typeof overrides === "object") {
97
+ Object.entries(overrides).forEach(([state, words]) => {
98
+ if (words && typeof words === "object") merged[state] = { ...(merged[state] || {}), ...words }
99
+ })
100
+ }
101
+ return merged
102
+ }
103
+
104
+ function parseJson(text) {
105
+ try { return JSON.parse(text || "null") } catch (_) { return null }
106
+ }
107
+
108
+ // Binds one recorder. Returns { start, stop, toggle, destroy, get state }.
109
+ // options.open and options.meter replace openMicrophone and createLevelMeter
110
+ // (the unit tests pass stubs).
111
+ export function mountVoiceRecorder(root, { win = window, open = openMicrophone, meter: makeMeter = createLevelMeter } = {}) {
112
+ const one = (selector) => root.querySelector(selector)
113
+ const copy = mergeCopy(parseJson(root.getAttribute("data-voice-recorder-copy")))
114
+ const maxMs = parseInt(root.getAttribute("data-voice-recorder-max-ms") || "0", 10) || 0
115
+ const mode = root.getAttribute("data-voice-recorder-mode") === "hold" ? "hold" : "toggle"
116
+ const now = () => win.performance.now()
117
+
118
+ let state = null
119
+ let stream = null
120
+ let recorder = null
121
+ let meter = null
122
+ let chunks = []
123
+ let timer = null
124
+ let startedAt = 0
125
+ let microphone = { label: "", deviceId: null }
126
+ let hinted = null
127
+ let stopWanted = false
128
+ let destroyed = false
129
+
130
+ function setState(next, message = null) {
131
+ state = next
132
+ root.dataset.voiceRecorderState = next
133
+ const words = copy[next] || {}
134
+ const label = one("[data-voice-recorder-label]")
135
+ if (label && words.button) label.textContent = words.button
136
+ const status = one("[data-voice-recorder-status]")
137
+ if (status) status.textContent = message || words.status || ""
138
+ const button = one("[data-voice-recorder-button]")
139
+ if (button) {
140
+ button.setAttribute("aria-pressed", next === "recording" ? "true" : "false")
141
+ button.disabled = next === "unsupported"
142
+ }
143
+ if (next === "idle") paintTimer(0)
144
+ root.dispatchEvent(new win.CustomEvent(EVENTS.state, { bubbles: true, detail: { state: next } }))
145
+ }
146
+
147
+ function supported() {
148
+ return !!(win.MediaRecorder && win.navigator && win.navigator.mediaDevices && win.navigator.mediaDevices.getUserMedia)
149
+ }
150
+
151
+ function paintLevel(level) {
152
+ root.style.setProperty("--studio-recorder-level", level.toFixed(3))
153
+ root.dataset.voiceRecorderLevel = level.toFixed(2)
154
+ }
155
+
156
+ function paintTimer(ms) {
157
+ const el = one("[data-voice-recorder-timer]")
158
+ if (el) el.textContent = formatElapsed(ms)
159
+ }
160
+
161
+ function hint(kind) {
162
+ if (state !== "recording" || hinted === kind || !copy.recording[kind]) return
163
+ hinted = kind
164
+ const status = one("[data-voice-recorder-status]")
165
+ if (status) status.textContent = copy.recording[kind]
166
+ }
167
+
168
+ function onSample(rms) {
169
+ announce(MICROPHONE_EVENTS.level, { rms }, win)
170
+ if (rms >= SILENCE_RMS && root.dataset.voiceRecorderHeard !== "true") {
171
+ root.dataset.voiceRecorderHeard = "true"
172
+ hint("heard")
173
+ } else if (root.dataset.voiceRecorderHeard !== "true" && startedAt && now() - startedAt > QUIET_HINT_MS) {
174
+ hint("quiet")
175
+ }
176
+ root.dataset.voiceRecorderPeak = meter ? meter.peak.toFixed(4) : "0"
177
+ }
178
+
179
+ function stopTimer() {
180
+ if (timer) win.clearInterval(timer)
181
+ timer = null
182
+ }
183
+
184
+ // Lets go of the meter and the microphone, and tells the chip.
185
+ function release() {
186
+ stopTimer()
187
+ if (meter) meter.close()
188
+ meter = null
189
+ if (stream) {
190
+ stream.getTracks().forEach((track) => track.stop())
191
+ announce(MICROPHONE_EVENTS.closed, {}, win)
192
+ }
193
+ stream = null
194
+ }
195
+
196
+ async function start() {
197
+ if (destroyed || !READY.includes(state)) return
198
+ stopWanted = false
199
+ setState("starting")
200
+ let opened
201
+ try {
202
+ opened = await open(win.navigator.mediaDevices)
203
+ } catch (error) {
204
+ if (destroyed) return
205
+ const denied = error && error.name === "NotAllowedError"
206
+ if (denied) announce(MICROPHONE_EVENTS.denied, {}, win)
207
+ setState(denied ? "denied" : "failed", denied ? null : failedLine(error, copy))
208
+ return
209
+ }
210
+ stream = opened.stream
211
+ microphone = { label: opened.label || "", deviceId: opened.deviceId || null }
212
+ // Destroyed, or let go of (hold mode) before the microphone opened.
213
+ if (destroyed || stopWanted) {
214
+ release()
215
+ if (!destroyed) setState("idle")
216
+ return
217
+ }
218
+ chunks = []
219
+ try {
220
+ begin(stream)
221
+ } catch (_) {
222
+ // MediaRecorder refused the stream: let go of the microphone, the meter
223
+ // and its frames rather than leave them running.
224
+ recorder = null
225
+ release()
226
+ setState("failed", copy.failed.start)
227
+ }
228
+ }
229
+
230
+ function begin(source) {
231
+ root.dataset.voiceRecorderHeard = "false"
232
+ root.dataset.voiceRecorderPeak = "0"
233
+ hinted = null
234
+ meter = makeMeter(source, { win, onSample, onPaint: paintLevel })
235
+ const MediaRecorderClass = win.MediaRecorder
236
+ const mimeType = pickMimeType(MediaRecorderClass.isTypeSupported && MediaRecorderClass.isTypeSupported.bind(MediaRecorderClass))
237
+ try {
238
+ recorder = mimeType ? new MediaRecorderClass(source, { mimeType }) : new MediaRecorderClass(source)
239
+ } catch (_) {
240
+ recorder = new MediaRecorderClass(source)
241
+ }
242
+ recorder.addEventListener("dataavailable", (event) => {
243
+ if (event.data && event.data.size > 0) chunks.push(event.data)
244
+ })
245
+ recorder.addEventListener("stop", () => finish())
246
+ recorder.start(250)
247
+ startedAt = now()
248
+ paintTimer(0)
249
+ timer = win.setInterval(() => {
250
+ const elapsed = now() - startedAt
251
+ paintTimer(elapsed)
252
+ if (maxMs && elapsed >= maxMs) stop()
253
+ }, 250)
254
+ setState("recording")
255
+ }
256
+
257
+ function stop() {
258
+ if (state === "starting") { stopWanted = true; return }
259
+ if (recorder && recorder.state !== "inactive") recorder.stop()
260
+ }
261
+
262
+ function finish() {
263
+ const durationMs = Math.round(now() - startedAt)
264
+ stopTimer()
265
+ if (meter) meter.sample()
266
+ const heard = meter ? meter.heard() : true
267
+ const peakRms = meter ? meter.peak : 0
268
+ const type = (recorder && recorder.mimeType) || (chunks[0] && chunks[0].type) || "audio/webm"
269
+ const blob = new win.Blob(chunks, { type })
270
+ chunks = []
271
+ recorder = null
272
+ release()
273
+ if (destroyed) return
274
+ if (blob.size === 0 || !heard) {
275
+ setState("silent", silentMessage(copy.silent.named, microphone.label))
276
+ announce(MICROPHONE_EVENTS.silent, { label: microphone.label }, win)
277
+ root.dispatchEvent(new win.CustomEvent(EVENTS.silent, { bubbles: true, detail: { durationMs, microphone: { ...microphone } } }))
278
+ return
279
+ }
280
+ setState("captured")
281
+ root.dispatchEvent(new win.CustomEvent(EVENTS.take, {
282
+ bubbles: true,
283
+ detail: { blob, mimeType: type, durationMs, peakRms, microphone: { ...microphone } }
284
+ }))
285
+ }
286
+
287
+ function toggle() {
288
+ if (state === "recording" || state === "starting") stop()
289
+ else start()
290
+ }
291
+
292
+ // Buttons inside the root: the record button follows the mode.
293
+ function onClick(event) {
294
+ if (mode !== "toggle") return
295
+ const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
296
+ if (!button || !root.contains(button)) return
297
+ event.preventDefault()
298
+ toggle()
299
+ }
300
+
301
+ function onPointerDown(event) {
302
+ if (mode !== "hold") return
303
+ const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
304
+ if (!button || !root.contains(button)) return
305
+ event.preventDefault()
306
+ if (button.setPointerCapture && event.pointerId != null) {
307
+ try { button.setPointerCapture(event.pointerId) } catch (_) { /* a synthetic pointer */ }
308
+ }
309
+ start()
310
+ }
311
+
312
+ function onPointerUp() {
313
+ if (mode === "hold" && (state === "recording" || state === "starting")) stop()
314
+ }
315
+
316
+ // Hold mode by keyboard: Space or Enter held down records.
317
+ function onKeyDown(event) {
318
+ if (mode !== "hold" || event.repeat || ![" ", "Enter"].includes(event.key)) return
319
+ if (!event.target.closest || !event.target.closest("[data-voice-recorder-button]")) return
320
+ event.preventDefault()
321
+ start()
322
+ }
323
+
324
+ function onKeyUp(event) {
325
+ if (mode !== "hold" || ![" ", "Enter"].includes(event.key)) return
326
+ event.preventDefault()
327
+ onPointerUp()
328
+ }
329
+
330
+ root.addEventListener("click", onClick)
331
+ root.addEventListener("pointerdown", onPointerDown)
332
+ root.addEventListener("pointerup", onPointerUp)
333
+ root.addEventListener("pointercancel", onPointerUp)
334
+ root.addEventListener("keydown", onKeyDown)
335
+ root.addEventListener("keyup", onKeyUp)
336
+ root.dataset.voiceRecorderMode = mode
337
+ paintLevel(0)
338
+ if (supported()) setState("idle")
339
+ else setState("unsupported")
340
+ root.dataset.voiceRecorderReady = "true"
341
+
342
+ return {
343
+ start,
344
+ stop,
345
+ toggle,
346
+ get state() { return state },
347
+ destroy() {
348
+ destroyed = true
349
+ root.removeEventListener("click", onClick)
350
+ root.removeEventListener("pointerdown", onPointerDown)
351
+ root.removeEventListener("pointerup", onPointerUp)
352
+ root.removeEventListener("pointercancel", onPointerUp)
353
+ root.removeEventListener("keydown", onKeyDown)
354
+ root.removeEventListener("keyup", onKeyUp)
355
+ if (recorder && recorder.state !== "inactive") {
356
+ try { recorder.stop() } catch (_) { /* already stopped */ }
357
+ }
358
+ recorder = null
359
+ release()
360
+ delete root.dataset.voiceRecorderReady
361
+ }
362
+ }
363
+ }
@@ -0,0 +1,45 @@
1
+ <%# The microphone chip (docs/VOICE_CAPTURE.md): names the microphone the page
2
+ records from, and a tap lists every microphone, the one in use marked, to
3
+ pick from (studio/microphone_chip, studio/microphone). While a take records
4
+ the dot beside the name shows the live level, so a dead microphone is
5
+ visible at a glance. Help: studio/voice/_microphone_help, inside it.
6
+
7
+ Locals, all optional:
8
+ copy: overrides merged over Studio::VoiceCapture::MICROPHONE_COPY
9
+ storage_key: where the pick is remembered (default "studio.microphone")
10
+ id: the picker's id, for aria-controls (one chip per page needs
11
+ none; two chips need two ids)
12
+
13
+ The chip never opens the microphone: a recorder (studio/voice/_recorder or
14
+ any studio/voice_recorder) does, and the chip listens on window. The
15
+ controller is lazy (studio/stimulus); if it fails to load, the root carries
16
+ data-studio-controller-failed="microphone-chip" and says that picking a
17
+ microphone did not load (the recorder still uses the system default). %>
18
+ <% copy = Studio::VoiceCapture.microphone_copy(local_assigns[:copy]) %>
19
+ <% picker_id = local_assigns.fetch(:id, "studio-microphone-picker") %>
20
+ <% storage_key = local_assigns.fetch(:storage_key, Studio::VoiceCapture::STORAGE_KEY) %>
21
+ <%= render "studio/voice/styles" %>
22
+ <div class="studio-mic"
23
+ data-studio-controller="microphone-chip"
24
+ data-microphone-state="prompt"
25
+ data-microphone-storage-key="<%= storage_key %>"
26
+ data-microphone-copy="<%= copy.to_json %>">
27
+ <button type="button" class="studio-mic-chip" data-microphone-chip aria-expanded="false" aria-controls="<%= picker_id %>">
28
+ <svg class="studio-mic-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
29
+ <path d="M12 2a3.5 3.5 0 0 0-3.5 3.5v6a3.5 3.5 0 0 0 7 0v-6A3.5 3.5 0 0 0 12 2Z"/>
30
+ <path d="M6 10.5a1 1 0 1 0-2 0 8 8 0 0 0 7 7.94V21H8.5a1 1 0 1 0 0 2h7a1 1 0 1 0 0-2H13v-2.56a8 8 0 0 0 7-7.94 1 1 0 1 0-2 0 6 6 0 0 1-12 0Z"/>
31
+ </svg>
32
+ <span class="studio-mic-dot" data-microphone-dot aria-hidden="true"></span>
33
+ <span class="studio-mic-name" data-microphone-label><%= copy.dig("prompt", "chip") %></span>
34
+ <svg class="studio-mic-caret" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
35
+ <path d="M2.2 4.2a.75.75 0 0 1 1.06 0L6 6.94l2.74-2.74a.75.75 0 1 1 1.06 1.06L6.53 8.53a.75.75 0 0 1-1.06 0L2.2 5.26a.75.75 0 0 1 0-1.06Z"/>
36
+ </svg>
37
+ </button>
38
+ <div id="<%= picker_id %>" class="studio-mic-panel card" data-microphone-panel hidden>
39
+ <p class="studio-mic-note" data-microphone-note role="status"><%= copy.dig("prompt", "note") %></p>
40
+ <ul class="studio-mic-list" data-microphone-list aria-label="Microphones"></ul>
41
+ <button type="button" class="studio-mic-help-link" data-microphone-help-link><%= copy.dig("help", "link") %></button>
42
+ </div>
43
+ <p class="studio-mic-failed"><%= copy["unavailable"] %></p>
44
+ <%= render "studio/voice/microphone_help", copy: copy, title_id: "#{picker_id}-help-title" %>
45
+ </div>