From 533ef04a9ff9fedd5314e84401a697c1acb88e58 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 21:52:16 +0000 Subject: [PATCH] Rudder transcribes on a server: the page streams its own microphone to the engine With RUDDER_AUDIO=browser the engine records no local microphone. The page captures the reader's mic, converts it to 16 kHz mono PCM in an AudioWorklet, and streams it over a WebSocket at /api/rudder/stream into the same speech session a local recorder feeds. The words come back on that socket to that reader only, never on the shared event stream. One session at a time; a second reader is told the engine is busy. The engine honours RUDDER_HOME, accepts the public host named in RUDDER_ALLOW_HOST, and refuses a socket from another origin. The dev proxy now carries WebSockets to backends. --- backend/rudder-engine/main.go | 184 +++++++++++++++---- internal/backend-dev-plugin.mjs | 3 +- plugin-designs/rudder-voice/rudder-voice.vue | 105 +++++++++-- 3 files changed, 245 insertions(+), 47 deletions(-) diff --git a/backend/rudder-engine/main.go b/backend/rudder-engine/main.go index 83ed10b5d..4d3bb0677 100644 --- a/backend/rudder-engine/main.go +++ b/backend/rudder-engine/main.go @@ -63,7 +63,8 @@ func envOr(k, d string) string { func loadConfig() config { home, _ := os.UserHomeDir() - base := filepath.Join(home, ".local", "share", "rudder") + // RUDDER_HOME is where setup-runtime.sh installed the runtime and model (a server keeps it on a volume). + base := envOr("RUDDER_HOME", filepath.Join(home, ".local", "share", "rudder")) return config{ Port: envOr("RUDDER_PORT", "5193"), NemoPort: envOr("RUDDER_NEMO_PORT", "5194"), @@ -153,6 +154,7 @@ func (h *hub) send(e event) { // ─── Engine: supervises nemo-speech serve and one recording session at a time ─────────────────── type engine struct { + sink func(event) // the live browser session's own stream (RUDDER_AUDIO=browser); nil = the hub cfg config logs *ring hub *hub @@ -181,9 +183,11 @@ func (e *engine) status() event { e.mu.Lock() defer e.mu.Unlock() return event{ - "engine": e.state, "error": e.stateErr, "recording": e.recording, "recorder": e.recorder, + // A browser session belongs to one reader; everyone else sees only that the engine is in use. + "engine": e.state, "error": e.stateErr, "recording": e.recording && !browserAudio(), "inUse": e.recording, "recorder": e.recorder, "language": e.language, "defaultLanguage": e.cfg.Language, "device": e.device, "runtime": "NVIDIA NeMo-Speech.cpp (ggml, CPU)", "model": filepath.Base(e.cfg.Model), + "audio": map[bool]string{true: "browser", false: "local"}[browserAudio()], } } @@ -284,7 +288,16 @@ func recorderCmds(device string) [][]string { return cmds } -func (e *engine) start(lang, device string) error { +// browserAudio: RUDDER_AUDIO=browser — the engine runs on a server (the cloud dev server), where there is +// no microphone of the reader's to record. The page captures its own mic and streams 16 kHz s16le PCM to +// /api/rudder/stream; that stream feeds the same pump and speech session as a local recorder. +func browserAudio() bool { return os.Getenv("RUDDER_AUDIO") == "browser" } + +func (e *engine) start(lang, device string) error { return e.startWith(lang, device, nil, nil) } + +// startWith starts a session. src, when set, is the audio source (a browser stream); nil spawns a local recorder. +func (e *engine) startWith(lang, device string, src *recorderProc, sink func(event)) error { + browser := src != nil e.mu.Lock() if e.state != "ready" { e.mu.Unlock() @@ -292,6 +305,9 @@ func (e *engine) start(lang, device string) error { } if e.recording { e.mu.Unlock() + if browser { + return errors.New("busy — someone else is recording on this server; try again when they stop") + } return nil } if lang == "" { @@ -316,22 +332,25 @@ func (e *engine) start(lang, device string) error { // mic link died 3-5 s into EVERY session that had speech (kernel: corrupted SCO packets); after // switching to CVSD it ran 9.5 minutes without a drop. CVSD is 8 kHz, and Nemotron's transcript of an // 8 kHz copy of the test clip differed by one word. RUDDER_KEEP_MSBC=1 opts out. - if card := btCard(device); card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" { + if card := btCard(device); !browser && card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" { if p := currentProfile(card); p == "headset-head-unit-msbc" { e.logs.add("bluetooth headset on mSBC — switching to CVSD before recording (stable voice link)") - e.hub.send(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"}) + e.emit(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"}) if err := switchProfile(card, e.logs); err != nil { e.logs.add("pre-switch to CVSD failed: %v", err) } else { - e.hub.send(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."}) + e.emit(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."}) } } } - first, err := spawnRecorder(device, e.logs) - if err != nil { - ws.Close() - return err + first := src + if first == nil { + first, err = spawnRecorder(device, e.logs) + if err != nil { + ws.Close() + return err + } } recName := first.name var recMu sync.Mutex @@ -343,6 +362,7 @@ func (e *engine) start(lang, device string) error { e.mu.Lock() e.recording, e.recorder, e.language, e.device = true, recName, lang, device + e.sink = sink e.stopSession = func() { cancel() recMu.Lock() @@ -351,19 +371,29 @@ func (e *engine) start(lang, device string) error { writeMu.Lock() _ = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"input_audio_buffer.commit"}`)) writeMu.Unlock() - select { // let the final words arrive + // Let the final words arrive. A shared server's CPU can run behind real time (measured: a loaded + // 8-core box finished an 11 s clip ~15 s after it ended), so a browser session waits longer. + wait := 4 * time.Second + if browser { + wait = 30 * time.Second + } + select { case <-done: - case <-time.After(4 * time.Second): + case <-time.After(wait): } ws.Close() } e.mu.Unlock() - e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device))) - go watchKernelBluetooth(ctx, e.logs) - e.hub.send(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device}) + if browser { + e.logs.add("recording started: recorder=browser language=%s", lang) + } else { + e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device))) + go watchKernelBluetooth(ctx, e.logs) + } + e.emit(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device}) for _, d := range e.devs.current().Devices { if (d.ID == device || (device == "" && d.Default)) && d.Muted { - e.hub.send(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."}) + e.emit(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."}) } } @@ -400,6 +430,12 @@ func (e *engine) start(lang, device string) error { } continue } + if browser { + e.logs.add("browser audio stream ended: %v", err) + e.emit(event{"type": "error", "message": "The browser stopped sending microphone audio (the page was closed or lost its connection)."}) + go e.stop() + return + } // The recorder died on its own (bad device, sound server unreachable, device unplugged). // End the session so the UI stops showing "Listening", and say WHY in plain words. _ = r.cmd.Wait() @@ -415,7 +451,7 @@ func (e *engine) start(lang, device string) error { if why != "" { msg += " (" + r.name + ": " + why + ")" } - e.hub.send(event{"type": "error", "message": msg}) + e.emit(event{"type": "error", "message": msg}) go e.stop() return } @@ -434,7 +470,7 @@ func (e *engine) start(lang, device string) error { // transcribed, then literal zeros while parec stayed "healthy" — the old Rudder's BUG 1, // the headset losing the mic side of the link. Re-opening the source restarts the audio // stream; the speech session and the transcript carry on untouched. - if peak == 0 { + if peak == 0 && !browser { if silentSince.IsZero() { silentSince = time.Now() } else if !gaveUp && !resetting.Load() && time.Since(silentSince) > 1500*time.Millisecond { @@ -443,7 +479,7 @@ func (e *engine) start(lang, device string) error { case recoveries == 1: // Step 1: reopen the recorder (cheap; fixes a stalled stream). e.logs.add("mic link dead (exact zeros 1.5s) — step 1: reopening the microphone") - e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"}) + e.emit(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"}) if next, err := spawnRecorder(device, e.logs); err == nil { recMu.Lock() old := cur @@ -458,12 +494,12 @@ func (e *engine) start(lang, device string) error { // "detect first", so it can't decide a dead link is fine. card := btCard(device) e.logs.add("mic still dead — step 2: forcing a new voice link: %s → headset-head-unit-cvsd", card) - e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"}) + e.emit(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"}) resetting.Store(true) go func() { defer resetting.Store(false) if err := switchProfile(card, e.logs); err != nil { - e.hub.send(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()}) + e.emit(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()}) return } if next, err := spawnRecorder(device, e.logs); err == nil { @@ -478,7 +514,7 @@ func (e *engine) start(lang, device string) error { case recoveries == 3 && btResetPath() != "": // Step 3: full headset reset (disconnect → restart PipeWire → reconnect). e.logs.add("mic still dead — step 3: full headset reset via RUDDER_BT_RESET") - e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"}) + e.emit(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"}) resetting.Store(true) go func() { defer resetting.Store(false) @@ -487,7 +523,7 @@ func (e *engine) start(lang, device string) error { return } if !ok { - e.hub.send(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"}) + e.emit(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"}) } if next, err := spawnRecorder(device, e.logs); err == nil { recMu.Lock() @@ -505,7 +541,7 @@ func (e *engine) start(lang, device string) error { if btResetPath() == "" { msg += " (No headset-reset command is configured — see RUDDER_BT_RESET.)" } - e.hub.send(event{"type": "warning", "message": msg}) + e.emit(event{"type": "warning", "message": msg}) } silentSince = time.Now() } @@ -514,7 +550,7 @@ func (e *engine) start(lang, device string) error { recovering.Store(false) healthySince = time.Now() e.logs.add("mic recovered after %d reconnect(s)", recoveries) - e.hub.send(event{"type": "recovered", "message": "Microphone is back — keep talking."}) + e.emit(event{"type": "recovered", "message": "Microphone is back — keep talking."}) } if recoveries > 0 && !healthySince.IsZero() && time.Since(healthySince) > 30*time.Second { recoveries, gaveUp, healthySince = 0, false, time.Time{} @@ -530,14 +566,14 @@ func (e *engine) start(lang, device string) error { } if time.Since(lastLevel) > 150*time.Millisecond { lastLevel = time.Now() - e.hub.send(event{"type": "level", "peak": float64(peak) / 32768}) + e.emit(event{"type": "level", "peak": float64(peak) / 32768}) } writeMu.Lock() err := ws.WriteMessage(websocket.BinaryMessage, frame) writeMu.Unlock() if err != nil { if ctx.Err() == nil { - e.hub.send(event{"type": "error", "message": "speech server connection lost: " + err.Error()}) + e.emit(event{"type": "error", "message": "speech server connection lost: " + err.Error()}) } return } @@ -562,13 +598,13 @@ func (e *engine) start(lang, device string) error { case strings.HasSuffix(t, "input_audio_transcription.delta"): // Deltas APPEND to the utterance in progress; the server sends many empty ones. if text != "" { - e.hub.send(event{"type": "partial", "text": text}) + e.emit(event{"type": "partial", "text": text}) } case strings.HasSuffix(t, "input_audio_transcription.completed"): - e.hub.send(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)}) + e.emit(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)}) case t == "error": e.logs.add("speech server error: %s", string(msg)) - e.hub.send(event{"type": "error", "message": string(msg)}) + e.emit(event{"type": "error", "message": string(msg)}) case t == "input_audio_buffer.committed": return default: @@ -585,9 +621,13 @@ type recorderProc struct { out io.ReadCloser name string errBuf *bytes.Buffer + closer io.Closer // a browser source: closing it ends the stream } func (r *recorderProc) kill() { + if r != nil && r.closer != nil { + _ = r.closer.Close() + } if r != nil && r.cmd != nil && r.cmd.Process != nil { _ = r.cmd.Process.Kill() go func() { _ = r.cmd.Wait() }() @@ -642,7 +682,24 @@ func (e *engine) stop() { } stop() e.logs.add("recording stopped") - e.hub.send(event{"type": "recording", "recording": false}) + e.emit(event{"type": "recording", "recording": false}) + e.mu.Lock() + e.sink = nil + e.mu.Unlock() +} + +// emit sends a session event. A browser session's events go back on that reader's own stream ONLY: on a +// shared server the hub reaches every open Rudder page, and one person's words must never appear in +// another's box. +func (e *engine) emit(ev event) { + e.mu.Lock() + s := e.sink + e.mu.Unlock() + if s != nil { + s(ev) + return + } + e.hub.send(ev) } func (e *engine) shutdown() { @@ -670,6 +727,18 @@ func writeJSON(w http.ResponseWriter, code int, v any) { // - CORS: only the desktop webview's origin (tauri://localhost, or http://tauri.localhost on Windows) // may read responses cross-origin. The Plugin Studio needs none: it reaches /api/rudder through the // dev server's same-origin proxy. RUDDER_ALLOW_ORIGIN adds one more exact origin (tests). +// +// allowedHost: RUDDER_ALLOW_HOST (comma-separated) names the public host of a server deployment, whose own +// sign-in (oauth2-proxy) sits in front of the dev server that proxies /api/rudder here. Unset = loopback only. +func allowedHost(host string) bool { + for _, h := range strings.Split(os.Getenv("RUDDER_ALLOW_HOST"), ",") { + if h = strings.TrimSpace(h); h != "" && h == host { + return true + } + } + return false +} + func guard(next http.Handler) http.Handler { extra := os.Getenv("RUDDER_ALLOW_ORIGIN") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -677,7 +746,7 @@ func guard(next http.Handler) http.Handler { if i := strings.LastIndex(host, ":"); i >= 0 && !strings.HasSuffix(host, "]") { host = host[:i] } - if host != "127.0.0.1" && host != "localhost" && host != "[::1]" { + if host != "127.0.0.1" && host != "localhost" && host != "[::1]" && !allowedHost(host) { http.Error(w, "forbidden host", http.StatusForbidden) return } @@ -721,6 +790,10 @@ func main() { Device string `json:"device"` } _ = json.NewDecoder(r.Body).Decode(&body) + if browserAudio() { + writeJSON(w, 409, event{"error": "this engine takes the browser's microphone: stream to /api/rudder/stream"}) + return + } if err := e.start(body.Language, body.Device); err != nil { e.logs.add("start failed: %v", err) writeJSON(w, 409, event{"error": err.Error()}) @@ -728,6 +801,53 @@ func main() { } writeJSON(w, 200, e.status()) }) + // /api/rudder/stream (RUDDER_AUDIO=browser): a WebSocket of binary 16 kHz mono s16le PCM frames from the + // page's microphone. Opening it starts a session, closing it stops one; the text comes back on /events. + // The upgrader's default origin check (Origin must match Host) keeps other sites out. + upgrader := websocket.Upgrader{ReadBufferSize: 8192} + mux.HandleFunc("/api/rudder/stream", func(w http.ResponseWriter, r *http.Request) { + if !browserAudio() { + writeJSON(w, 409, event{"error": "this engine records a local microphone (RUDDER_AUDIO is not browser)"}) + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + pr, pw := io.Pipe() + src := &recorderProc{out: pr, name: "browser", errBuf: &bytes.Buffer{}, closer: pr} + var wmu sync.Mutex + sink := func(ev event) { + b, _ := json.Marshal(ev) + wmu.Lock() + _ = conn.WriteMessage(websocket.TextMessage, b) + wmu.Unlock() + } + if err := e.startWith(r.URL.Query().Get("language"), "", src, sink); err != nil { + e.logs.add("stream start failed: %v", err) + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(4409, err.Error())) + return + } + for { + t, b, err := conn.ReadMessage() + if err != nil { + break + } + if t != websocket.BinaryMessage { + // {"type":"stop"}: end the session but keep the socket open, so the last words (sent after the + // speech server's commit) still reach this reader; the page closes the socket after "recording false". + if bytes.Contains(b, []byte(`"stop"`)) { + go e.stop() + } + continue + } + if _, err := pw.Write(b); err != nil { + break // the session stopped (its pipe was closed) + } + } + e.stop() + }) mux.HandleFunc("/api/rudder/stop", func(w http.ResponseWriter, r *http.Request) { e.stop() writeJSON(w, 200, e.status()) diff --git a/internal/backend-dev-plugin.mjs b/internal/backend-dev-plugin.mjs index 9a81be79f..2bf490745 100644 --- a/internal/backend-dev-plugin.mjs +++ b/internal/backend-dev-plugin.mjs @@ -35,7 +35,8 @@ export const DEV_BACKENDS = [ // The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered. export function devBackendProxy() { - return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false }])) + // ws: a backend may take a WebSocket (rudder's /stream carries the browser's microphone on a server deployment). + return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false, ws: true }])) } const REG = (globalThis.__dxDevBackends ||= { procs: new Map(), exitHooked: false, stopping: false }) diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index 7f2bfb7b3..64b1b7b9c 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -286,7 +286,9 @@ function applyStatus(s) { engineError.value = s.error || '' if (s.runtime) runtime.value = s.runtime if (s.model) model.value = s.model - if (typeof s.recording === 'boolean') recording.value = s.recording + if (s.audio) audioMode.value = s.audio + // A browser session's state is this page's own; the server's shared status never reports it. + if (typeof s.recording === 'boolean' && audioMode.value !== 'browser') recording.value = s.recording } function connect() { @@ -300,9 +302,20 @@ function connect() { } es = new window.EventSource(apiBase.value + '/events') es.onopen = () => logc('events connected') - es.onmessage = (m) => { + es.onmessage = (m) => onEvent(m.data) + es.onerror = () => { + engine.value = 'offline' + if (audioMode.value !== 'browser') recording.value = false + logc('events disconnected; retrying in 3s') + try { es.close() } catch {} + retry = setTimeout(connect, 3000) + } +} + +// One vocabulary for both channels: the shared /events stream, and a browser session's own socket. +function onEvent(data) { let e - try { e = JSON.parse(m.data) } catch { return } + try { e = JSON.parse(data) } catch { return } switch (e.type) { case 'status': applyStatus(e.status || e) @@ -311,7 +324,7 @@ function connect() { break case 'recording': recording.value = !!e.recording - if (!e.recording) { level.value = 0; flushPartial() } + if (!e.recording) { level.value = 0; flushPartial(); closeStream() } break case 'partial': partial.value += e.text @@ -342,14 +355,72 @@ function connect() { logc('engine error: ' + e.message) break } +} + +// ─── BROWSER MICROPHONE (engine on a server, status.audio === 'browser') ───────────────────────────── +// The engine cannot hear the reader's microphone from a server, so this page captures it, turns it into +// 16 kHz mono 16-bit PCM in an AudioWorklet, and streams 100 ms frames to /api/rudder/stream. The words +// come back on that same socket, to this reader only. +const WORKLET = `class RudderPcm16k extends AudioWorkletProcessor { + constructor () { super(); this.step = sampleRate / 16000; this.pos = 0; this.out = new Int16Array(1600); this.n = 0 } + process (inputs) { + const ch = inputs[0] && inputs[0][0] + if (!ch) return true + for (; this.pos < ch.length; this.pos += this.step) { + const v = Math.max(-1, Math.min(1, ch[Math.floor(this.pos)])) + this.out[this.n++] = v < 0 ? v * 32768 : v * 32767 + if (this.n === this.out.length) { this.port.postMessage(this.out.buffer, [this.out.buffer]); this.out = new Int16Array(1600); this.n = 0 } + } + this.pos -= ch.length + return true } - es.onerror = () => { - engine.value = 'offline' - recording.value = false - logc('events disconnected; retrying in 3s') - try { es.close() } catch {} - retry = setTimeout(connect, 3000) +} +registerProcessor('rudder-pcm16k', RudderPcm16k)` +const audioMode = ref('local') +let sock = null, media = null, actx = null + +async function startBrowser() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error('This browser cannot share its microphone here (it needs a secure https:// page).') } + media = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } }) + actx = new window.AudioContext() + const url = URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' })) + await actx.audioWorklet.addModule(url) + URL.revokeObjectURL(url) + const node = new window.AudioWorkletNode(actx, 'rudder-pcm16k') + const mute = actx.createGain() + mute.gain.value = 0 // keeps the graph pulled without playing the mic back + actx.createMediaStreamSource(media).connect(node) + node.connect(mute).connect(actx.destination) + const u = new URL(apiBase.value + '/stream', location.href) + u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:' + u.searchParams.set('language', language.value) + sock = new window.WebSocket(u) + sock.binaryType = 'arraybuffer' + sock.onmessage = (m) => onEvent(m.data) + sock.onclose = (c) => { + if (c.code === 4409) say(c.reason || 'Someone else is recording on this server.', 'error') + sock = null + releaseMic() + if (recording.value) { recording.value = false; level.value = 0; flushPartial() } + } + node.port.onmessage = (e) => { if (sock && sock.readyState === 1) sock.send(e.data) } +} +function releaseMic() { + try { media && media.getTracks().forEach((t) => t.stop()) } catch {} + try { actx && actx.close() } catch {} + media = null + actx = null +} +// Stop: the mic goes off at once; the socket stays open until the engine sends the last words. +function stopBrowser() { + releaseMic() + if (sock && sock.readyState === 1) sock.send(JSON.stringify({ type: 'stop' })) + else closeStream() +} +function closeStream() { + if (sock) { try { sock.close() } catch {} } } // Recording stopped mid-sentence: the streamed words are already in the box, so just let go of them. @@ -379,7 +450,10 @@ async function toggle() { if (busy.value) return busy.value = true try { - if (recording.value) { + if (audioMode.value === 'browser') { + if (recording.value) stopBrowser() + else { say(''); await startBrowser() } + } else if (recording.value) { await post('/stop') } else { say('') @@ -387,7 +461,8 @@ async function toggle() { applyStatus(await post('/start', { language: language.value, device: device.value })) } } catch (err) { - say(String(err.message || err), 'error') + if (audioMode.value === 'browser') { releaseMic(); closeStream() } + say(err && err.name === 'NotAllowedError' ? 'Microphone permission was refused — allow it for this site to record.' : String(err.message || err), 'error') logc('toggle failed: ' + err) } finally { busy.value = false @@ -463,6 +538,8 @@ onMounted(() => { onBeforeUnmount(() => { clearTimeout(retry) try { es?.close() } catch {} + releaseMic() + closeStream() window.removeEventListener('keydown', onKey) }) @@ -549,7 +626,7 @@ const statusText = computed(() => ({ -
+