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(() => ({
-
+
“{{ chosen.label }}” is muted in your system sound settings — Rudder won't hear anything until it's unmuted.
{{ deviceError }}
- No microphone found. Connect a headset or turn on your computer's microphone in Sound settings.
+ No microphone found. Connect a headset or turn on your computer's microphone in Sound settings.
Also available but switched off in Sound settings: {{ offCards.join(', ') }}. Turn it on there to use it here.
{{ notice }}