Rudder is thin-client by default; the robot voice is an experimental opt-in (High performance mode)
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:
parent
2bff270734
commit
bfd1b78b4e
2 changed files with 111 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue