Rudder: Robot voice — speak each sentence in a synthetic voice, offline, for videos without your real voice

Settings → Robot voice: a checkbox and a voice dropdown. When on, every finished sentence is spoken back
(sentences, not words: TTS needs context for its intonation). speak(text) is the single entry point, so a
future conversational mode can reuse the same voice.

- Providers, both on this computer only: Piper (offline neural TTS run by the engine on the CPU; six
  LibriTTS-R speakers offered as "Offline voice A–F") and the browser's own voices, restricted to
  localService === true (some browsers synthesise "online" voices on a server — never offered).
- backend/rudder-engine/tts.go: POST /api/rudder/tts (text → audio/wav, 1–2000 characters, one synthesis
  at a time), GET /api/rudder/tts/voices, POST /api/rudder/duck. The spoken text is never logged, only
  its length. Piper 2023.11.14-2 (MIT) bundles ONNX Runtime 1.14.1: no telemetry client; a synthesis
  traced with strace made 0 network calls. ~150 MB RAM; a 10.7 s sentence rendered in 2.2 s on CPU.
- While the robot speaks through loudspeakers, the engine feeds the transcriber silence, so it never
  transcribes itself (and that silence is not mistaken for a dead mic). With a Bluetooth headset or
  headphones it keeps listening — pausing would only lose the person's words.
- setup-runtime.sh: Piper and the en_US-libritts_r-medium voice (CC BY 4.0), pinned by SHA-256.
- Verified in Firefox against a dev server: dropdown lists A–F, each finished sentence is synthesised
  (200 audio/wav) between duck on/off, zero requests to any other host.
This commit is contained in:
Doxa Maps 2026-09-28 13:53:45 +00:00
parent fb1938e732
commit a3a7c4b5b6
5 changed files with 389 additions and 3 deletions

View file

@ -186,6 +186,7 @@ type engine struct {
device string
devs *deviceWatcher
nemoKey string
ducked atomic.Bool // the robot voice is speaking: feed the transcriber silence
}
func (e *engine) setState(s, errText string) {
@ -502,7 +503,13 @@ func (e *engine) startWith(lang, device string, src *recorderProc, sink func(eve
// transcribed, then literal zeros while parec stayed "healthy" — the old Rudder's BUG 1,
// the headset losing the mic side of the link. Re-opening the source restarts the audio
// stream; the speech session and the transcript carry on untouched.
if peak == 0 && !browser {
// ROBOT VOICE SPEAKING (tts.go, /api/rudder/duck): the transcriber hears silence, so the robot's own
// voice through a loudspeaker is never transcribed back into the box — and that deliberate silence
// must not look like a dead mic link to the check below.
if e.ducked.Load() {
clear(frame)
silentSince = time.Time{}
} else if peak == 0 && !browser {
if silentSince.IsZero() {
silentSince = time.Now()
} else if !gaveUp && !resetting.Load() && time.Since(silentSince) > 1500*time.Millisecond {
@ -899,6 +906,7 @@ func main() {
_ = json.NewDecoder(r.Body).Decode(&body)
writeJSON(w, 200, e.micTest(body.Device))
})
registerTTS(mux, e, newTTS(filepath.Dir(filepath.Dir(cfg.Model)), logs))
mux.HandleFunc("/api/rudder/log", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, logs.text())

View file

@ -22,6 +22,15 @@ MODEL_REV="ea30d66debe3"
MODEL_FILE="nemotron-3.5-asr-streaming-0.6b.q8_0.gguf"
MODEL_URL="https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/resolve/${MODEL_REV}/${MODEL_FILE}"
MODEL_SHA="3fc991d3badad7277c11030a7519832cddaf2057aafed6d4b25147e953a070b1"
# Robot voice (optional text-to-speech): Piper 2023.11.14-2 (MIT; ONNX Runtime 1.14.1 inside — no telemetry
# client, 0 network calls in a traced synthesis) and one CC BY 4.0 voice (LibriTTS-R, 904 speakers).
PIPER_URL="https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz"
PIPER_SHA="a50cb45f355b7af1f6d758c1b360717877ba0a398cc8cbe6d2a7a3a26e225992"
VOICE_REV="c10ece1aade47bb51c153c893d14e5bf8e5b7117"
VOICE="en_US-libritts_r-medium"
VOICE_BASE="https://huggingface.co/rhasspy/piper-voices/resolve/${VOICE_REV}/en/en_US/libritts_r/medium"
VOICE_SHA="10bb85e071d616fcf4071f369f1799d0491492ab3c5d552ec19fb548fac13195"
VOICE_JSON_SHA="b471dc60d2d8335e819c393d196d6fbf792817f40051257b269878505bc9afb3"
say() { printf '\033[1;34m→\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
@ -46,6 +55,12 @@ fetch "$RT_URL" "$RUDDER_HOME/bin/$RT_NAME.tar.gz" "$RT_SHA"
tar -xzf "$RUDDER_HOME/bin/$RT_NAME.tar.gz" -C "$RUDDER_HOME/bin"
fetch "$MODEL_URL" "$RUDDER_HOME/models/$MODEL_FILE" "$MODEL_SHA"
mkdir -p "$RUDDER_HOME/tts"
fetch "$PIPER_URL" "$RUDDER_HOME/tts/piper_linux_x86_64.tar.gz" "$PIPER_SHA"
tar -xzf "$RUDDER_HOME/tts/piper_linux_x86_64.tar.gz" -C "$RUDDER_HOME/tts"
fetch "$VOICE_BASE/$VOICE.onnx" "$RUDDER_HOME/tts/$VOICE.onnx" "$VOICE_SHA"
fetch "$VOICE_BASE/$VOICE.onnx.json" "$RUDDER_HOME/tts/$VOICE.onnx.json" "$VOICE_JSON_SHA"
if command -v go >/dev/null; then
say "building backend/bin/rudder-engine"
(cd "$(dirname "$0")" && go build -o ../bin/rudder-engine .)

View file

@ -0,0 +1,253 @@
// tts.go — Robot voice: speak what Rudder transcribes in a synthetic voice, so someone can record a video
// that uses dictation without their real voice in it.
//
// PROVIDER: Piper (rhasspy/piper 2023.11.14-2, MIT, archived — a frozen, pinnable release), run here in the
// engine rather than in the page: one small native binary on the CPU, the same audio in the Plugin Studio,
// the desktop webview and the Studio corner, and no WebAssembly runtime shipped through the page.
//
// OFFLINE, NO TELEMETRY (measured 2026-09-28): the bundled ONNX Runtime is 1.14.1, which carries none of
// the telemetry client later versions ship (0 "OneCollector"/events strings); a synthesis traced with strace
// made 0 network calls. The voice files are local and pinned by SHA-256 (setup-runtime.sh).
//
// PRIVACY: the text to speak is never logged — only its length.
//
// POST /api/rudder/tts {"text", "voice", "speaker", "speed"} → audio/wav
// GET /api/rudder/tts/voices the voices installed under RUDDER_HOME/tts
// POST /api/rudder/duck {"on": bool} — while on, the transcriber hears silence (the robot's own
// voice through a loudspeaker must not be transcribed back into the box)
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type ttsVoice struct {
ID string `json:"id"`
Label string `json:"label"`
Speakers int `json:"speakers"`
SampleRate int `json:"sampleRate"`
model string
}
type ttsEngine struct {
dir string // RUDDER_TTS_DIR (default RUDDER_HOME/tts): piper/ + <voice>.onnx + <voice>.onnx.json
piper string
logs *ring
mu sync.Mutex // one synthesis at a time: CPU is shared with speech-to-text
}
func newTTS(base string, logs *ring) *ttsEngine {
dir := envOr("RUDDER_TTS_DIR", filepath.Join(base, "tts"))
bin := "piper"
if runtime.GOOS == "windows" {
bin = "piper.exe"
}
return &ttsEngine{dir: dir, piper: envOr("RUDDER_PIPER_BIN", filepath.Join(dir, "piper", bin)), logs: logs}
}
// voices lists every <name>.onnx with its <name>.onnx.json next to it.
func (t *ttsEngine) voices() ([]ttsVoice, error) {
if _, err := os.Stat(t.piper); err != nil {
return nil, errors.New("Piper is not installed (run backend/rudder-engine/setup-runtime.sh)")
}
matches, _ := filepath.Glob(filepath.Join(t.dir, "*.onnx"))
var out []ttsVoice
for _, m := range matches {
b, err := os.ReadFile(m + ".json")
if err != nil {
continue
}
var cfg struct {
Audio struct {
SampleRate int `json:"sample_rate"`
} `json:"audio"`
NumSpeakers int `json:"num_speakers"`
}
if json.Unmarshal(b, &cfg) != nil || cfg.Audio.SampleRate == 0 {
continue
}
id := strings.TrimSuffix(filepath.Base(m), ".onnx")
out = append(out, ttsVoice{ID: id, Label: voiceLabel(id), Speakers: max(cfg.NumSpeakers, 1), SampleRate: cfg.Audio.SampleRate, model: m})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
if len(out) == 0 {
return nil, errors.New("no Piper voice installed (run backend/rudder-engine/setup-runtime.sh)")
}
return out, nil
}
// "en_US-libritts_r-medium" → "English (US) · libritts_r"
func voiceLabel(id string) string {
parts := strings.Split(id, "-")
if len(parts) < 2 {
return id
}
lang := map[string]string{"en_US": "English (US)", "en_GB": "English (UK)"}[parts[0]]
if lang == "" {
lang = parts[0]
}
return lang + " · " + parts[1]
}
var errTTSInput = errors.New("bad request")
func (t *ttsEngine) synthesize(ctx context.Context, text, voiceID string, speaker int, speed float64) ([]byte, error) {
text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if text == "" || len([]rune(text)) > 2000 {
return nil, fmt.Errorf("%w: text must be 1–2000 characters", errTTSInput)
}
vs, err := t.voices()
if err != nil {
return nil, err
}
v := vs[0]
for _, x := range vs {
if x.ID == voiceID {
v = x
}
}
if speaker < 0 || speaker >= v.Speakers {
speaker = 0
}
// Piper's length_scale is the inverse of speed (1.0 = normal; larger = slower).
if speed < 0.5 || speed > 2 {
speed = 1
}
t.mu.Lock()
defer t.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, t.piper, "--model", v.model, "--output-raw",
"--speaker", strconv.Itoa(speaker), "--length_scale", strconv.FormatFloat(1/speed, 'f', 3, 64))
cmd.Dir = filepath.Dir(t.piper) // its libraries and espeak-ng-data sit next to it
cmd.Stdin = strings.NewReader(text + "\n")
var pcm, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &pcm, &stderr
start := time.Now()
if err := cmd.Run(); err != nil {
t.logs.add("robot voice failed after %s: %v", time.Since(start).Round(time.Millisecond), err)
return nil, errors.New("the voice engine failed — see the engine log")
}
t.logs.add("robot voice: %d characters → %.1f s of audio in %s (voice %s, speaker %d)",
len([]rune(text)), float64(pcm.Len())/2/float64(v.SampleRate), time.Since(start).Round(time.Millisecond), v.ID, speaker)
return wav(pcm.Bytes(), v.SampleRate), nil
}
// wav wraps 16-bit mono little-endian PCM in a WAV header.
func wav(pcm []byte, rate int) []byte {
var b bytes.Buffer
w := func(v any) { _ = binary.Write(&b, binary.LittleEndian, v) }
b.WriteString("RIFF")
w(uint32(36 + len(pcm)))
b.WriteString("WAVEfmt ")
w(uint32(16))
w(uint16(1)) // PCM
w(uint16(1)) // mono
w(uint32(rate))
w(uint32(rate * 2))
w(uint16(2))
w(uint16(16))
b.WriteString("data")
w(uint32(len(pcm)))
b.Write(pcm)
return b.Bytes()
}
func registerTTS(mux *http.ServeMux, e *engine, t *ttsEngine) {
mux.HandleFunc("/api/rudder/tts/voices", func(w http.ResponseWriter, r *http.Request) {
vs, err := t.voices()
if err != nil {
writeJSON(w, 200, event{"available": false, "error": err.Error(), "voices": []ttsVoice{}})
return
}
writeJSON(w, 200, event{"available": true, "provider": "piper", "voices": vs})
})
mux.HandleFunc("/api/rudder/tts", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, event{"error": "POST only"})
return
}
var body struct {
Text string `json:"text"`
Voice string `json:"voice"`
Speaker int `json:"speaker"`
Speed float64 `json:"speed"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil {
writeJSON(w, 400, event{"error": "expected JSON {text, voice, speaker, speed}"})
return
}
audio, err := t.synthesize(r.Context(), body.Text, body.Voice, body.Speaker, body.Speed)
if err != nil {
code := 500
if errors.Is(err, errTTSInput) {
code = 400
}
writeJSON(w, code, event{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(audio)
})
mux.HandleFunc("/api/rudder/duck", func(w http.ResponseWriter, r *http.Request) {
var body struct {
On bool `json:"on"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body)
// Headphones hear the robot; the mic doesn't — so there is no echo to prevent, and pausing would only
// throw away whatever the person says while the robot talks.
if body.On && headphonesInUse() {
body.On = false
}
if e.ducked.Swap(body.On) != body.On {
e.logs.add("%s", map[bool]string{true: "listening paused: the robot voice is speaking", false: "listening resumed"}[body.On])
}
writeJSON(w, 200, event{"ducked": body.On})
})
}
// headphonesInUse: the default output is a Bluetooth device or a wired headphone port, so the robot voice
// cannot leak back into the microphone. Unknown → false (pause, the safe choice for loudspeakers).
func headphonesInUse() bool {
b, err := runPactl("get-default-sink")
if err != nil {
return false
}
sink := strings.TrimSpace(string(b))
if strings.HasPrefix(sink, "bluez_output.") {
return true
}
b, err = runPactl("-f", "json", "list", "sinks")
if err != nil {
return false
}
var sinks []struct {
Name string `json:"name"`
ActivePort string `json:"active_port"`
}
if json.Unmarshal(b, &sinks) != nil {
return false
}
for _, s := range sinks {
if s.Name == sink {
return strings.Contains(strings.ToLower(s.ActivePort), "headphone")
}
}
return false
}

View file

@ -14,6 +14,12 @@ talk to your agents instead of typing, with nothing leaving your computer.
- **Microphones like a video-call app.** Pick the input, hot-plug updates, a 3-second mic test, and
plain-language errors (muted, disconnected, dead Bluetooth mic link — which it tries to recover).
- **Robot voice (optional).** Settings → *Robot voice*: every sentence you say is spoken back in a
synthetic voice, so you can record a video that uses dictation without your real voice in it. Offline
neural voices (Piper, run by the engine on the CPU) or the browser's own on-device voices; nothing leaves
the computer. While the robot talks through loudspeakers, the engine stops listening, so it never
transcribes itself; with headphones it keeps listening.
## Run it in the Plugin Studio
```bash

View file

@ -51,7 +51,7 @@ function load() {
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}') } catch { return {} }
}
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify({ language: language.value, device: device.value, draft: draft.value })) } catch {}
try { localStorage.setItem(STORE_KEY, JSON.stringify({ language: language.value, device: device.value, draft: draft.value, robotOn: robotOn.value, robotVoice: robotVoice.value })) } catch {}
}
const saved = load()
@ -75,6 +75,91 @@ const copied = ref('')
const showDebug = ref(false)
const showInfo = ref(false)
const showSettings = ref(false)
// ─── ROBOT VOICE ───────────────────────────────────────────────────────────────────────────────────
// Speaks each finished sentence back in a synthetic voice, so a video can use dictation without the
// speaker's real voice in it. Providers (all on this computer, nothing leaves it):
// piper:<voice>:<speaker> the engine's Piper voice (tts.go) — offline neural TTS on the CPU
// system:<voiceURI> the browser's own voices, ONLY those with localService === true (some
// browsers synthesise "online" voices on a server — never offered here)
// Sentences, not words: TTS needs a few words of context for its intonation; single words sound choppy.
// speak(text) is the one entry point, so a future conversational mode can reuse the same voice.
const robotOn = ref(saved.robotOn === true)
const robotVoice = ref(typeof saved.robotVoice === 'string' ? saved.robotVoice : '')
const robotOptions = ref([]) // [{ value, label }]
const robotError = ref('')
// A handful of LibriTTS-R speakers as distinct presets — 904 is too many to list.
const PIPER_SPEAKERS = [0, 12, 40, 118, 262, 500]
async function loadRobotVoices() {
const opts = []
robotError.value = ''
try {
const r = await (await fetch(apiBase.value + '/tts/voices')).json()
if (r.available) {
for (const v of r.voices) {
const speakers = v.speakers > 1 ? PIPER_SPEAKERS.filter((n) => n < v.speakers) : [0]
speakers.forEach((n, i) => opts.push({ value: `piper:${v.id}:${n}`, label: `Offline voice ${String.fromCharCode(65 + i)} · ${v.label}` }))
}
} else if (r.error) robotError.value = r.error
} catch { /* engine offline: system voices may still work */ }
const synth = typeof window !== 'undefined' && window.speechSynthesis
if (synth) {
for (const v of synth.getVoices().filter((x) => x.localService)) opts.push({ value: 'system:' + v.voiceURI, label: 'System voice · ' + v.name })
}
robotOptions.value = opts
if (!opts.some((o) => o.value === robotVoice.value)) robotVoice.value = opts[0] ? opts[0].value : ''
}
const sayQueue = []
let saying = false
function speak(text) {
const t = (text || '').trim()
if (!t || !robotVoice.value) return
sayQueue.push(t)
if (!saying) drainSpeech()
}
// While the robot talks, the engine feeds the transcriber silence (unless the sound goes to headphones),
// so a loudspeaker never transcribes the robot back into the box.
async function drainSpeech() {
saying = true
post('/duck', { on: true }).catch(() => {})
while (sayQueue.length) {
try { await speakOne(sayQueue.shift()) } catch (err) { robotError.value = String(err.message || err) }
}
await new Promise((r) => setTimeout(r, 250)) // let the room's echo die down
post('/duck', { on: false }).catch(() => {})
saying = false
}
async function speakOne(text) {
const v = robotVoice.value
if (v.startsWith('piper:')) {
const [, voice, speaker] = v.split(':')
const r = await fetch(apiBase.value + '/tts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice, speaker: Number(speaker) || 0 }),
})
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'The robot voice failed')
const url = URL.createObjectURL(await r.blob())
try {
await new Promise((done) => { const a = new Audio(url); a.onended = a.onerror = () => done(); a.play().catch(() => done()) })
} finally { URL.revokeObjectURL(url) }
} else if (v.startsWith('system:') && window.speechSynthesis) {
const voice = window.speechSynthesis.getVoices().find((x) => x.voiceURI === v.slice(7) && x.localService)
if (!voice) throw new Error('That system voice is no longer available')
await new Promise((done) => {
const u = new window.SpeechSynthesisUtterance(text)
u.voice = voice
u.onend = u.onerror = () => done()
window.speechSynthesis.speak(u)
})
}
}
function toggleRobot() {
save()
if (robotOn.value) { loadRobotVoices(); speak('Robot voice on.') }
else { sayQueue.length = 0; window.speechSynthesis?.cancel?.() }
}
const clientLog = ref([])
// ─── Microphones (the Jitsi device-selection pattern, served natively by the engine) ───
@ -335,6 +420,7 @@ function onEvent(data) {
case 'final':
partial.value = ''
onFinal(e.text)
if (robotOn.value) speak(e.text)
break
case 'recovering':
say(e.message, 'warn')
@ -567,6 +653,9 @@ onMounted(() => {
fetchStatus()
connect()
refreshDevices()
loadRobotVoices()
// System voices load asynchronously in most browsers.
if (window.speechSynthesis) window.speechSynthesis.addEventListener?.('voiceschanged', loadRobotVoices)
window.addEventListener('keydown', onKey)
})
onBeforeUnmount(() => {
@ -614,7 +703,7 @@ const statusText = computed(() => ({
<span class="dot" aria-hidden="true"></span>{{ statusText }}
</div>
<span class="spacer"></span>
<button class="icon-btn" :aria-expanded="showSettings" title="Language and microphone" aria-label="Language and microphone" @click="showSettings = !showSettings">
<button class="icon-btn" :aria-expanded="showSettings" title="Settings" aria-label="Settings" @click="showSettings = !showSettings">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg>
</button>
<button class="icon-btn" :aria-expanded="showInfo" title="What is Rudder?" aria-label="What is Rudder?" @click="showInfo = !showInfo">
@ -690,6 +779,17 @@ const statusText = computed(() => ({
</button>
<button class="small ghost" title="Look for microphones again" aria-label="Look for microphones again" :disabled="recording" @click="refreshDevices">↻</button>
</section>
<section v-if="!compact || 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>
</label>
<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>
</section>
<p v-if="testResult" class="notice" :data-kind="testResult.ok ? (testResult.peak < 0.01 ? 'warn' : 'ok') : 'error'">
{{ testResult.ok ? '✓ ' : '✗ ' }}{{ testResult.verdict }}
</p>
@ -762,6 +862,10 @@ const statusText = computed(() => ({
</template>
<style>
.robot { display: flex; flex-direction: column; gap: 6px; }
.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%; }
:host { display: block; }
.rudder {
--bg: #0b0f14; --panel: #111823; --line: #1f2937; --text: #e5e7eb; --muted: #9ca3af;