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,250 @@
1
+ // studio/microphone: which microphone a page records from. The one the person
2
+ // picked on the microphone chip (studio/microphone_chip), remembered on this
3
+ // device, or the system default when they never picked one or the picked one
4
+ // is gone. Ported from Voicebox's microphone.js (docs/VOICE_CAPTURE.md).
5
+ //
6
+ // Every recorder opens the microphone through openMicrophone, so the recorder
7
+ // and the chip never disagree about the device. The recorder then tells the
8
+ // chip what it opened, how loud the take is, and when it let go, with EVENTS on
9
+ // window; the chip only listens.
10
+ //
11
+ // Why it exists: Chrome's site setting can pick a microphone that is switched
12
+ // off (a wireless headset) instead of the system's own, and every take then
13
+ // records silence with nothing on the page saying which microphone was used.
14
+ //
15
+ // It imports nothing, so test/javascript/microphone.test.mjs loads it as a
16
+ // data: module.
17
+
18
+ // Where the pick is remembered (localStorage). One key for every engine page
19
+ // on an origin, so the chip on the hub's chat and on /profile agree. A host
20
+ // that wants its own key calls configureMicrophone({ storageKey }) or renders
21
+ // the chip with storage_key: (studio/voice/_microphone_chip).
22
+ export const STORAGE_KEY = "studio.microphone"
23
+
24
+ let currentKey = STORAGE_KEY
25
+
26
+ export function configureMicrophone({ storageKey } = {}) {
27
+ currentKey = storageKey ? String(storageKey) : STORAGE_KEY
28
+ return currentKey
29
+ }
30
+
31
+ export function storageKey() { return currentKey }
32
+
33
+ export const EVENTS = {
34
+ opened: "studio:microphone:opened", // detail: { label, deviceId, fellBack }
35
+ level: "studio:microphone:level", // detail: { rms }
36
+ closed: "studio:microphone:closed",
37
+ denied: "studio:microphone:denied",
38
+ chosen: "studio:microphone:chosen", // detail: { deviceId }
39
+ silent: "studio:microphone:silent" // detail: { label }: a take came back silent
40
+ }
41
+
42
+ // Which help to show: Chrome's (and Chromium Edge's, Opera's) site-settings
43
+ // steps, or one generic line for Safari and Firefox. Only desktop and Android
44
+ // Chromium carry "Chrome/" (or Edge's "Edg/"); Chrome and Edge on iOS say
45
+ // "CriOS/" and "EdgiOS/", WebKit underneath with no site-settings icon by the
46
+ // address, so they get the generic line too.
47
+ export function browserFamily(userAgent) {
48
+ return /(Chrome|Chromium|Edg)\//.test(String(userAgent || "")) ? "chrome" : "other"
49
+ }
50
+
51
+ // Chrome's two stand-ins for "whatever the system picks". They repeat a real
52
+ // device under another id, so the picker leaves them out when real ones exist.
53
+ const PSEUDO_IDS = ["default", "communications"]
54
+
55
+ // "Arctis Nova Pro Wireless (1038:12e0)" -> "Arctis Nova Pro Wireless", and
56
+ // Chrome's "Default - MacBook Pro Microphone (Built-in)" ->
57
+ // "MacBook Pro Microphone". The USB vendor:product pair and the "(Built-in)"
58
+ // tag mean nothing to a person.
59
+ export function trimLabel(label) {
60
+ return String(label || "")
61
+ .replace(/^(Default|Communications)\s+-\s+/i, "")
62
+ .replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)/gi, "")
63
+ .replace(/\s*\(Built-in\)/gi, "")
64
+ .replace(/\s+/g, " ")
65
+ .trim()
66
+ }
67
+
68
+ export function audioInputs(devices) {
69
+ return Array.from(devices || []).filter((device) => device && device.kind === "audioinput")
70
+ }
71
+
72
+ // Before the person allows the microphone the browser lists inputs with empty
73
+ // labels (and often empty ids): there is nothing to show yet.
74
+ export function permissionGranted(inputs) {
75
+ return (inputs || []).some((device) => !!device.label)
76
+ }
77
+
78
+ // The devices worth offering in the picker: real ones, named, once each.
79
+ export function pickerChoices(inputs) {
80
+ const named = (inputs || []).filter((device) => device.label && device.deviceId)
81
+ const real = named.filter((device) => !PSEUDO_IDS.includes(device.deviceId))
82
+ const seen = new Set()
83
+ return (real.length ? real : named).flatMap((device) => {
84
+ if (seen.has(device.deviceId)) return []
85
+ seen.add(device.deviceId)
86
+ return [{ deviceId: device.deviceId, label: trimLabel(device.label) }]
87
+ })
88
+ }
89
+
90
+ // The remembered device, if it is still plugged in; null means "let the
91
+ // browser pick". Before permission the ids are blank and nothing can be
92
+ // checked, so the remembered one is tried and openMicrophone falls back if the
93
+ // browser says it is gone.
94
+ export function chooseDeviceId(inputs, rememberedId) {
95
+ if (!rememberedId) return null
96
+ if (inputs.some((device) => device.deviceId === rememberedId)) return rememberedId
97
+ if (inputs.every((device) => !device.deviceId)) return rememberedId
98
+ return null
99
+ }
100
+
101
+ // The system default, as a device to ask for by id. Chrome lists it as the
102
+ // "default" entry, and a bare { audio: true } does NOT open it: Chrome opens
103
+ // its per-site preference instead, which can be a headset switched off in a
104
+ // drawer. So with nothing picked the page asks for the system default by id,
105
+ // and the chip, which names the system default, names what is opened. The
106
+ // real twin's id when there is one (the track then reports that id), else
107
+ // "default" itself; null where the browser lists no "default" (Safari,
108
+ // Firefox), and there the browser's own choice stands and the opened track
109
+ // corrects the chip.
110
+ export function systemDefaultId(inputs) {
111
+ const entry = inputs.find((device) => device.deviceId === "default" && device.label)
112
+ if (!entry) return null
113
+ const name = trimLabel(entry.label)
114
+ const twin = inputs.find((device) => device.deviceId && !PSEUDO_IDS.includes(device.deviceId) &&
115
+ trimLabel(device.label) === name)
116
+ return twin ? twin.deviceId : "default"
117
+ }
118
+
119
+ export function audioConstraints(deviceId) {
120
+ return deviceId ? { audio: { deviceId: { exact: deviceId } } } : { audio: true }
121
+ }
122
+
123
+ // The errors getUserMedia gives when an exact device is not there.
124
+ export function isMissingDevice(error) {
125
+ return !!error && ["OverconstrainedError", "NotFoundError"].includes(error.name)
126
+ }
127
+
128
+ // What the chip names before a take has opened anything: the picked device,
129
+ // else the system default's name, else the first input. After a take, the
130
+ // track's own label wins (activeLabel), because that is the device that was
131
+ // actually recorded from.
132
+ export function displayedLabel(inputs, rememberedId, activeLabel) {
133
+ if (activeLabel) return trimLabel(activeLabel)
134
+ if (!permissionGranted(inputs)) return null
135
+ const remembered = rememberedId && inputs.find((device) => device.deviceId === rememberedId && device.label)
136
+ if (remembered) return trimLabel(remembered.label)
137
+ const fallback = inputs.find((device) => device.deviceId === "default" && device.label) ||
138
+ inputs.find((device) => device.label)
139
+ return fallback ? trimLabel(fallback.label) : null
140
+ }
141
+
142
+ // ---- Remembering the choice (localStorage can throw: private mode, blocked
143
+ // site data), so every touch is guarded and a failure means "no choice". ----
144
+
145
+ function storageOf(storage) {
146
+ if (storage !== undefined) return storage
147
+ try { return globalThis.localStorage || null } catch (_) { return null }
148
+ }
149
+
150
+ export function rememberedDeviceId(storage, key = currentKey) {
151
+ try { return storageOf(storage)?.getItem(key) || null } catch (_) { return null }
152
+ }
153
+
154
+ export function rememberDevice(deviceId, storage, key = currentKey) {
155
+ try {
156
+ const store = storageOf(storage)
157
+ if (!store) return
158
+ if (deviceId) store.setItem(key, deviceId)
159
+ else store.removeItem(key)
160
+ } catch (_) { /* nothing remembered: the browser picks next time */ }
161
+ }
162
+
163
+ export function forgetDevice(storage, key = currentKey) { rememberDevice(null, storage, key) }
164
+
165
+ export function trackOf(stream) {
166
+ return (stream && stream.getAudioTracks && stream.getAudioTracks()[0]) || null
167
+ }
168
+
169
+ // Opens the microphone the person picked, or the system default. A picked
170
+ // device that has gone away is forgotten and the default opened instead
171
+ // (fellBack), so a headset left in a drawer never blocks recording. Other
172
+ // errors (NotAllowedError above all) are the caller's to word.
173
+ //
174
+ // With nothing picked it asks for the system default by id (systemDefaultId),
175
+ // so Chrome's per-site preference never silently wins. Before permission the
176
+ // ids are blank and only a bare request can ask; once that grants permission,
177
+ // a stream that landed on another device is closed and the system default
178
+ // opened in its place, so even the first take records from what the chip will
179
+ // name.
180
+ export async function openMicrophone(mediaDevices = globalThis.navigator && globalThis.navigator.mediaDevices, storage) {
181
+ const remembered = rememberedDeviceId(storage)
182
+ const inputs = await listInputs(mediaDevices)
183
+ const picked = chooseDeviceId(inputs, remembered)
184
+ let fellBack = !!remembered && !picked
185
+ if (fellBack) forgetDevice(storage)
186
+ let deviceId = picked || systemDefaultId(inputs)
187
+
188
+ let stream
189
+ try {
190
+ stream = await mediaDevices.getUserMedia(audioConstraints(deviceId))
191
+ } catch (error) {
192
+ if (!deviceId || !isMissingDevice(error)) throw error
193
+ if (picked) {
194
+ forgetDevice(storage)
195
+ fellBack = true
196
+ }
197
+ deviceId = null
198
+ stream = await mediaDevices.getUserMedia(audioConstraints(null))
199
+ }
200
+
201
+ if (!deviceId && !permissionGranted(inputs)) stream = await reopenSystemDefault(mediaDevices, stream)
202
+
203
+ const track = trackOf(stream)
204
+ const settings = settingsOf(track)
205
+ const opened = { label: trimLabel(track && track.label), deviceId: settings.deviceId || deviceId || null, fellBack }
206
+ announce(EVENTS.opened, opened)
207
+ return { stream, ...opened }
208
+ }
209
+
210
+ async function listInputs(mediaDevices) {
211
+ if (!mediaDevices || !mediaDevices.enumerateDevices) return []
212
+ try { return audioInputs(await mediaDevices.enumerateDevices()) } catch (_) { return [] }
213
+ }
214
+
215
+ function settingsOf(track) {
216
+ try { return (track && track.getSettings && track.getSettings()) || {} } catch (_) { return {} }
217
+ }
218
+
219
+ // After the permission prompt: is the bare request's stream the system
220
+ // default? If it landed elsewhere (Chrome's site preference), let it go and
221
+ // open the system default by id. Any doubt keeps the stream it has.
222
+ export async function reopenSystemDefault(mediaDevices, stream) {
223
+ const inputs = await listInputs(mediaDevices)
224
+ const wanted = systemDefaultId(inputs)
225
+ const track = trackOf(stream)
226
+ if (!wanted || !track) return stream
227
+ const got = settingsOf(track).deviceId
228
+ const wantedLabel = trimLabel((inputs.find((device) => device.deviceId === wanted) || {}).label)
229
+ if (got === wanted || got === "default" || (wantedLabel && trimLabel(track.label) === wantedLabel)) return stream
230
+ try {
231
+ const replacement = await mediaDevices.getUserMedia(audioConstraints(wanted))
232
+ stream.getTracks().forEach((t) => t.stop())
233
+ return replacement
234
+ } catch (_) {
235
+ return stream
236
+ }
237
+ }
238
+
239
+ // Tells the chip (and anything else listening on window).
240
+ export function announce(name, detail = {}, win = globalThis) {
241
+ try { win.dispatchEvent(new CustomEvent(name, { detail })) } catch (_) { /* no listeners to tell */ }
242
+ }
243
+
244
+ // The silent-take line that names the microphone, or null when the track had
245
+ // no name to give (then the page's general line stands).
246
+ export function silentMessage(template, label) {
247
+ const name = trimLabel(label)
248
+ if (!template || !name) return null
249
+ return template.replace("%{microphone}", name)
250
+ }
@@ -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
+ }
@@ -57,7 +57,12 @@ export const schema = {
57
57
  // knowledge transcript: a recording whose controller failed still plays from
58
58
  // its own controls, and its page says that click-to-play did not load. So is the
59
59
  // agent chat: a chat whose controller failed still renders, and its section
60
- // carries data-studio-controller-failed="agent-chat".
60
+ // carries data-studio-controller-failed="agent-chat". So is the voice row: a
61
+ // row whose controller failed keeps its saved state and its Delete, and says
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").
61
66
  export const LAZY = {
62
67
  "board": () => import("studio/controllers/board_controller"),
63
68
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -67,7 +72,10 @@ export const LAZY = {
67
72
  "footer-map": () => import("studio/controllers/footer_map_controller"),
68
73
  "survey": () => import("studio/controllers/survey_controller"),
69
74
  "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
70
- "agent-chat": () => import("studio/controllers/agent_chat_controller")
75
+ "agent-chat": () => import("studio/controllers/agent_chat_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")
71
79
  }
72
80
 
73
81
  export const application = Application.start(document.documentElement, schema)