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,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">“<%= phrase %>”</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>
|