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,352 @@
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
+ denied: { button: "Record", status: "Allow the microphone to record." },
66
+ unsupported: { button: "Record", status: "This browser can't record here. Try Safari or Chrome." }
67
+ }
68
+
69
+ export function pickMimeType(isTypeSupported) {
70
+ if (typeof isTypeSupported !== "function") return ""
71
+ return MIME_CANDIDATES.find((type) => {
72
+ try { return isTypeSupported(type) } catch (_) { return false }
73
+ }) || ""
74
+ }
75
+
76
+ export function formatElapsed(ms) {
77
+ const seconds = Math.max(0, Math.floor(ms / 1000))
78
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
79
+ }
80
+
81
+ // The page's words over the defaults, one state at a time.
82
+ export function mergeCopy(overrides) {
83
+ const merged = {}
84
+ Object.keys(COPY).forEach((state) => { merged[state] = { ...COPY[state] } })
85
+ if (overrides && typeof overrides === "object") {
86
+ Object.entries(overrides).forEach(([state, words]) => {
87
+ if (words && typeof words === "object") merged[state] = { ...(merged[state] || {}), ...words }
88
+ })
89
+ }
90
+ return merged
91
+ }
92
+
93
+ function parseJson(text) {
94
+ try { return JSON.parse(text || "null") } catch (_) { return null }
95
+ }
96
+
97
+ // Binds one recorder. Returns { start, stop, toggle, destroy, get state }.
98
+ // options.open and options.meter replace openMicrophone and createLevelMeter
99
+ // (the unit tests pass stubs).
100
+ export function mountVoiceRecorder(root, { win = window, open = openMicrophone, meter: makeMeter = createLevelMeter } = {}) {
101
+ const one = (selector) => root.querySelector(selector)
102
+ const copy = mergeCopy(parseJson(root.getAttribute("data-voice-recorder-copy")))
103
+ const maxMs = parseInt(root.getAttribute("data-voice-recorder-max-ms") || "0", 10) || 0
104
+ const mode = root.getAttribute("data-voice-recorder-mode") === "hold" ? "hold" : "toggle"
105
+ const now = () => win.performance.now()
106
+
107
+ let state = null
108
+ let stream = null
109
+ let recorder = null
110
+ let meter = null
111
+ let chunks = []
112
+ let timer = null
113
+ let startedAt = 0
114
+ let microphone = { label: "", deviceId: null }
115
+ let hinted = null
116
+ let stopWanted = false
117
+ let destroyed = false
118
+
119
+ function setState(next, message = null) {
120
+ state = next
121
+ root.dataset.voiceRecorderState = next
122
+ const words = copy[next] || {}
123
+ const label = one("[data-voice-recorder-label]")
124
+ if (label && words.button) label.textContent = words.button
125
+ const status = one("[data-voice-recorder-status]")
126
+ if (status) status.textContent = message || words.status || ""
127
+ const button = one("[data-voice-recorder-button]")
128
+ if (button) {
129
+ button.setAttribute("aria-pressed", next === "recording" ? "true" : "false")
130
+ button.disabled = next === "unsupported"
131
+ }
132
+ if (next === "idle") paintTimer(0)
133
+ root.dispatchEvent(new win.CustomEvent(EVENTS.state, { bubbles: true, detail: { state: next } }))
134
+ }
135
+
136
+ function supported() {
137
+ return !!(win.MediaRecorder && win.navigator && win.navigator.mediaDevices && win.navigator.mediaDevices.getUserMedia)
138
+ }
139
+
140
+ function paintLevel(level) {
141
+ root.style.setProperty("--studio-recorder-level", level.toFixed(3))
142
+ root.dataset.voiceRecorderLevel = level.toFixed(2)
143
+ }
144
+
145
+ function paintTimer(ms) {
146
+ const el = one("[data-voice-recorder-timer]")
147
+ if (el) el.textContent = formatElapsed(ms)
148
+ }
149
+
150
+ function hint(kind) {
151
+ if (state !== "recording" || hinted === kind || !copy.recording[kind]) return
152
+ hinted = kind
153
+ const status = one("[data-voice-recorder-status]")
154
+ if (status) status.textContent = copy.recording[kind]
155
+ }
156
+
157
+ function onSample(rms) {
158
+ announce(MICROPHONE_EVENTS.level, { rms }, win)
159
+ if (rms >= SILENCE_RMS && root.dataset.voiceRecorderHeard !== "true") {
160
+ root.dataset.voiceRecorderHeard = "true"
161
+ hint("heard")
162
+ } else if (root.dataset.voiceRecorderHeard !== "true" && startedAt && now() - startedAt > QUIET_HINT_MS) {
163
+ hint("quiet")
164
+ }
165
+ root.dataset.voiceRecorderPeak = meter ? meter.peak.toFixed(4) : "0"
166
+ }
167
+
168
+ function stopTimer() {
169
+ if (timer) win.clearInterval(timer)
170
+ timer = null
171
+ }
172
+
173
+ // Lets go of the meter and the microphone, and tells the chip.
174
+ function release() {
175
+ stopTimer()
176
+ if (meter) meter.close()
177
+ meter = null
178
+ if (stream) {
179
+ stream.getTracks().forEach((track) => track.stop())
180
+ announce(MICROPHONE_EVENTS.closed, {}, win)
181
+ }
182
+ stream = null
183
+ }
184
+
185
+ async function start() {
186
+ if (destroyed || !READY.includes(state)) return
187
+ stopWanted = false
188
+ setState("starting")
189
+ let opened
190
+ try {
191
+ opened = await open(win.navigator.mediaDevices)
192
+ } catch (error) {
193
+ if (destroyed) return
194
+ const denied = error && error.name === "NotAllowedError"
195
+ if (denied) announce(MICROPHONE_EVENTS.denied, {}, win)
196
+ setState(denied ? "denied" : "failed", denied ? null : copy.failed.missing)
197
+ return
198
+ }
199
+ stream = opened.stream
200
+ microphone = { label: opened.label || "", deviceId: opened.deviceId || null }
201
+ // Destroyed, or let go of (hold mode) before the microphone opened.
202
+ if (destroyed || stopWanted) {
203
+ release()
204
+ if (!destroyed) setState("idle")
205
+ return
206
+ }
207
+ chunks = []
208
+ try {
209
+ begin(stream)
210
+ } catch (_) {
211
+ // MediaRecorder refused the stream: let go of the microphone, the meter
212
+ // and its frames rather than leave them running.
213
+ recorder = null
214
+ release()
215
+ setState("failed", copy.failed.start)
216
+ }
217
+ }
218
+
219
+ function begin(source) {
220
+ root.dataset.voiceRecorderHeard = "false"
221
+ root.dataset.voiceRecorderPeak = "0"
222
+ hinted = null
223
+ meter = makeMeter(source, { win, onSample, onPaint: paintLevel })
224
+ const MediaRecorderClass = win.MediaRecorder
225
+ const mimeType = pickMimeType(MediaRecorderClass.isTypeSupported && MediaRecorderClass.isTypeSupported.bind(MediaRecorderClass))
226
+ try {
227
+ recorder = mimeType ? new MediaRecorderClass(source, { mimeType }) : new MediaRecorderClass(source)
228
+ } catch (_) {
229
+ recorder = new MediaRecorderClass(source)
230
+ }
231
+ recorder.addEventListener("dataavailable", (event) => {
232
+ if (event.data && event.data.size > 0) chunks.push(event.data)
233
+ })
234
+ recorder.addEventListener("stop", () => finish())
235
+ recorder.start(250)
236
+ startedAt = now()
237
+ paintTimer(0)
238
+ timer = win.setInterval(() => {
239
+ const elapsed = now() - startedAt
240
+ paintTimer(elapsed)
241
+ if (maxMs && elapsed >= maxMs) stop()
242
+ }, 250)
243
+ setState("recording")
244
+ }
245
+
246
+ function stop() {
247
+ if (state === "starting") { stopWanted = true; return }
248
+ if (recorder && recorder.state !== "inactive") recorder.stop()
249
+ }
250
+
251
+ function finish() {
252
+ const durationMs = Math.round(now() - startedAt)
253
+ stopTimer()
254
+ if (meter) meter.sample()
255
+ const heard = meter ? meter.heard() : true
256
+ const peakRms = meter ? meter.peak : 0
257
+ const type = (recorder && recorder.mimeType) || (chunks[0] && chunks[0].type) || "audio/webm"
258
+ const blob = new win.Blob(chunks, { type })
259
+ chunks = []
260
+ recorder = null
261
+ release()
262
+ if (destroyed) return
263
+ if (blob.size === 0 || !heard) {
264
+ setState("silent", silentMessage(copy.silent.named, microphone.label))
265
+ announce(MICROPHONE_EVENTS.silent, { label: microphone.label }, win)
266
+ root.dispatchEvent(new win.CustomEvent(EVENTS.silent, { bubbles: true, detail: { durationMs, microphone: { ...microphone } } }))
267
+ return
268
+ }
269
+ setState("captured")
270
+ root.dispatchEvent(new win.CustomEvent(EVENTS.take, {
271
+ bubbles: true,
272
+ detail: { blob, mimeType: type, durationMs, peakRms, microphone: { ...microphone } }
273
+ }))
274
+ }
275
+
276
+ function toggle() {
277
+ if (state === "recording" || state === "starting") stop()
278
+ else start()
279
+ }
280
+
281
+ // Buttons inside the root: the record button follows the mode.
282
+ function onClick(event) {
283
+ if (mode !== "toggle") return
284
+ const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
285
+ if (!button || !root.contains(button)) return
286
+ event.preventDefault()
287
+ toggle()
288
+ }
289
+
290
+ function onPointerDown(event) {
291
+ if (mode !== "hold") return
292
+ const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
293
+ if (!button || !root.contains(button)) return
294
+ event.preventDefault()
295
+ if (button.setPointerCapture && event.pointerId != null) {
296
+ try { button.setPointerCapture(event.pointerId) } catch (_) { /* a synthetic pointer */ }
297
+ }
298
+ start()
299
+ }
300
+
301
+ function onPointerUp() {
302
+ if (mode === "hold" && (state === "recording" || state === "starting")) stop()
303
+ }
304
+
305
+ // Hold mode by keyboard: Space or Enter held down records.
306
+ function onKeyDown(event) {
307
+ if (mode !== "hold" || event.repeat || ![" ", "Enter"].includes(event.key)) return
308
+ if (!event.target.closest || !event.target.closest("[data-voice-recorder-button]")) return
309
+ event.preventDefault()
310
+ start()
311
+ }
312
+
313
+ function onKeyUp(event) {
314
+ if (mode !== "hold" || ![" ", "Enter"].includes(event.key)) return
315
+ event.preventDefault()
316
+ onPointerUp()
317
+ }
318
+
319
+ root.addEventListener("click", onClick)
320
+ root.addEventListener("pointerdown", onPointerDown)
321
+ root.addEventListener("pointerup", onPointerUp)
322
+ root.addEventListener("pointercancel", onPointerUp)
323
+ root.addEventListener("keydown", onKeyDown)
324
+ root.addEventListener("keyup", onKeyUp)
325
+ root.dataset.voiceRecorderMode = mode
326
+ paintLevel(0)
327
+ if (supported()) setState("idle")
328
+ else setState("unsupported")
329
+ root.dataset.voiceRecorderReady = "true"
330
+
331
+ return {
332
+ start,
333
+ stop,
334
+ toggle,
335
+ get state() { return state },
336
+ destroy() {
337
+ destroyed = true
338
+ root.removeEventListener("click", onClick)
339
+ root.removeEventListener("pointerdown", onPointerDown)
340
+ root.removeEventListener("pointerup", onPointerUp)
341
+ root.removeEventListener("pointercancel", onPointerUp)
342
+ root.removeEventListener("keydown", onKeyDown)
343
+ root.removeEventListener("keyup", onKeyUp)
344
+ if (recorder && recorder.state !== "inactive") {
345
+ try { recorder.stop() } catch (_) { /* already stopped */ }
346
+ }
347
+ recorder = null
348
+ release()
349
+ delete root.dataset.voiceRecorderReady
350
+ }
351
+ }
352
+ }
@@ -0,0 +1,108 @@
1
+ <%# The voice row: "Set up your voice". Rendered only when Studio.voice_profile
2
+ is on and the user has the voice columns (lib/studio/profile_sections.rb).
3
+
4
+ Locals: user (required); embedder (optional, defaults to
5
+ Studio.voice_embedder: the browser e2e lab passes "fake" rather than write
6
+ the process-wide setting).
7
+
8
+ THE DEVICE DOES THE LISTENING. Three phrases are recorded one at a time;
9
+ studio/voice_profile turns each take into a speaker embedding in the browser,
10
+ drops the audio, and Save posts only the vectors to /profile/voice, where
11
+ Studio::VoiceMatch averages them into one voice on this user. Nothing here
12
+ uploads a recording.
13
+
14
+ WITHOUT JAVASCRIPT the row still says whether a voice is saved and still
15
+ deletes it: the summary and the Delete form are plain HTML. Only recording
16
+ needs the controller (lazy, studio/stimulus). If it fails to load, the row
17
+ carries data-studio-controller-failed="voice-profile" and says so.
18
+
19
+ SCOPED CSS rather than utilities, as in _identity_styles: the meter ring and
20
+ the phrase marks must render in an app whose Tailwind build never saw this
21
+ file. The ring reads --studio-voice-level (0..1), which the module writes
22
+ while a take records. %>
23
+ <% embedder = local_assigns.fetch(:embedder, Studio.voice_embedder).to_s %>
24
+ <% config = Studio::VoiceMatch.browser_config(embedder) %>
25
+ <% saved = user.voice_embedding.present? %>
26
+ <% current = saved && user.voice_embedding_model.to_s == config["id"] %>
27
+
28
+ <style>
29
+ .studio-voice-consent { font-size: .8125rem; color: var(--color-text-secondary, inherit); margin-bottom: 1rem; }
30
+ .studio-voice-lead { margin-bottom: .5rem; }
31
+ .studio-voice-row { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; }
32
+ .studio-voice-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
33
+ .studio-voice-phrases { list-style: none; padding: 0; margin: 0 0 1rem; display: grid; gap: .5rem; }
34
+ .studio-voice-phrase { display: flex; align-items: center; gap: .75rem; padding: .75rem; border-radius: .75rem;
35
+ border: 1px solid var(--color-border, rgb(127 127 127 / .25)); }
36
+ .studio-voice-phrase[data-phrase-state="current"] { outline: 2px solid rgb(var(--color-primary-500-rgb, 99 102 241)); outline-offset: 1px; }
37
+ .studio-voice-mark { flex-shrink: 0; width: 1.75rem; height: 1.75rem; border-radius: 9999px; display: flex;
38
+ align-items: center; justify-content: center; font-size: .8125rem; font-weight: 700;
39
+ border: 1px solid var(--color-border, rgb(127 127 127 / .25)); color: var(--color-text-secondary, inherit); }
40
+ .studio-voice-phrase[data-phrase-state="done"] .studio-voice-mark { background: #10b981; border-color: #10b981; color: #fff; }
41
+ .studio-voice-controls { display: flex; flex-direction: column; align-items: center; gap: .75rem; text-align: center; }
42
+ .studio-voice-record { min-width: 12rem; border-radius: 9999px; padding: .875rem 1.75rem; font-weight: 700;
43
+ color: #fff; background: rgb(var(--color-primary-500-rgb, 99 102 241));
44
+ box-shadow: 0 0 0 calc(var(--studio-voice-level, 0) * 14px) rgb(var(--color-primary-500-rgb, 99 102 241) / .3);
45
+ transition: box-shadow 80ms linear; }
46
+ .studio-voice-record:disabled { opacity: .5; cursor: not-allowed; }
47
+ [data-voice-state="recording"] .studio-voice-record { background: #dc2626; }
48
+ @media (prefers-reduced-motion: reduce) { .studio-voice-record { transition: none; } }
49
+ .studio-voice-status { min-height: 2.5rem; max-width: 24rem; color: var(--color-text-body, inherit); }
50
+ .studio-voice-failed { display: none; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
51
+ [data-studio-controller-failed~="voice-profile"] .studio-voice-failed { display: block; }
52
+ </style>
53
+
54
+ <div data-studio-controller="voice-profile"
55
+ data-voice-state="idle"
56
+ data-voice-save-url="<%= profile_voice_path %>"
57
+ data-voice-embedder="<%= config.to_json %>">
58
+ <p class="studio-voice-consent" data-voice-consent>
59
+ This saves a voice pattern, not a recording. Your device listens to three short phrases and keeps
60
+ only numbers that describe how you sound, so your agent can tell it's you. Delete it any time.
61
+ </p>
62
+
63
+ <div class="studio-voice-row" data-voice-summary>
64
+ <p class="text-body min-w-0" data-voice-saved="<%= current ? "current" : (saved ? "stale" : "none") %>">
65
+ <% if current %>
66
+ Your voice is set up<% if user.voice_embedding_updated_at %> (<%= user.voice_embedding_updated_at.to_date.to_fs(:long) %>)<% end %>.
67
+ <% elsif saved %>
68
+ Your saved voice came from an older model. Record it again to keep using it.
69
+ <% else %>
70
+ Read three short phrases so your agent knows your voice.
71
+ <% end %>
72
+ </p>
73
+ <div class="studio-voice-actions">
74
+ <button type="button" class="btn btn-primary btn-sm" data-voice-action="open">
75
+ <%= saved ? "Re-record" : "Set up your voice" %>
76
+ </button>
77
+ <% if saved %>
78
+ <%= button_to "Delete", profile_voice_path, method: :delete,
79
+ class: "btn btn-sm", form: { class: "inline", data: { voice_delete: true } } %>
80
+ <% end %>
81
+ </div>
82
+ </div>
83
+ <p class="studio-voice-failed">Recording didn't load on this page. Reload to try again.</p>
84
+
85
+ <div data-voice-setup hidden>
86
+ <p class="studio-voice-lead text-sm font-semibold text-heading">Read each one out loud</p>
87
+ <ol class="studio-voice-phrases">
88
+ <% Studio::VoiceMatch::PHRASES.each_with_index do |phrase, i| %>
89
+ <li class="studio-voice-phrase" data-voice-phrase data-phrase-state="waiting">
90
+ <span class="studio-voice-mark" aria-hidden="true"><%= i + 1 %></span>
91
+ <span class="text-body">&ldquo;<%= phrase %>&rdquo;</span>
92
+ </li>
93
+ <% end %>
94
+ </ol>
95
+
96
+ <div class="studio-voice-controls">
97
+ <button type="button" class="studio-voice-record" data-voice-action="record" data-voice-record
98
+ aria-pressed="false" disabled>
99
+ <span data-voice-record-label>Record phrase 1</span>
100
+ </button>
101
+ <p class="studio-voice-status" data-voice-status role="status" aria-live="polite"></p>
102
+ <div class="studio-voice-actions">
103
+ <button type="button" class="btn btn-primary btn-sm" data-voice-action="save" data-voice-save disabled>Save</button>
104
+ <button type="button" class="btn btn-sm" data-voice-action="cancel">Cancel</button>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </div>
@@ -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>
@@ -0,0 +1,63 @@
1
+ <%# "How to change your microphone in Chrome": a native modal <dialog> inside
2
+ the microphone chip (studio/voice/_microphone_chip). showModal() makes the
3
+ page behind it inert, so focus stays inside; Esc and Close shut it, and
4
+ focus goes back where it was (studio/microphone_chip). It opens after a
5
+ switch, on a refused microphone, on the second silent take in a row on one
6
+ microphone, and from the picker's help link. The lead line on top says
7
+ which; the browser decides between Chrome's steps and one generic line
8
+ (browserFamily, studio/microphone). The picture is a drawn, simplified
9
+ address bar, never a screenshot.
10
+
11
+ Locals: copy (the chip's merged copy), title_id. %>
12
+ <% help = copy.fetch("help") %>
13
+ <dialog class="studio-mic-help" data-microphone-dialog aria-labelledby="<%= title_id %>">
14
+ <div class="studio-mic-help-body">
15
+ <div class="studio-mic-help-lead" data-microphone-help-lead hidden>
16
+ <p class="studio-mic-help-headline" data-microphone-help-headline></p>
17
+ <p class="studio-mic-help-tip" data-microphone-help-tip></p>
18
+ </div>
19
+
20
+ <h2 id="<%= title_id %>" class="studio-mic-help-title" data-microphone-help-title><%= help.fetch("title_chrome") %></h2>
21
+
22
+ <div class="studio-mic-help-chrome" data-microphone-help-chrome>
23
+ <svg class="studio-mic-mock" viewBox="0 0 320 168" role="img" aria-label="A drawing of Chrome's address bar: the site settings icon left of the address is circled, and below it the Microphone setting is on with its list of microphones.">
24
+ <rect x="4" y="6" width="312" height="36" rx="18" class="mock-bar"/>
25
+ <circle cx="26" cy="24" r="14" class="mock-ring"/>
26
+ <g class="mock-ink" stroke-width="2" stroke-linecap="round">
27
+ <line x1="19" y1="20" x2="33" y2="20"/><circle cx="23" cy="20" r="2.5" class="mock-knob"/>
28
+ <line x1="19" y1="28" x2="33" y2="28"/><circle cx="29" cy="28" r="2.5" class="mock-knob"/>
29
+ </g>
30
+ <text x="48" y="29" class="mock-text" font-size="13"><%= request.host %></text>
31
+ <rect x="12" y="54" width="232" height="106" rx="10" class="mock-panel"/>
32
+ <g class="mock-ink" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
33
+ <rect x="26" y="68" width="8" height="13" rx="4"/><path d="M23 77a7 7 0 0 0 14 0M30 84v4"/>
34
+ </g>
35
+ <text x="46" y="82" class="mock-text" font-size="13" font-weight="600">Microphone</text>
36
+ <rect x="196" y="70" width="34" height="18" rx="9" class="mock-on"/>
37
+ <circle cx="221" cy="79" r="6" class="mock-knob-on"/>
38
+ <rect x="26" y="102" width="204" height="44" rx="8" class="mock-ring"/>
39
+ <text x="38" y="129" class="mock-text" font-size="12">MacBook Pro Microphone</text>
40
+ <path d="M212 120l5 5 5-5" class="mock-ink" stroke-width="2" fill="none" stroke-linecap="round"/>
41
+ <g font-size="11" font-weight="700" text-anchor="middle">
42
+ <circle cx="50" cy="8" r="8" class="mock-badge"/><text x="50" y="12" class="mock-badge-text">1</text>
43
+ <circle cx="252" cy="70" r="8" class="mock-badge"/><text x="252" y="74" class="mock-badge-text">2</text>
44
+ <circle cx="252" cy="118" r="8" class="mock-badge"/><text x="252" y="122" class="mock-badge-text">3</text>
45
+ </g>
46
+ </svg>
47
+ <ol class="studio-mic-help-steps" data-microphone-help-steps>
48
+ <% help.fetch("steps_chrome").each_with_index do |step, i| %>
49
+ <li>
50
+ <span class="studio-mic-step" aria-hidden="true"><%= i + 1 %></span>
51
+ <span><%= step %></span>
52
+ </li>
53
+ <% end %>
54
+ </ol>
55
+ </div>
56
+
57
+ <p class="studio-mic-help-other" data-microphone-help-other hidden><%= help.fetch("other") %></p>
58
+
59
+ <form method="dialog" class="studio-mic-help-actions">
60
+ <button type="submit" class="btn btn-primary studio-mic-help-close" data-microphone-help-close><%= help.fetch("close") %></button>
61
+ </form>
62
+ </div>
63
+ </dialog>
@@ -0,0 +1,34 @@
1
+ <%# The record button (docs/VOICE_CAPTURE.md): studio/voice_recorder opens the
2
+ microphone through studio/microphone (so it records from what the chip
3
+ names), runs the engine's one level meter (studio/level_meter) as the ring
4
+ around the button, refuses a silent take, and hands a heard take to the page
5
+ as a studio:voice-recorder:take event with the blob. It uploads nothing:
6
+ the page that renders it decides where the audio goes.
7
+
8
+ Locals, all optional:
9
+ copy: per-state words merged over studio/voice_recorder's COPY
10
+ (e.g. { "idle" => { "button" => "Talk" } })
11
+ mode: "toggle" (tap to start, tap to stop; the default) or "hold"
12
+ (press and hold to talk)
13
+ max_ms: stop a take on its own after this long (default 60000)
14
+ id: an id for the root, for a page's own listener
15
+
16
+ The controller is lazy (studio/stimulus); if it fails to load, the root
17
+ carries data-studio-controller-failed="voice-recorder" and says so. %>
18
+ <% copy = Studio::VoiceCapture.recorder_copy(local_assigns[:copy]) %>
19
+ <% mode = local_assigns.fetch(:mode, "toggle").to_s == "hold" ? "hold" : "toggle" %>
20
+ <%= render "studio/voice/styles" %>
21
+ <div class="studio-recorder"
22
+ <%= tag.attributes(id: local_assigns[:id]) %>
23
+ data-studio-controller="voice-recorder"
24
+ data-voice-recorder-state="idle"
25
+ data-voice-recorder-mode="<%= mode %>"
26
+ data-voice-recorder-max-ms="<%= local_assigns.fetch(:max_ms, 60_000).to_i %>"
27
+ data-voice-recorder-copy="<%= copy.except("unavailable").to_json %>">
28
+ <button type="button" class="studio-recorder-button" data-voice-recorder-button aria-pressed="false">
29
+ <span data-voice-recorder-label><%= copy.dig("idle", "button") %></span>
30
+ </button>
31
+ <span class="studio-recorder-timer" data-voice-recorder-timer aria-hidden="true">0:00</span>
32
+ <p class="studio-recorder-status" data-voice-recorder-status role="status" aria-live="polite"></p>
33
+ <p class="studio-recorder-failed"><%= copy["unavailable"] %></p>
34
+ </div>