studio-engine 0.120.0 → 0.122.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 +84 -0
- data/README.md +2 -0
- data/app/controllers/studio/voice_takes_controller.rb +146 -0
- data/app/javascript/studio/controllers/microphone_chip_controller.js +21 -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 +374 -0
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/javascript/studio/voice_profile.js +38 -113
- data/app/javascript/studio/voice_recorder.js +363 -0
- data/app/views/studio/voice/_microphone_chip.html.erb +45 -0
- data/app/views/studio/voice/_microphone_help.html.erb +65 -0
- data/app/views/studio/voice/_recorder.html.erb +34 -0
- data/app/views/studio/voice/_styles.html.erb +94 -0
- data/lib/studio/engine.rb +3 -1
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_capture.rb +95 -0
- data/lib/studio/voice_transcription.rb +470 -0
- data/lib/studio.rb +45 -0
- metadata +15 -2
|
@@ -26,31 +26,25 @@
|
|
|
26
26
|
// fake Tests only: a fixed vector per voice name (window.studioFakeVoice),
|
|
27
27
|
// because Chrome's fake microphone cannot play chosen audio.
|
|
28
28
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// The level meter, the silence rule and the recording format are the engine's
|
|
30
|
+
// one copy (studio/level_meter, studio/voice_recorder; docs/VOICE_CAPTURE.md),
|
|
31
|
+
// re-exported here so callers of this module keep their names. The model
|
|
32
|
+
// runtime is a dynamic import of the transformers.js URL the page names
|
|
33
|
+
// (Studio::VoiceMatch::TRANSFORMERS_URL), made only once someone opens the
|
|
34
|
+
// setup. test/javascript/voice_profile.test.mjs loads it with its studio/
|
|
35
|
+
// imports inlined (test/javascript/support/studio_module.mjs).
|
|
36
|
+
import { SILENCE_RMS, rootMeanSquare, peakWindowRms, meterLevel, takeWasHeard, createLevelMeter } from "studio/level_meter"
|
|
37
|
+
import { pickMimeType } from "studio/voice_recorder"
|
|
38
|
+
|
|
39
|
+
export { SILENCE_RMS, rootMeanSquare, peakWindowRms, meterLevel, takeWasHeard, pickMimeType }
|
|
33
40
|
|
|
34
41
|
// The model hears 16 kHz mono; a take is cut to its first MAX_SECONDS.
|
|
35
42
|
export const SAMPLE_RATE = 16000
|
|
36
43
|
export const MAX_SECONDS = 10
|
|
37
44
|
|
|
38
|
-
// The loudest frame (RMS, 0..1) a take must reach to count as heard: 0.01 is
|
|
39
|
-
// -40 dBFS. A muted microphone sits at 0, a quiet room near 0.002-0.006, and
|
|
40
|
-
// speech at arm's length peaks well above 0.05.
|
|
41
|
-
export const SILENCE_RMS = 0.01
|
|
42
|
-
|
|
43
45
|
// A phrase is about four seconds; the take stops itself after TAKE_MS.
|
|
44
46
|
export const TAKE_MS = 6000
|
|
45
47
|
|
|
46
|
-
const MIME_CANDIDATES = [
|
|
47
|
-
"audio/webm;codecs=opus",
|
|
48
|
-
"audio/webm",
|
|
49
|
-
"audio/mp4;codecs=mp4a.40.2",
|
|
50
|
-
"audio/mp4",
|
|
51
|
-
"audio/ogg;codecs=opus"
|
|
52
|
-
]
|
|
53
|
-
|
|
54
48
|
export const COPY = {
|
|
55
49
|
idle: ["", ""],
|
|
56
50
|
ready: ["Record phrase {n}", "Tap Record, then read phrase {n} out loud."],
|
|
@@ -64,6 +58,7 @@ export const MESSAGES = {
|
|
|
64
58
|
silent: "I couldn't hear that one. Check your microphone and read it again.",
|
|
65
59
|
failed: "That one didn't work. Tap Record and read it again.",
|
|
66
60
|
denied: "Allow the microphone to set up your voice.",
|
|
61
|
+
busy: "Your microphone is busy in another app. Close it there, then try again.",
|
|
67
62
|
unsupported: "This browser can't record here. Try Safari or Chrome.",
|
|
68
63
|
network: "We couldn't reach the server. Tap Save again.",
|
|
69
64
|
notSaved: "That didn't save. Tap Save again."
|
|
@@ -71,48 +66,13 @@ export const MESSAGES = {
|
|
|
71
66
|
|
|
72
67
|
export class SilentTakeError extends Error {}
|
|
73
68
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// The loudest 50 ms window's RMS, 0..1, at 16 kHz.
|
|
82
|
-
export function peakWindowRms(samples, windowSize = 800) {
|
|
83
|
-
let peak = 0
|
|
84
|
-
if (!samples) return peak
|
|
85
|
-
for (let start = 0; start < samples.length; start += windowSize) {
|
|
86
|
-
const end = Math.min(samples.length, start + windowSize)
|
|
87
|
-
let sum = 0
|
|
88
|
-
for (let i = start; i < end; i++) sum += samples[i] * samples[i]
|
|
89
|
-
const rms = Math.sqrt(sum / Math.max(1, end - start))
|
|
90
|
-
if (rms > peak) peak = rms
|
|
91
|
-
}
|
|
92
|
-
return peak
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// The meter's reading, 0..1: logarithmic from -40 dBFS to -10 dBFS, where
|
|
96
|
-
// speech lives.
|
|
97
|
-
export function meterLevel(rms) {
|
|
98
|
-
if (!(rms > 0)) return 0
|
|
99
|
-
const db = 20 * Math.log10(rms)
|
|
100
|
-
return Math.min(1, Math.max(0, (db + 40) / 30))
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Heard, unless the meter measured the take and it stayed under SILENCE_RMS. A
|
|
104
|
-
// take the meter never measured (no Web Audio, a context that never ran) is
|
|
105
|
-
// let through, and the embedder's own check on the decoded samples decides.
|
|
106
|
-
export function takeWasHeard(peakRms, framesMeasured) {
|
|
107
|
-
if (!framesMeasured) return true
|
|
108
|
-
return peakRms >= SILENCE_RMS
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function pickMimeType(isTypeSupported) {
|
|
112
|
-
if (typeof isTypeSupported !== "function") return ""
|
|
113
|
-
return MIME_CANDIDATES.find((type) => {
|
|
114
|
-
try { return isTypeSupported(type) } catch (_) { return false }
|
|
115
|
-
}) || ""
|
|
69
|
+
// Why the microphone would not open: refused, held by another app
|
|
70
|
+
// (NotReadableError; Chrome's older TrackStartError), or anything else.
|
|
71
|
+
export function openFailure(error) {
|
|
72
|
+
const name = error && error.name
|
|
73
|
+
if (name === "NotAllowedError") return MESSAGES.denied
|
|
74
|
+
if (name === "NotReadableError" || name === "TrackStartError") return MESSAGES.busy
|
|
75
|
+
return MESSAGES.failed
|
|
116
76
|
}
|
|
117
77
|
|
|
118
78
|
// The words for a state, with {n} the phrase being read (1-based).
|
|
@@ -258,13 +218,7 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
258
218
|
let recorder = null
|
|
259
219
|
let chunks = []
|
|
260
220
|
let timer = null
|
|
261
|
-
let
|
|
262
|
-
let analyser = null
|
|
263
|
-
let levelBuffer = null
|
|
264
|
-
let meterFrame = null
|
|
265
|
-
let meterTimer = null
|
|
266
|
-
let peakRms = 0
|
|
267
|
-
let frames = 0
|
|
221
|
+
let meter = null
|
|
268
222
|
let starting = false
|
|
269
223
|
let destroyed = false
|
|
270
224
|
|
|
@@ -327,56 +281,27 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
327
281
|
root.dataset.voiceLevel = level.toFixed(2)
|
|
328
282
|
}
|
|
329
283
|
|
|
330
|
-
|
|
331
|
-
if (!analyser || !audioContext || audioContext.state !== "running") return
|
|
332
|
-
analyser.getFloatTimeDomainData(levelBuffer)
|
|
333
|
-
const rms = rootMeanSquare(levelBuffer)
|
|
334
|
-
frames += 1
|
|
335
|
-
if (rms > peakRms) {
|
|
336
|
-
peakRms = rms
|
|
337
|
-
root.dataset.voicePeak = rms.toFixed(4)
|
|
338
|
-
}
|
|
339
|
-
if (rms >= SILENCE_RMS) root.dataset.voiceHeard = "true"
|
|
340
|
-
paintLevel(meterLevel(rms))
|
|
341
|
-
}
|
|
342
|
-
|
|
284
|
+
// The engine's one meter (studio/level_meter) on the take's own stream.
|
|
343
285
|
function startMeter(source) {
|
|
344
|
-
peakRms = 0
|
|
345
|
-
frames = 0
|
|
346
286
|
root.dataset.voiceHeard = "false"
|
|
347
287
|
root.dataset.voicePeak = "0"
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
closeMeter()
|
|
361
|
-
return
|
|
362
|
-
}
|
|
363
|
-
const frame = () => {
|
|
364
|
-
sampleLevel()
|
|
365
|
-
meterFrame = win.requestAnimationFrame(frame)
|
|
366
|
-
}
|
|
367
|
-
meterFrame = win.requestAnimationFrame(frame)
|
|
368
|
-
// A background tab stops animation frames; the timer keeps measuring.
|
|
369
|
-
meterTimer = win.setInterval(sampleLevel, 100)
|
|
288
|
+
let peak = 0
|
|
289
|
+
meter = createLevelMeter(source, {
|
|
290
|
+
win,
|
|
291
|
+
onPaint: paintLevel,
|
|
292
|
+
onSample: (rms) => {
|
|
293
|
+
if (rms > peak) {
|
|
294
|
+
peak = rms
|
|
295
|
+
root.dataset.voicePeak = rms.toFixed(4)
|
|
296
|
+
}
|
|
297
|
+
if (rms >= SILENCE_RMS) root.dataset.voiceHeard = "true"
|
|
298
|
+
}
|
|
299
|
+
})
|
|
370
300
|
}
|
|
371
301
|
|
|
372
302
|
function closeMeter() {
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
meterFrame = null
|
|
376
|
-
meterTimer = null
|
|
377
|
-
analyser = null
|
|
378
|
-
if (audioContext && audioContext.state !== "closed") audioContext.close().catch(() => {})
|
|
379
|
-
audioContext = null
|
|
303
|
+
if (meter) meter.close()
|
|
304
|
+
meter = null
|
|
380
305
|
paintLevel(0)
|
|
381
306
|
}
|
|
382
307
|
|
|
@@ -389,7 +314,7 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
389
314
|
try {
|
|
390
315
|
stream = await win.navigator.mediaDevices.getUserMedia({ audio: true })
|
|
391
316
|
} catch (error) {
|
|
392
|
-
setState("ready", error
|
|
317
|
+
setState("ready", openFailure(error))
|
|
393
318
|
return
|
|
394
319
|
}
|
|
395
320
|
if (destroyed || state !== "ready") { release(); return }
|
|
@@ -428,8 +353,8 @@ export function mountVoiceProfile(root, win = window) {
|
|
|
428
353
|
|
|
429
354
|
async function finishTake() {
|
|
430
355
|
const discarded = recorder && recorder.discard
|
|
431
|
-
|
|
432
|
-
const heard =
|
|
356
|
+
if (meter) meter.sample()
|
|
357
|
+
const heard = meter ? meter.heard() : true
|
|
433
358
|
closeMeter()
|
|
434
359
|
release()
|
|
435
360
|
const type = (recorder && recorder.mimeType) || "audio/webm"
|
|
@@ -0,0 +1,363 @@
|
|
|
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
|
+
busy: "Your microphone is busy in another app. Close it there, then try again." },
|
|
66
|
+
denied: { button: "Record", status: "Allow the microphone to record." },
|
|
67
|
+
unsupported: { button: "Record", status: "This browser can't record here. Try Safari or Chrome." }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pickMimeType(isTypeSupported) {
|
|
71
|
+
if (typeof isTypeSupported !== "function") return ""
|
|
72
|
+
return MIME_CANDIDATES.find((type) => {
|
|
73
|
+
try { return isTypeSupported(type) } catch (_) { return false }
|
|
74
|
+
}) || ""
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatElapsed(ms) {
|
|
78
|
+
const seconds = Math.max(0, Math.floor(ms / 1000))
|
|
79
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The line for a microphone that would not open (NotAllowedError is "denied",
|
|
83
|
+
// not this). NotReadableError is a microphone another app holds (Chrome's
|
|
84
|
+
// older name, TrackStartError); everything else reads as no microphone found.
|
|
85
|
+
export const BUSY_ERRORS = ["NotReadableError", "TrackStartError"]
|
|
86
|
+
|
|
87
|
+
export function failedLine(error, copy = COPY) {
|
|
88
|
+
const busy = !!error && BUSY_ERRORS.includes(error.name)
|
|
89
|
+
return busy ? (copy.failed.busy || COPY.failed.busy) : copy.failed.missing
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The page's words over the defaults, one state at a time.
|
|
93
|
+
export function mergeCopy(overrides) {
|
|
94
|
+
const merged = {}
|
|
95
|
+
Object.keys(COPY).forEach((state) => { merged[state] = { ...COPY[state] } })
|
|
96
|
+
if (overrides && typeof overrides === "object") {
|
|
97
|
+
Object.entries(overrides).forEach(([state, words]) => {
|
|
98
|
+
if (words && typeof words === "object") merged[state] = { ...(merged[state] || {}), ...words }
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
return merged
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseJson(text) {
|
|
105
|
+
try { return JSON.parse(text || "null") } catch (_) { return null }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Binds one recorder. Returns { start, stop, toggle, destroy, get state }.
|
|
109
|
+
// options.open and options.meter replace openMicrophone and createLevelMeter
|
|
110
|
+
// (the unit tests pass stubs).
|
|
111
|
+
export function mountVoiceRecorder(root, { win = window, open = openMicrophone, meter: makeMeter = createLevelMeter } = {}) {
|
|
112
|
+
const one = (selector) => root.querySelector(selector)
|
|
113
|
+
const copy = mergeCopy(parseJson(root.getAttribute("data-voice-recorder-copy")))
|
|
114
|
+
const maxMs = parseInt(root.getAttribute("data-voice-recorder-max-ms") || "0", 10) || 0
|
|
115
|
+
const mode = root.getAttribute("data-voice-recorder-mode") === "hold" ? "hold" : "toggle"
|
|
116
|
+
const now = () => win.performance.now()
|
|
117
|
+
|
|
118
|
+
let state = null
|
|
119
|
+
let stream = null
|
|
120
|
+
let recorder = null
|
|
121
|
+
let meter = null
|
|
122
|
+
let chunks = []
|
|
123
|
+
let timer = null
|
|
124
|
+
let startedAt = 0
|
|
125
|
+
let microphone = { label: "", deviceId: null }
|
|
126
|
+
let hinted = null
|
|
127
|
+
let stopWanted = false
|
|
128
|
+
let destroyed = false
|
|
129
|
+
|
|
130
|
+
function setState(next, message = null) {
|
|
131
|
+
state = next
|
|
132
|
+
root.dataset.voiceRecorderState = next
|
|
133
|
+
const words = copy[next] || {}
|
|
134
|
+
const label = one("[data-voice-recorder-label]")
|
|
135
|
+
if (label && words.button) label.textContent = words.button
|
|
136
|
+
const status = one("[data-voice-recorder-status]")
|
|
137
|
+
if (status) status.textContent = message || words.status || ""
|
|
138
|
+
const button = one("[data-voice-recorder-button]")
|
|
139
|
+
if (button) {
|
|
140
|
+
button.setAttribute("aria-pressed", next === "recording" ? "true" : "false")
|
|
141
|
+
button.disabled = next === "unsupported"
|
|
142
|
+
}
|
|
143
|
+
if (next === "idle") paintTimer(0)
|
|
144
|
+
root.dispatchEvent(new win.CustomEvent(EVENTS.state, { bubbles: true, detail: { state: next } }))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function supported() {
|
|
148
|
+
return !!(win.MediaRecorder && win.navigator && win.navigator.mediaDevices && win.navigator.mediaDevices.getUserMedia)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function paintLevel(level) {
|
|
152
|
+
root.style.setProperty("--studio-recorder-level", level.toFixed(3))
|
|
153
|
+
root.dataset.voiceRecorderLevel = level.toFixed(2)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function paintTimer(ms) {
|
|
157
|
+
const el = one("[data-voice-recorder-timer]")
|
|
158
|
+
if (el) el.textContent = formatElapsed(ms)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hint(kind) {
|
|
162
|
+
if (state !== "recording" || hinted === kind || !copy.recording[kind]) return
|
|
163
|
+
hinted = kind
|
|
164
|
+
const status = one("[data-voice-recorder-status]")
|
|
165
|
+
if (status) status.textContent = copy.recording[kind]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function onSample(rms) {
|
|
169
|
+
announce(MICROPHONE_EVENTS.level, { rms }, win)
|
|
170
|
+
if (rms >= SILENCE_RMS && root.dataset.voiceRecorderHeard !== "true") {
|
|
171
|
+
root.dataset.voiceRecorderHeard = "true"
|
|
172
|
+
hint("heard")
|
|
173
|
+
} else if (root.dataset.voiceRecorderHeard !== "true" && startedAt && now() - startedAt > QUIET_HINT_MS) {
|
|
174
|
+
hint("quiet")
|
|
175
|
+
}
|
|
176
|
+
root.dataset.voiceRecorderPeak = meter ? meter.peak.toFixed(4) : "0"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stopTimer() {
|
|
180
|
+
if (timer) win.clearInterval(timer)
|
|
181
|
+
timer = null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Lets go of the meter and the microphone, and tells the chip.
|
|
185
|
+
function release() {
|
|
186
|
+
stopTimer()
|
|
187
|
+
if (meter) meter.close()
|
|
188
|
+
meter = null
|
|
189
|
+
if (stream) {
|
|
190
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
191
|
+
announce(MICROPHONE_EVENTS.closed, {}, win)
|
|
192
|
+
}
|
|
193
|
+
stream = null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function start() {
|
|
197
|
+
if (destroyed || !READY.includes(state)) return
|
|
198
|
+
stopWanted = false
|
|
199
|
+
setState("starting")
|
|
200
|
+
let opened
|
|
201
|
+
try {
|
|
202
|
+
opened = await open(win.navigator.mediaDevices)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (destroyed) return
|
|
205
|
+
const denied = error && error.name === "NotAllowedError"
|
|
206
|
+
if (denied) announce(MICROPHONE_EVENTS.denied, {}, win)
|
|
207
|
+
setState(denied ? "denied" : "failed", denied ? null : failedLine(error, copy))
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
stream = opened.stream
|
|
211
|
+
microphone = { label: opened.label || "", deviceId: opened.deviceId || null }
|
|
212
|
+
// Destroyed, or let go of (hold mode) before the microphone opened.
|
|
213
|
+
if (destroyed || stopWanted) {
|
|
214
|
+
release()
|
|
215
|
+
if (!destroyed) setState("idle")
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
chunks = []
|
|
219
|
+
try {
|
|
220
|
+
begin(stream)
|
|
221
|
+
} catch (_) {
|
|
222
|
+
// MediaRecorder refused the stream: let go of the microphone, the meter
|
|
223
|
+
// and its frames rather than leave them running.
|
|
224
|
+
recorder = null
|
|
225
|
+
release()
|
|
226
|
+
setState("failed", copy.failed.start)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function begin(source) {
|
|
231
|
+
root.dataset.voiceRecorderHeard = "false"
|
|
232
|
+
root.dataset.voiceRecorderPeak = "0"
|
|
233
|
+
hinted = null
|
|
234
|
+
meter = makeMeter(source, { win, onSample, onPaint: paintLevel })
|
|
235
|
+
const MediaRecorderClass = win.MediaRecorder
|
|
236
|
+
const mimeType = pickMimeType(MediaRecorderClass.isTypeSupported && MediaRecorderClass.isTypeSupported.bind(MediaRecorderClass))
|
|
237
|
+
try {
|
|
238
|
+
recorder = mimeType ? new MediaRecorderClass(source, { mimeType }) : new MediaRecorderClass(source)
|
|
239
|
+
} catch (_) {
|
|
240
|
+
recorder = new MediaRecorderClass(source)
|
|
241
|
+
}
|
|
242
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
243
|
+
if (event.data && event.data.size > 0) chunks.push(event.data)
|
|
244
|
+
})
|
|
245
|
+
recorder.addEventListener("stop", () => finish())
|
|
246
|
+
recorder.start(250)
|
|
247
|
+
startedAt = now()
|
|
248
|
+
paintTimer(0)
|
|
249
|
+
timer = win.setInterval(() => {
|
|
250
|
+
const elapsed = now() - startedAt
|
|
251
|
+
paintTimer(elapsed)
|
|
252
|
+
if (maxMs && elapsed >= maxMs) stop()
|
|
253
|
+
}, 250)
|
|
254
|
+
setState("recording")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function stop() {
|
|
258
|
+
if (state === "starting") { stopWanted = true; return }
|
|
259
|
+
if (recorder && recorder.state !== "inactive") recorder.stop()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function finish() {
|
|
263
|
+
const durationMs = Math.round(now() - startedAt)
|
|
264
|
+
stopTimer()
|
|
265
|
+
if (meter) meter.sample()
|
|
266
|
+
const heard = meter ? meter.heard() : true
|
|
267
|
+
const peakRms = meter ? meter.peak : 0
|
|
268
|
+
const type = (recorder && recorder.mimeType) || (chunks[0] && chunks[0].type) || "audio/webm"
|
|
269
|
+
const blob = new win.Blob(chunks, { type })
|
|
270
|
+
chunks = []
|
|
271
|
+
recorder = null
|
|
272
|
+
release()
|
|
273
|
+
if (destroyed) return
|
|
274
|
+
if (blob.size === 0 || !heard) {
|
|
275
|
+
setState("silent", silentMessage(copy.silent.named, microphone.label))
|
|
276
|
+
announce(MICROPHONE_EVENTS.silent, { label: microphone.label }, win)
|
|
277
|
+
root.dispatchEvent(new win.CustomEvent(EVENTS.silent, { bubbles: true, detail: { durationMs, microphone: { ...microphone } } }))
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
setState("captured")
|
|
281
|
+
root.dispatchEvent(new win.CustomEvent(EVENTS.take, {
|
|
282
|
+
bubbles: true,
|
|
283
|
+
detail: { blob, mimeType: type, durationMs, peakRms, microphone: { ...microphone } }
|
|
284
|
+
}))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function toggle() {
|
|
288
|
+
if (state === "recording" || state === "starting") stop()
|
|
289
|
+
else start()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Buttons inside the root: the record button follows the mode.
|
|
293
|
+
function onClick(event) {
|
|
294
|
+
if (mode !== "toggle") return
|
|
295
|
+
const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
|
|
296
|
+
if (!button || !root.contains(button)) return
|
|
297
|
+
event.preventDefault()
|
|
298
|
+
toggle()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function onPointerDown(event) {
|
|
302
|
+
if (mode !== "hold") return
|
|
303
|
+
const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
|
|
304
|
+
if (!button || !root.contains(button)) return
|
|
305
|
+
event.preventDefault()
|
|
306
|
+
if (button.setPointerCapture && event.pointerId != null) {
|
|
307
|
+
try { button.setPointerCapture(event.pointerId) } catch (_) { /* a synthetic pointer */ }
|
|
308
|
+
}
|
|
309
|
+
start()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function onPointerUp() {
|
|
313
|
+
if (mode === "hold" && (state === "recording" || state === "starting")) stop()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Hold mode by keyboard: Space or Enter held down records.
|
|
317
|
+
function onKeyDown(event) {
|
|
318
|
+
if (mode !== "hold" || event.repeat || ![" ", "Enter"].includes(event.key)) return
|
|
319
|
+
if (!event.target.closest || !event.target.closest("[data-voice-recorder-button]")) return
|
|
320
|
+
event.preventDefault()
|
|
321
|
+
start()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function onKeyUp(event) {
|
|
325
|
+
if (mode !== "hold" || ![" ", "Enter"].includes(event.key)) return
|
|
326
|
+
event.preventDefault()
|
|
327
|
+
onPointerUp()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
root.addEventListener("click", onClick)
|
|
331
|
+
root.addEventListener("pointerdown", onPointerDown)
|
|
332
|
+
root.addEventListener("pointerup", onPointerUp)
|
|
333
|
+
root.addEventListener("pointercancel", onPointerUp)
|
|
334
|
+
root.addEventListener("keydown", onKeyDown)
|
|
335
|
+
root.addEventListener("keyup", onKeyUp)
|
|
336
|
+
root.dataset.voiceRecorderMode = mode
|
|
337
|
+
paintLevel(0)
|
|
338
|
+
if (supported()) setState("idle")
|
|
339
|
+
else setState("unsupported")
|
|
340
|
+
root.dataset.voiceRecorderReady = "true"
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
start,
|
|
344
|
+
stop,
|
|
345
|
+
toggle,
|
|
346
|
+
get state() { return state },
|
|
347
|
+
destroy() {
|
|
348
|
+
destroyed = true
|
|
349
|
+
root.removeEventListener("click", onClick)
|
|
350
|
+
root.removeEventListener("pointerdown", onPointerDown)
|
|
351
|
+
root.removeEventListener("pointerup", onPointerUp)
|
|
352
|
+
root.removeEventListener("pointercancel", onPointerUp)
|
|
353
|
+
root.removeEventListener("keydown", onKeyDown)
|
|
354
|
+
root.removeEventListener("keyup", onKeyUp)
|
|
355
|
+
if (recorder && recorder.state !== "inactive") {
|
|
356
|
+
try { recorder.stop() } catch (_) { /* already stopped */ }
|
|
357
|
+
}
|
|
358
|
+
recorder = null
|
|
359
|
+
release()
|
|
360
|
+
delete root.dataset.voiceRecorderReady
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -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>
|