rudder-engine builds for macOS and Windows, finds its runtime per platform, and accepts the desktop webview's stream

The engine did not compile outside Linux: Pdeathsig (kill children with the
engine) is Linux-only. It now sits behind a build tag, with a no-op elsewhere
where the desktop shell owns the engine's lifetime. The default speech-server path
names this platform's NeMo-Speech.cpp release folder instead of the Linux one.
The /stream WebSocket accepts the Tauri origins (tauri://localhost,
http://tauri.localhost) and RUDDER_ALLOW_ORIGIN as well as the page's own host,
and still refuses any other site. Cross-compiled for darwin/arm64, darwin/amd64
and windows/amd64.
This commit is contained in:
Doxa Maps 2026-09-28 01:22:06 +00:00
parent 7080546fdf
commit ea151b9a56
4 changed files with 57 additions and 7 deletions

View file

@ -24,7 +24,6 @@ import (
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
@ -215,7 +214,7 @@ func (w *deviceWatcher) run() {
for {
cmd := exec.Command("pactl", "subscribe")
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
cmd.SysProcAttr = childAttr()
out, err := cmd.StdoutPipe()
if err == nil && cmd.Start() == nil {
sc := bufio.NewScanner(out)
@ -255,7 +254,7 @@ func (e *engine) micTest(device string) event {
return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."}
}
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
cmd.SysProcAttr = childAttr()
var errBuf strings.Builder
cmd.Stderr = &errBuf
out, err := cmd.StdoutPipe()

View file

@ -29,11 +29,13 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"sync/atomic"
@ -61,6 +63,21 @@ func envOr(k, d string) string {
return d
}
// defaultNemoBin: where setup puts NVIDIA's NeMo-Speech.cpp 0.1.0 CPU release for THIS platform — the
// release folders are named nemo-speech-0.1.0-<linux|macos|windows>-<x86_64|aarch64>-cpu.
func defaultNemoBin(base string) string {
osName := map[string]string{"darwin": "macos"}[runtime.GOOS]
if osName == "" {
osName = runtime.GOOS
}
arch := map[string]string{"amd64": "x86_64", "arm64": "aarch64"}[runtime.GOARCH]
exe := "nemo-speech"
if runtime.GOOS == "windows" {
exe += ".exe"
}
return filepath.Join(base, "bin", "nemo-speech-0.1.0-"+osName+"-"+arch+"-cpu", "bin", exe)
}
func loadConfig() config {
home, _ := os.UserHomeDir()
// RUDDER_HOME is where setup-runtime.sh installed the runtime and model (a server keeps it on a volume).
@ -68,7 +85,7 @@ func loadConfig() config {
return config{
Port: envOr("RUDDER_PORT", "5193"),
NemoPort: envOr("RUDDER_NEMO_PORT", "5194"),
NemoBin: envOr("RUDDER_NEMO_BIN", filepath.Join(base, "bin", "nemo-speech-0.1.0-linux-x86_64-cpu", "bin", "nemo-speech")),
NemoBin: envOr("RUDDER_NEMO_BIN", defaultNemoBin(base)),
Model: envOr("RUDDER_MODEL", filepath.Join(base, "models", "nemotron-3.5-asr-streaming-0.6b.q8_0.gguf")),
Language: envOr("RUDDER_LANG", "en-US"),
EndpointMs: 800,
@ -207,7 +224,7 @@ func (e *engine) startNemo() {
cmd := exec.Command(e.cfg.NemoBin, "serve",
"--host", "127.0.0.1", "--port", e.cfg.NemoPort,
"--asr-model", e.cfg.Model, "--device", "cpu", "--no-ui", "--api-key", e.nemoKey)
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} // dies with us
cmd.SysProcAttr = childAttr() // dies with us
out, _ := cmd.StdoutPipe()
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
@ -669,7 +686,7 @@ func spawnRecorder(device string, logs *ring) (*recorderProc, error) {
}
cmd := exec.Command(c[0], c[1:]...)
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
cmd.SysProcAttr = childAttr()
errBuf := &bytes.Buffer{}
cmd.Stderr = errBuf
out, err := cmd.StdoutPipe()
@ -813,7 +830,17 @@ func main() {
// /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}
// Origin: the page's own host (the dev server proxy), a desktop app's webview (tauri://localhost, or
// http://tauri.localhost on Windows — the same origins guard() answers CORS for), or RUDDER_ALLOW_ORIGIN.
// Anything else is another site and is refused.
upgrader := websocket.Upgrader{ReadBufferSize: 8192, CheckOrigin: func(r *http.Request) bool {
o := r.Header.Get("Origin")
if o == "" || o == "tauri://localhost" || o == "http://tauri.localhost" || (os.Getenv("RUDDER_ALLOW_ORIGIN") != "" && o == os.Getenv("RUDDER_ALLOW_ORIGIN")) {
return true
}
u, err := url.Parse(o)
return err == nil && strings.EqualFold(u.Host, r.Host)
}}
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)"})

View file

@ -0,0 +1,12 @@
//go:build linux
package main
import "syscall"
// childAttr: a child process (the speech server, a recorder) is killed with the engine, so a crash
// never leaves a speech server holding a gigabyte of memory. Linux only: Pdeathsig does not exist on
// macOS or Windows (see procattr_other.go).
func childAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
}

View file

@ -0,0 +1,12 @@
//go:build !linux
package main
import "syscall"
// childAttr: macOS and Windows have no parent-death signal. In the desktop app the Tauri shell owns the
// engine's lifetime and the engine kills its children on a normal exit (shutdown); a crashed engine can
// leave its speech server running until the app is next closed.
func childAttr() *syscall.SysProcAttr {
return nil
}