studio-engine 0.120.0 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,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>
@@ -0,0 +1,90 @@
1
+ <%# The voice capture layer's scoped CSS (docs/VOICE_CAPTURE.md), rendered by
2
+ the chip and the recorder partials. Scoped classes rather than utilities, as
3
+ in studio/profiles/_voice_section: the chip, the dot, the ring and the help
4
+ drawing must render in an app whose Tailwind build never saw these files.
5
+ Every colour is a theme token (the primary through --color-primary-500-rgb
6
+ and the CTA fill under its label), so dark mode and each app's palette come
7
+ for free. Tap targets are 44px. A page rendering both partials carries this
8
+ block twice, which is harmless. %>
9
+ <style>
10
+ .studio-mic { display: flex; width: 100%; flex-direction: column; align-items: center; gap: .5rem; }
11
+ .studio-mic-chip { display: inline-flex; min-height: 44px; max-width: 100%; align-items: center; gap: .5rem;
12
+ border-radius: 1.5rem; border: 1px solid var(--color-border, rgb(127 127 127 / .25));
13
+ background: var(--color-inset, rgb(127 127 127 / .12)); padding: .5rem 1rem; font-size: .875rem; font-weight: 600;
14
+ color: var(--color-text, inherit); cursor: pointer; text-align: left; }
15
+ .studio-mic-chip:focus-visible { outline: 3px solid rgb(var(--color-primary-500-rgb, 99 102 241) / .5); outline-offset: 2px; }
16
+ .studio-mic-icon { width: 1rem; height: 1rem; flex-shrink: 0; color: var(--color-text-secondary, currentColor); }
17
+ .studio-mic-name { min-width: 0; overflow-wrap: anywhere; }
18
+ .studio-mic-caret { width: .75rem; height: .75rem; flex-shrink: 0; color: var(--color-text-muted, currentColor); }
19
+ .studio-mic-chip[aria-expanded="true"] .studio-mic-caret { transform: rotate(180deg); }
20
+ [data-microphone-state="denied"] .studio-mic-chip { border-color: rgb(var(--color-primary-500-rgb, 99 102 241)); }
21
+ /* The live dot: hollow while nothing records; while a take records it fills
22
+ with the primary colour as loud as the voice (--studio-mic-level, 0..1). */
23
+ .studio-mic-dot { width: .625rem; height: .625rem; flex-shrink: 0; border-radius: 9999px; border: 2px solid currentColor; opacity: .35; }
24
+ [data-microphone-live="true"] .studio-mic-dot { opacity: 1; border-color: rgb(var(--color-primary-500-rgb, 99 102 241));
25
+ background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / calc(.15 + var(--studio-mic-level, 0) * .85));
26
+ transform: scale(calc(1 + var(--studio-mic-level, 0) * .5)); transition: transform 80ms linear, background-color 80ms linear; }
27
+ .studio-mic-panel { width: 100%; max-width: 24rem; padding: .75rem; text-align: left; display: grid; gap: .5rem; }
28
+ .studio-mic-panel[hidden] { display: none; }
29
+ .studio-mic-note { font-size: .875rem; color: var(--color-text-muted, inherit); }
30
+ .studio-mic-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .25rem; }
31
+ .studio-mic-option { display: flex; min-height: 44px; width: 100%; align-items: center; gap: .5rem; border-radius: .75rem;
32
+ border: 1px solid transparent; background: transparent; padding: .5rem .75rem; text-align: left; font-size: .875rem;
33
+ color: var(--color-text, inherit); cursor: pointer; }
34
+ .studio-mic-option:hover { background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / .08); }
35
+ .studio-mic-option[aria-current="true"] { border-color: rgb(var(--color-primary-500-rgb, 99 102 241));
36
+ background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / .12); font-weight: 600; }
37
+ .studio-mic-check { width: 1rem; flex-shrink: 0; font-weight: 700; color: rgb(var(--color-primary-500-rgb, 99 102 241)); }
38
+ .studio-mic-option-label { min-width: 0; flex: 1; overflow-wrap: anywhere; }
39
+ .studio-mic-in-use { flex-shrink: 0; border-radius: 9999px; padding: .125rem .5rem; font-size: .75rem; font-weight: 700;
40
+ background: var(--color-cta-fill, var(--color-cta, rgb(var(--color-primary-500-rgb, 99 102 241)))); color: var(--btn-primary-fg, #fff); }
41
+ .studio-mic-help-link { min-height: 44px; width: 100%; border-radius: .75rem; padding: 0 .75rem; text-align: left; font-size: .875rem;
42
+ font-weight: 600; color: var(--color-text-secondary, inherit); text-decoration: underline; background: transparent; border: 0; cursor: pointer; }
43
+ /* The help dialog: a native modal <dialog>, so the page behind it is inert. */
44
+ .studio-mic-help { border: 0; padding: 0; margin: auto; width: min(28rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem);
45
+ overflow-y: auto; text-align: left; color: var(--color-text-body, inherit); background: var(--color-surface, Canvas); border-radius: 1rem; }
46
+ .studio-mic-help::backdrop { background-color: rgb(0 0 0 / .55); }
47
+ .studio-mic-help-body { display: grid; gap: 1rem; padding: 1.25rem; }
48
+ .studio-mic-help-lead { display: grid; gap: .25rem; border-radius: .75rem; background: var(--color-inset, rgb(127 127 127 / .12)); padding: .75rem; }
49
+ .studio-mic-help-lead[hidden], .studio-mic-help-chrome[hidden], .studio-mic-help-other[hidden] { display: none; }
50
+ .studio-mic-help-headline { font-weight: 600; color: var(--color-text, inherit); margin: 0; }
51
+ .studio-mic-help-tip { font-size: .875rem; color: var(--color-text-secondary, inherit); margin: 0; }
52
+ .studio-mic-help-title { font-size: 1.25rem; font-weight: 700; color: var(--color-text, inherit); margin: 0; }
53
+ .studio-mic-help-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
54
+ .studio-mic-help-steps li { display: flex; align-items: flex-start; gap: .75rem; }
55
+ .studio-mic-step { display: flex; width: 1.5rem; height: 1.5rem; flex-shrink: 0; align-items: center; justify-content: center;
56
+ border-radius: 9999px; font-size: .75rem; font-weight: 700;
57
+ background: var(--color-cta-fill, var(--color-cta, rgb(var(--color-primary-500-rgb, 99 102 241)))); color: var(--btn-primary-fg, #fff); }
58
+ .studio-mic-help-actions { display: flex; justify-content: flex-end; margin: 0; }
59
+ .studio-mic-help-close { min-height: 44px; padding: 0 1.5rem; }
60
+ /* The drawn address bar: never a screenshot, every stroke a token. */
61
+ .studio-mic-mock { width: 100%; height: auto; color: var(--color-text, currentColor); }
62
+ .studio-mic-mock .mock-bar, .studio-mic-mock .mock-panel { fill: var(--color-inset, rgb(127 127 127 / .12)); stroke: currentColor; stroke-opacity: .25; }
63
+ .studio-mic-mock .mock-ring { fill: none; stroke: rgb(var(--color-primary-500-rgb, 99 102 241)); stroke-width: 3; }
64
+ .studio-mic-mock .mock-ink { stroke: currentColor; }
65
+ .studio-mic-mock .mock-knob { fill: currentColor; stroke: none; }
66
+ .studio-mic-mock .mock-text { fill: currentColor; font-family: inherit; }
67
+ .studio-mic-mock .mock-on, .studio-mic-mock .mock-badge { fill: rgb(var(--color-primary-500-rgb, 99 102 241)); }
68
+ .studio-mic-mock .mock-knob-on, .studio-mic-mock .mock-badge-text { fill: var(--color-surface, Canvas); }
69
+
70
+ /* The record button: a ring as loud as the voice (--studio-recorder-level). */
71
+ .studio-recorder { display: flex; flex-direction: column; align-items: center; gap: .75rem; text-align: center; }
72
+ .studio-recorder-button { min-height: 44px; min-width: 12rem; max-width: 100%; border: 0; border-radius: 9999px; padding: .875rem 1.75rem;
73
+ font-weight: 700; cursor: pointer; touch-action: none; -webkit-user-select: none; user-select: none;
74
+ color: var(--btn-primary-fg, #fff); background: var(--color-cta-fill, var(--color-cta, rgb(var(--color-primary-500-rgb, 99 102 241))));
75
+ box-shadow: 0 0 0 calc(var(--studio-recorder-level, 0) * 14px) rgb(var(--color-primary-500-rgb, 99 102 241) / .3);
76
+ transition: box-shadow 80ms linear; }
77
+ .studio-recorder-button:focus-visible { outline: 3px solid rgb(var(--color-primary-500-rgb, 99 102 241) / .5); outline-offset: 3px; }
78
+ .studio-recorder-button:disabled { opacity: .5; cursor: not-allowed; }
79
+ [data-voice-recorder-state="recording"] .studio-recorder-button { background: var(--color-danger-fill, var(--color-danger, #dc2626)); }
80
+ .studio-recorder-timer { font-variant-numeric: tabular-nums; font-size: .875rem; color: var(--color-text-muted, inherit); }
81
+ .studio-recorder-status { min-height: 2.5rem; max-width: 24rem; margin: 0; color: var(--color-text-body, inherit); }
82
+ .studio-recorder-failed { display: none; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
83
+ [data-studio-controller-failed~="voice-recorder"] .studio-recorder-failed { display: block; }
84
+ .studio-mic-failed { display: none; margin: 0; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
85
+ [data-studio-controller-failed~="microphone-chip"] .studio-mic-failed { display: block; }
86
+ @media (prefers-reduced-motion: reduce) {
87
+ [data-microphone-live="true"] .studio-mic-dot { transform: none; transition: none; }
88
+ .studio-recorder-button { transition: none; }
89
+ }
90
+ </style>
data/lib/studio/engine.rb CHANGED
@@ -303,8 +303,10 @@ module Studio
303
303
  # User#inspect: POST /profile/voice carries voice[embeddings], and the user
304
304
  # holds voice_embedding (Voicebox logged vectors until it filtered them).
305
305
  # `(\A|\.)` also masks the JSON param wrapper's copy (profile.voice.embeddings).
306
+ # POST /voice/takes carries voice[embedding] (one take's vector) and
307
+ # voice[audio] (the take itself); both are masked the same way.
306
308
  initializer "studio.voice_profile.filter_parameters" do |app|
307
- app.config.filter_parameters += [/(\A|\.)voice\.embeddings\z/, /\Avoice_embedding\z/]
309
+ app.config.filter_parameters += [/(\A|\.)voice\.(embeddings|embedding|audio)\z/, /\Avoice_embedding\z/]
308
310
  end
309
311
 
310
312
  initializer "studio.footer_only_eager_load" do
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.120.0"
2
+ VERSION = "0.121.0"
3
3
  end