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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +3 -0
- data/app/controllers/studio/profiles_controller.rb +56 -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_profile_controller.js +20 -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 +10 -2
- data/app/javascript/studio/voice_profile.js +444 -0
- data/app/javascript/studio/voice_recorder.js +352 -0
- data/app/views/studio/profiles/_voice_section.html.erb +108 -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/db/migrate/20261029120000_add_voice_profile_to_users.rb +44 -0
- data/lib/studio/engine.rb +10 -0
- data/lib/studio/profile_sections.rb +11 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_capture.rb +95 -0
- data/lib/studio/voice_match.rb +238 -0
- data/lib/studio/voice_transcription.rb +404 -0
- data/lib/studio.rb +57 -0
- metadata +20 -2
|
@@ -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)
|