diff --git a/backend/rudder-engine/tts.go b/backend/rudder-engine/tts.go index 04fa12ce8..5c5e01148 100644 --- a/backend/rudder-engine/tts.go +++ b/backend/rudder-engine/tts.go @@ -33,6 +33,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" ) @@ -49,6 +50,9 @@ type ttsEngine struct { piper string logs *ring mu sync.Mutex // one synthesis at a time: CPU is shared with speech-to-text + // highPerf: the robot voice is ON (experimental "High performance mode"). Off = thin-client mode, the + // default: no robot process ever runs, and when one does it yields the CPU to speech-to-text. + highPerf atomic.Bool } func newTTS(base string, logs *ring) *ttsEngine { @@ -141,7 +145,9 @@ func (t *ttsEngine) synthesize(ctx context.Context, text, voiceID string, speake start := time.Now() err = cmd.Start() if err == nil { - yieldCPU(cmd.Process.Pid) // speech-to-text keeps priority over the robot voice (thin clients) + if !t.highPerf.Load() { + yieldCPU(cmd.Process.Pid) // thin-client mode: speech-to-text keeps priority over the robot voice + } err = cmd.Wait() } if err != nil { @@ -197,6 +203,11 @@ func registerTTS(mux *http.ServeMux, e *engine, t *ttsEngine) { writeJSON(w, 400, event{"error": "expected JSON {text, voice, speaker, speed}"}) return } + if !t.highPerf.Load() { + // Thin-client mode (the default) never runs the robot voice — not even from an old saved setting. + writeJSON(w, 409, event{"error": "The robot voice is off — turn it on in Settings (experimental)."}) + return + } audio, err := t.synthesize(r.Context(), body.Text, body.Voice, body.Speaker, body.Speed) if err != nil { code := 500 @@ -210,6 +221,27 @@ func registerTTS(mux *http.ServeMux, e *engine, t *ttsEngine) { w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(audio) }) + // POST /api/rudder/mode {"robot": bool} — thin-client mode (default) or the experimental High performance + // mode the robot voice needs. Refused when the machine is short of memory: crashing someone's laptop with a + // voice tool would be the worst outcome (the Piper voice needs ~150 MB while it speaks). + mux.HandleFunc("/api/rudder/mode", func(w http.ResponseWriter, r *http.Request) { + var body struct { + Robot bool `json:"robot"` + } + if r.Method == http.MethodPost { + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body) + if body.Robot { + if avail, ok := memAvailableMB(); ok && avail < 500 { + writeJSON(w, 409, event{"mode": "thin-client", "error": fmt.Sprintf("Not enough free memory for the robot voice (%d MB free, needs about 500). Close some programs and try again.", avail)}) + return + } + } + if t.highPerf.Swap(body.Robot) != body.Robot { + e.logs.add("mode: %s", map[bool]string{true: "high performance (robot voice on, experimental)", false: "thin-client (default)"}[body.Robot]) + } + } + writeJSON(w, 200, event{"mode": map[bool]string{true: "high-performance", false: "thin-client"}[t.highPerf.Load()]}) + }) mux.HandleFunc("/api/rudder/duck", func(w http.ResponseWriter, r *http.Request) { var body struct { On bool `json:"on"` @@ -256,3 +288,23 @@ func headphonesInUse() bool { } return false } + +// memAvailableMB reads MemAvailable from /proc/meminfo (Linux). ok=false elsewhere: no guard there. +func memAvailableMB() (int, bool) { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, false + } + for _, line := range strings.Split(string(b), "\n") { + if strings.HasPrefix(line, "MemAvailable:") { + f := strings.Fields(line) + if len(f) >= 2 { + kb, err := strconv.Atoi(f[1]) + if err == nil { + return kb / 1024, true + } + } + } + } + return 0, false +} diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index 50fd3281c..f8823048b 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -256,10 +256,44 @@ async function speakOne(text) { }) } } -function toggleRobot() { +// ─── Thin-client by default; the robot voice is an EXPERIMENTAL opt-in ───────────────────────────── +// Default ("thin-client mode"): no robot process runs and no voice list is even loaded — the whole budget +// goes to speech-to-text, so Rudder stays light enough for a thin client. Ticking Robot voice first shows a +// warning; confirming switches the engine to "high performance mode" (POST /mode), which the engine refuses +// when the machine is short of memory. +const robotConfirm = ref(false) +let voicesHooked = false +function requestRobot(on) { + robotError.value = '' + if (on) { robotConfirm.value = true; return } + disableRobot() +} +async function enableRobot(quiet) { + robotConfirm.value = false + try { + await post('/mode', { robot: true }) + } catch (err) { + robotOn.value = false + robotError.value = String(err.message || err) + save() + return + } + robotOn.value = true save() - if (robotOn.value) { loadRobotVoices(); speak('Robot voice on.') } - else { sayQueue.length = 0; window.speechSynthesis?.cancel?.() } + if (!voicesHooked && window.speechSynthesis) { + voicesHooked = true + window.speechSynthesis.addEventListener?.('voiceschanged', loadRobotVoices) // system voices load late + } + await loadRobotVoices() + if (quiet !== true) speak('Robot voice on.') +} +function disableRobot() { + robotOn.value = false + robotConfirm.value = false + sayQueue.length = 0 + window.speechSynthesis?.cancel?.() + save() + post('/mode', { robot: false }).catch(() => {}) } const clientLog = ref([]) @@ -757,9 +791,8 @@ onMounted(() => { fetchStatus() connect() refreshDevices() - loadRobotVoices() - // System voices load asynchronously in most browsers. - if (window.speechSynthesis) window.speechSynthesis.addEventListener?.('voiceschanged', loadRobotVoices) + // Robot voice was left on last time: re-enter high performance mode (the engine re-checks memory). + if (robotOn.value) enableRobot(true) window.addEventListener('keydown', onKey) }) onBeforeUnmount(() => { @@ -886,14 +919,25 @@ const statusText = computed(() => ({
+
+

Experimental: switches Rudder to High performance mode. The robot voice runs an extra voice engine. It + uses more CPU, and about 150 MB of memory while it speaks. On a smaller laptop or a shared dev server it can slow + down voice-to-text. Leave it off for the lightest, most reliable setup.

+
+ + +
+
+

Mode: {{ robotOn ? 'High performance (experimental)' : 'Thin-client (default)' }}

-

{{ robotError }}

+

{{ robotError }}

{{ testResult.ok ? '✓ ' : '✗ ' }}{{ testResult.verdict }} @@ -972,6 +1016,11 @@ const statusText = computed(() => ({ .robot-on { display: flex; align-items: flex-start; gap: 8px; font-size: 13.5px; line-height: 1.4; cursor: pointer; } .robot-on input { margin-top: 3px; accent-color: var(--accent); } .robot select { align-self: flex-start; max-width: 100%; } +.robot .exp { display: inline-block; padding: 0 6px; border-radius: 999px; background: #3b2a06; color: var(--warn); font-size: 11px; + text-transform: uppercase; letter-spacing: .04em; vertical-align: 1px; } +.robot-warn p { margin: 0 0 8px; } +.robot-warn-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.robot-mode { margin: 0; color: var(--muted); font-size: 12.5px; } :host { display: block; } .rudder { --bg: #0b0f14; --panel: #111823; --line: #1f2937; --text: #e5e7eb; --muted: #9ca3af;