Rudder is thin-client by default; the robot voice is an experimental opt-in (High performance mode)
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Rudder is built for a thin client: by default the whole budget goes to speech-to-text and nothing of the
robot voice runs or even loads.

- Settings → Robot voice is marked "experimental". Ticking it first shows a warning (in the page, not a
  browser dialog the desktop webview may block): it runs an extra voice engine, uses more CPU and ~150 MB
  while it speaks, and can slow voice-to-text on a smaller laptop or shared dev server. "Turn on anyway"
  switches to High performance mode; the settings show the current mode.
- Engine: POST /api/rudder/mode {robot} switches thin-client ↔ high performance, and refuses the robot when
  the machine has under ~500 MB available (/proc/meminfo) — a voice tool must never crash someone's laptop.
  /api/rudder/tts refuses in thin-client mode, so an old saved setting can't start the robot either. In
  thin-client mode Piper would yield the CPU; in high performance mode it runs at normal priority.
- The voice list loads only when the robot voice is turned on; a robot voice left on re-enters high
  performance mode on the next visit (the engine re-checks memory).
- Verified on the engine: starts thin-client; speaking refused (409); on → high-performance; speaks; off →
  thin-client; speaking refused again.
This commit is contained in:
Doxa Maps 2026-09-28 15:35:51 +00:00
parent 2bff270734
commit bfd1b78b4e
2 changed files with 111 additions and 10 deletions

View file

@ -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
}

View file

@ -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(() => ({
</section>
<section v-if="showSettings" class="robot">
<label class="robot-on">
<input v-model="robotOn" type="checkbox" @change="toggleRobot" />
<span><b>Robot voice</b> — speaks what you say in a synthetic voice, so you can record a video without your real voice</span>
<input type="checkbox" :checked="robotOn || robotConfirm" @click.prevent="requestRobot(!robotOn && !robotConfirm)" />
<span><b>Robot voice</b> <span class="exp">experimental</span> — speaks what you say in a synthetic voice, so you can
record a video without your real voice</span>
</label>
<div v-if="robotConfirm" class="notice robot-warn" data-kind="warn" role="alertdialog" aria-label="Robot voice is experimental">
<p><b>Experimental: switches Rudder to High performance mode.</b> 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.</p>
<div class="robot-warn-actions">
<button class="small" @click="enableRobot">Turn on anyway</button>
<button class="small ghost" @click="robotConfirm = false">Cancel</button>
</div>
</div>
<p class="robot-mode">Mode: <b>{{ robotOn ? 'High performance (experimental)' : 'Thin-client (default)' }}</b></p>
<select v-if="robotOn" v-model="robotVoice" aria-label="Robot voice" @change="save(); speak('This is my robot voice.')">
<option v-for="o in robotOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
<option v-if="!robotOptions.length" value="" disabled>No voice installed</option>
</select>
<p v-if="robotOn && robotError" class="hint">{{ robotError }}</p>
<p v-if="robotError" class="hint">{{ robotError }}</p>
</section>
<p v-if="testResult" class="notice" :data-kind="testResult.ok ? (testResult.peak < 0.01 ? 'warn' : 'ok') : 'error'">
{{ 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;