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;