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,444 @@
|
|
|
1
|
+
// studio/voice_profile: the voice row on /profile (studio/profiles/_voice_section).
|
|
2
|
+
// The signed-in person reads three short phrases; each take is recorded, turned
|
|
3
|
+
// into a speaker embedding ON THIS DEVICE, and thrown away. Save posts only the
|
|
4
|
+
// vectors; the server averages them into one voice on the person's own record
|
|
5
|
+
// (Studio::VoiceMatch). No audio leaves the device for this.
|
|
6
|
+
//
|
|
7
|
+
// <section data-studio-controller="voice-profile" data-voice-state="idle"
|
|
8
|
+
// data-voice-save-url="/profile/voice" data-voice-embedder='{"name":"wespeaker",...}'>
|
|
9
|
+
//
|
|
10
|
+
// The controller (studio/controllers/voice_profile_controller) calls
|
|
11
|
+
// mountVoiceProfile(element) and destroys it on disconnect. Ported from
|
|
12
|
+
// Voicebox's voice setup (McRitchie-Studio/voicebox#10), where the embedder, the
|
|
13
|
+
// level meter and the silence threshold were measured.
|
|
14
|
+
//
|
|
15
|
+
// STATES, on data-voice-state: idle (the summary) -> ready (setup open, phrase
|
|
16
|
+
// n waiting) -> recording -> thinking -> ready ... -> complete -> saving. Each
|
|
17
|
+
// phrase carries data-phrase-state: waiting, current or done. A take the meter
|
|
18
|
+
// heard nothing in is refused (data-voice-last-take="silent") and the phrase
|
|
19
|
+
// stays current. While recording, --studio-voice-level (0..1) on the root drives
|
|
20
|
+
// the ring around the record button.
|
|
21
|
+
//
|
|
22
|
+
// EMBEDDERS, named by the page (Studio::VoiceMatch.browser_config):
|
|
23
|
+
// wespeaker WeSpeaker ResNet34 (VoxCeleb), int8, through transformers.js on
|
|
24
|
+
// onnxruntime-web, from jsDelivr and Hugging Face at a pinned
|
|
25
|
+
// revision. Loaded when the setup opens; the browser caches it.
|
|
26
|
+
// fake Tests only: a fixed vector per voice name (window.studioFakeVoice),
|
|
27
|
+
// because Chrome's fake microphone cannot play chosen audio.
|
|
28
|
+
//
|
|
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 }
|
|
40
|
+
|
|
41
|
+
// The model hears 16 kHz mono; a take is cut to its first MAX_SECONDS.
|
|
42
|
+
export const SAMPLE_RATE = 16000
|
|
43
|
+
export const MAX_SECONDS = 10
|
|
44
|
+
|
|
45
|
+
// A phrase is about four seconds; the take stops itself after TAKE_MS.
|
|
46
|
+
export const TAKE_MS = 6000
|
|
47
|
+
|
|
48
|
+
export const COPY = {
|
|
49
|
+
idle: ["", ""],
|
|
50
|
+
ready: ["Record phrase {n}", "Tap Record, then read phrase {n} out loud."],
|
|
51
|
+
recording: ["Stop", "Listening... read phrase {n}, then tap Stop."],
|
|
52
|
+
thinking: ["One moment...", "Learning your voice..."],
|
|
53
|
+
complete: ["All three done", "All three done. Tap Save."],
|
|
54
|
+
saving: ["Saving...", "Saving..."]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const MESSAGES = {
|
|
58
|
+
silent: "I couldn't hear that one. Check your microphone and read it again.",
|
|
59
|
+
failed: "That one didn't work. Tap Record and read it again.",
|
|
60
|
+
denied: "Allow the microphone to set up your voice.",
|
|
61
|
+
unsupported: "This browser can't record here. Try Safari or Chrome.",
|
|
62
|
+
network: "We couldn't reach the server. Tap Save again.",
|
|
63
|
+
notSaved: "That didn't save. Tap Save again."
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class SilentTakeError extends Error {}
|
|
67
|
+
|
|
68
|
+
// The words for a state, with {n} the phrase being read (1-based).
|
|
69
|
+
export function copyFor(state, step) {
|
|
70
|
+
const [button, status] = COPY[state] || ["Try again", ""]
|
|
71
|
+
const n = String(step + 1)
|
|
72
|
+
return { button: button.replace("{n}", n), status: status.replace("{n}", n) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Each phrase's state for a step (how many takes are kept).
|
|
76
|
+
export function phraseStates(total, step) {
|
|
77
|
+
return Array.from({ length: total }, (_, i) => (i < step ? "done" : (i === step ? "current" : "waiting")))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// mulberry32: a small seeded generator, so the fake's vectors are the same in
|
|
81
|
+
// every run and every browser.
|
|
82
|
+
function seededRandom(seed) {
|
|
83
|
+
let a = seed >>> 0
|
|
84
|
+
return () => {
|
|
85
|
+
a = (a + 0x6D2B79F5) >>> 0
|
|
86
|
+
let t = a
|
|
87
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
88
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
89
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hashString(text) {
|
|
94
|
+
let h = 2166136261
|
|
95
|
+
for (let i = 0; i < text.length; i++) {
|
|
96
|
+
h ^= text.charCodeAt(i)
|
|
97
|
+
h = Math.imul(h, 16777619)
|
|
98
|
+
}
|
|
99
|
+
return h >>> 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The fake embedder's vector for a voice name: the same name always gives the
|
|
103
|
+
// same vector, and two names give nearly unrelated ones (cosine near 0).
|
|
104
|
+
export function fakeVector(voice, size = 256) {
|
|
105
|
+
const random = seededRandom(hashString(String(voice)))
|
|
106
|
+
return Array.from({ length: size }, () => random() * 2 - 1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A recorded Blob as 16 kHz mono floats: the browser decodes the container
|
|
110
|
+
// (webm/opus or mp4/aac) and resamples it while rendering offline.
|
|
111
|
+
export async function decodeToMono16k(blob, win = globalThis) {
|
|
112
|
+
const bytes = await blob.arrayBuffer()
|
|
113
|
+
const Offline = win.OfflineAudioContext || win.webkitOfflineAudioContext
|
|
114
|
+
const probe = new Offline(1, 1, SAMPLE_RATE)
|
|
115
|
+
const decoded = await probe.decodeAudioData(bytes)
|
|
116
|
+
const seconds = Math.min(decoded.duration, MAX_SECONDS)
|
|
117
|
+
const length = Math.max(1, Math.ceil(seconds * SAMPLE_RATE))
|
|
118
|
+
const offline = new Offline(1, length, SAMPLE_RATE)
|
|
119
|
+
const source = offline.createBufferSource()
|
|
120
|
+
source.buffer = decoded
|
|
121
|
+
source.connect(offline.destination) // a mono destination downmixes
|
|
122
|
+
source.start()
|
|
123
|
+
const rendered = await offline.startRendering()
|
|
124
|
+
return rendered.getChannelData(0)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class WespeakerEmbedder {
|
|
128
|
+
constructor(config) { this.config = config }
|
|
129
|
+
|
|
130
|
+
get name() { return this.config.name }
|
|
131
|
+
get id() { return this.config.id }
|
|
132
|
+
|
|
133
|
+
// Starts the download (runtime ~4.3 MB, model ~6.7 MB, cached after the first
|
|
134
|
+
// time) without waiting for it. Safe to call more than once; a failed load
|
|
135
|
+
// can be retried.
|
|
136
|
+
warm() {
|
|
137
|
+
if (!this.loading) {
|
|
138
|
+
this.loading = (async () => {
|
|
139
|
+
// By full URL (the page's config.runtime), never an importmap pin.
|
|
140
|
+
const transformers = await import(this.config.runtime)
|
|
141
|
+
// The model comes from the Hugging Face hub at a pinned revision, never
|
|
142
|
+
// from this app's own /models path.
|
|
143
|
+
transformers.env.allowLocalModels = false
|
|
144
|
+
const options = { revision: this.config.revision }
|
|
145
|
+
const [processor, model] = await Promise.all([
|
|
146
|
+
transformers.AutoProcessor.from_pretrained(this.config.model, options),
|
|
147
|
+
transformers.AutoModel.from_pretrained(this.config.model, { ...options, dtype: this.config.dtype })
|
|
148
|
+
])
|
|
149
|
+
return { processor, model }
|
|
150
|
+
})()
|
|
151
|
+
this.loading.catch(() => { this.loading = null })
|
|
152
|
+
}
|
|
153
|
+
return this.loading
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async embed(blob) {
|
|
157
|
+
const samples = await decodeToMono16k(blob)
|
|
158
|
+
if (peakWindowRms(samples) < SILENCE_RMS) throw new SilentTakeError("silent take")
|
|
159
|
+
const { processor, model } = await this.warm()
|
|
160
|
+
const inputs = await processor(samples)
|
|
161
|
+
const output = await model(inputs)
|
|
162
|
+
const tensor = output.embeddings || output.last_hidden_state || Object.values(output)[0]
|
|
163
|
+
return Array.from(tensor.data)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class FakeEmbedder {
|
|
168
|
+
constructor(config, win) {
|
|
169
|
+
this.config = config
|
|
170
|
+
this.win = win
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get name() { return this.config.name }
|
|
174
|
+
get id() { return this.config.id }
|
|
175
|
+
|
|
176
|
+
warm() { return Promise.resolve() }
|
|
177
|
+
|
|
178
|
+
async embed(blob) {
|
|
179
|
+
if (!blob || blob.size === 0) throw new SilentTakeError("empty take")
|
|
180
|
+
return fakeVector((this.win && this.win.studioFakeVoice) || "default", this.config.size)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const EMBEDDERS = { wespeaker: WespeakerEmbedder, fake: FakeEmbedder }
|
|
185
|
+
|
|
186
|
+
// The embedder the page names, or null when it names none this script knows.
|
|
187
|
+
export function embedderFor(config, win = globalThis) {
|
|
188
|
+
if (!config || !config.name) return null
|
|
189
|
+
const Embedder = EMBEDDERS[config.name]
|
|
190
|
+
return Embedder ? new Embedder(config, win) : null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseJson(text) {
|
|
194
|
+
try { return JSON.parse(text || "null") } catch (_) { return null }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Binds the voice row. Returns { destroy }.
|
|
198
|
+
export function mountVoiceProfile(root, win = window) {
|
|
199
|
+
const doc = root.ownerDocument
|
|
200
|
+
const phrases = () => Array.from(root.querySelectorAll("[data-voice-phrase]"))
|
|
201
|
+
const one = (selector) => root.querySelector(selector)
|
|
202
|
+
const embedder = embedderFor(parseJson(root.getAttribute("data-voice-embedder")), win)
|
|
203
|
+
const saveUrl = root.getAttribute("data-voice-save-url")
|
|
204
|
+
|
|
205
|
+
let vectors = []
|
|
206
|
+
let state = "idle"
|
|
207
|
+
let stream = null
|
|
208
|
+
let recorder = null
|
|
209
|
+
let chunks = []
|
|
210
|
+
let timer = null
|
|
211
|
+
let meter = null
|
|
212
|
+
let starting = false
|
|
213
|
+
let destroyed = false
|
|
214
|
+
|
|
215
|
+
function setState(next, message = null) {
|
|
216
|
+
state = next
|
|
217
|
+
root.dataset.voiceState = next
|
|
218
|
+
const states = phraseStates(phrases().length, vectors.length)
|
|
219
|
+
phrases().forEach((phrase, i) => { phrase.dataset.phraseState = states[i] })
|
|
220
|
+
const words = copyFor(next, vectors.length)
|
|
221
|
+
const label = one("[data-voice-record-label]")
|
|
222
|
+
if (label && words.button) label.textContent = words.button
|
|
223
|
+
const status = one("[data-voice-status]")
|
|
224
|
+
if (status) status.textContent = message || words.status
|
|
225
|
+
const button = one("[data-voice-record]")
|
|
226
|
+
if (button) {
|
|
227
|
+
button.disabled = !["ready", "recording"].includes(next)
|
|
228
|
+
button.setAttribute("aria-pressed", next === "recording" ? "true" : "false")
|
|
229
|
+
}
|
|
230
|
+
const save = one("[data-voice-save]")
|
|
231
|
+
if (save) save.disabled = next !== "complete"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function settle(message = null) {
|
|
235
|
+
setState(vectors.length >= phrases().length ? "complete" : "ready", message)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function supported() {
|
|
239
|
+
return !!(win.MediaRecorder && win.navigator && win.navigator.mediaDevices && win.navigator.mediaDevices.getUserMedia)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function open() {
|
|
243
|
+
if (state !== "idle") return
|
|
244
|
+
vectors = []
|
|
245
|
+
const setup = one("[data-voice-setup]")
|
|
246
|
+
if (setup) setup.hidden = false
|
|
247
|
+
const summary = one("[data-voice-summary]")
|
|
248
|
+
if (summary) summary.hidden = true
|
|
249
|
+
if (!embedder || !supported()) {
|
|
250
|
+
setState("ready", MESSAGES.unsupported)
|
|
251
|
+
const button = one("[data-voice-record]")
|
|
252
|
+
if (button) button.disabled = true
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
embedder.warm().catch(() => {})
|
|
256
|
+
setState("ready")
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cancel() {
|
|
260
|
+
stopTake(false)
|
|
261
|
+
vectors = []
|
|
262
|
+
const setup = one("[data-voice-setup]")
|
|
263
|
+
if (setup) setup.hidden = true
|
|
264
|
+
const summary = one("[data-voice-summary]")
|
|
265
|
+
if (summary) summary.hidden = false
|
|
266
|
+
setState("idle")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function paintLevel(level) {
|
|
270
|
+
root.style.setProperty("--studio-voice-level", level.toFixed(3))
|
|
271
|
+
root.dataset.voiceLevel = level.toFixed(2)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The engine's one meter (studio/level_meter) on the take's own stream.
|
|
275
|
+
function startMeter(source) {
|
|
276
|
+
root.dataset.voiceHeard = "false"
|
|
277
|
+
root.dataset.voicePeak = "0"
|
|
278
|
+
let peak = 0
|
|
279
|
+
meter = createLevelMeter(source, {
|
|
280
|
+
win,
|
|
281
|
+
onPaint: paintLevel,
|
|
282
|
+
onSample: (rms) => {
|
|
283
|
+
if (rms > peak) {
|
|
284
|
+
peak = rms
|
|
285
|
+
root.dataset.voicePeak = rms.toFixed(4)
|
|
286
|
+
}
|
|
287
|
+
if (rms >= SILENCE_RMS) root.dataset.voiceHeard = "true"
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function closeMeter() {
|
|
293
|
+
if (meter) meter.close()
|
|
294
|
+
meter = null
|
|
295
|
+
paintLevel(0)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function release() {
|
|
299
|
+
if (stream) stream.getTracks().forEach((track) => track.stop())
|
|
300
|
+
stream = null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function startTake() {
|
|
304
|
+
try {
|
|
305
|
+
stream = await win.navigator.mediaDevices.getUserMedia({ audio: true })
|
|
306
|
+
} catch (error) {
|
|
307
|
+
setState("ready", error && error.name === "NotAllowedError" ? MESSAGES.denied : MESSAGES.failed)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (destroyed || state !== "ready") { release(); return }
|
|
311
|
+
chunks = []
|
|
312
|
+
try {
|
|
313
|
+
startMeter(stream)
|
|
314
|
+
const MediaRecorderClass = win.MediaRecorder
|
|
315
|
+
const mimeType = pickMimeType(MediaRecorderClass.isTypeSupported && MediaRecorderClass.isTypeSupported.bind(MediaRecorderClass))
|
|
316
|
+
try {
|
|
317
|
+
recorder = mimeType ? new MediaRecorderClass(stream, { mimeType }) : new MediaRecorderClass(stream)
|
|
318
|
+
} catch (_) {
|
|
319
|
+
recorder = new MediaRecorderClass(stream)
|
|
320
|
+
}
|
|
321
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
322
|
+
if (event.data && event.data.size > 0) chunks.push(event.data)
|
|
323
|
+
})
|
|
324
|
+
recorder.addEventListener("stop", () => finishTake())
|
|
325
|
+
recorder.start(250)
|
|
326
|
+
} catch (_) {
|
|
327
|
+
closeMeter()
|
|
328
|
+
release()
|
|
329
|
+
setState("ready", MESSAGES.failed)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
setState("recording")
|
|
333
|
+
timer = win.setTimeout(() => stopTake(true), TAKE_MS)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function stopTake(keep = true) {
|
|
337
|
+
if (timer) win.clearTimeout(timer)
|
|
338
|
+
timer = null
|
|
339
|
+
if (!recorder || recorder.state === "inactive") return
|
|
340
|
+
if (!keep) recorder.discard = true
|
|
341
|
+
recorder.stop()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function finishTake() {
|
|
345
|
+
const discarded = recorder && recorder.discard
|
|
346
|
+
if (meter) meter.sample()
|
|
347
|
+
const heard = meter ? meter.heard() : true
|
|
348
|
+
closeMeter()
|
|
349
|
+
release()
|
|
350
|
+
const type = (recorder && recorder.mimeType) || "audio/webm"
|
|
351
|
+
const blob = new Blob(chunks, { type })
|
|
352
|
+
chunks = []
|
|
353
|
+
recorder = null
|
|
354
|
+
if (discarded || destroyed) return
|
|
355
|
+
if (!heard || blob.size === 0) {
|
|
356
|
+
root.dataset.voiceLastTake = "silent"
|
|
357
|
+
settle(MESSAGES.silent)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
setState("thinking")
|
|
361
|
+
try {
|
|
362
|
+
const vector = await embedder.embed(blob)
|
|
363
|
+
if (destroyed || state !== "thinking") return
|
|
364
|
+
vectors.push(vector)
|
|
365
|
+
root.dataset.voiceLastTake = "kept"
|
|
366
|
+
settle()
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (destroyed || state !== "thinking") return
|
|
369
|
+
const silent = error instanceof SilentTakeError
|
|
370
|
+
root.dataset.voiceLastTake = silent ? "silent" : "failed"
|
|
371
|
+
settle(silent ? MESSAGES.silent : MESSAGES.failed)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function toggle() {
|
|
376
|
+
if (state === "recording") stopTake(true)
|
|
377
|
+
else if (state === "ready" && !starting) {
|
|
378
|
+
starting = true
|
|
379
|
+
startTake().finally(() => { starting = false })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function csrfToken() {
|
|
384
|
+
const meta = doc.querySelector("meta[name='csrf-token']")
|
|
385
|
+
return meta ? meta.content : ""
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function save() {
|
|
389
|
+
if (state !== "complete") return
|
|
390
|
+
setState("saving")
|
|
391
|
+
let response
|
|
392
|
+
let body
|
|
393
|
+
try {
|
|
394
|
+
response = await win.fetch(saveUrl, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
credentials: "same-origin",
|
|
397
|
+
headers: { "Accept": "application/json", "Content-Type": "application/json", "X-CSRF-Token": csrfToken() },
|
|
398
|
+
body: JSON.stringify({ voice: { model: embedder.id, embeddings: vectors } })
|
|
399
|
+
})
|
|
400
|
+
body = await response.json().catch(() => null)
|
|
401
|
+
} catch (_) {
|
|
402
|
+
setState("complete", MESSAGES.network)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
// fetch resolves on a 4xx or 5xx: only an ok answer with a url is saved.
|
|
406
|
+
if (!response.ok || !body || !body.url) {
|
|
407
|
+
setState("complete", (body && body.error) || MESSAGES.notSaved)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
vectors = []
|
|
411
|
+
root.dataset.voiceSaved = "true"
|
|
412
|
+
win.location.assign(body.url)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const handlers = {
|
|
416
|
+
open: () => open(),
|
|
417
|
+
cancel: () => cancel(),
|
|
418
|
+
record: () => toggle(),
|
|
419
|
+
save: () => save()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function onClick(event) {
|
|
423
|
+
const target = event.target.closest && event.target.closest("[data-voice-action]")
|
|
424
|
+
if (!target || !root.contains(target)) return
|
|
425
|
+
const handler = handlers[target.getAttribute("data-voice-action")]
|
|
426
|
+
if (!handler) return
|
|
427
|
+
event.preventDefault()
|
|
428
|
+
handler()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
root.addEventListener("click", onClick)
|
|
432
|
+
root.dataset.voiceReady = "true"
|
|
433
|
+
setState("idle")
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
destroy() {
|
|
437
|
+
destroyed = true
|
|
438
|
+
root.removeEventListener("click", onClick)
|
|
439
|
+
stopTake(false)
|
|
440
|
+
closeMeter()
|
|
441
|
+
release()
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|