From 971162a4b6be0154aa38602eaec553235557a6f1 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 14:42:09 +0000 Subject: [PATCH] Rudder's robot voice keeps up with non-stop talk, skips fillers, and defaults to Male voice 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Speaks each sentence as soon as it's complete in the stream, not only after a pause. MEASURED: with no pause between sentences the model joins them with a comma, not a period, so there may be no sentence end for 20+ s; a long stretch (≥14 words) now also breaks at the last comma (≥8 words), and ≥35 words with no punctuation speaks the first 25. Short fragments (<3 words) ride along with the next chunk. On 27 s of speech with no pause the robot spoke at 8.4 s, 16.5 s and 24.1 s instead of silent until 21.7 s; every word is spoken exactly once, in order. - Cleans what the ROBOT says (the box keeps exactly what was said): drops um/uh/erm/hmm/ah with their commas, collapses stuttered repeats ("the the"), tidies spacing, capitalises. Text rules only. - Default voice: Male voice 5 (speaker 375) — the clearest by ear; the Robot voice itself stays off until ticked. --- plugin-designs/rudder-voice/rudder-voice.vue | 60 +++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index 721c4c750..1cf8e3f61 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -124,13 +124,65 @@ async function loadRobotVoices() { for (const v of synth.getVoices().filter((x) => x.localService).slice(0, 10)) 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 : '' + if (!opts.some((o) => o.value === robotVoice.value)) { + // Default: Male voice 5 (speaker 375) — chosen by ear as the clearest; 1–4 sounded muffled. + const preferred = opts.find((o) => /:375$/.test(o.value)) + robotVoice.value = preferred ? preferred.value : opts[0] ? opts[0].value : '' + } } const sayQueue = [] let saying = false +// ─── What the robot says: whole sentences, as soon as they're complete, cleaned up ─────────────── +// The engine only sends a FINAL after a pause, so during a minute of continuous talk the robot fell silent +// and then read everything at once. The model already punctuates as it streams, so a sentence is ready the +// moment a word ending in . ! or ? is followed by more words: speak it then, without waiting for a pause. +// Fragments under 3 words ("Okay.") wait and ride along with the next sentence. robotSpoken counts the words +// of the current utterance already handed to the robot; the final speaks only what's left. +let robotSpoken = 0 +const SENTENCE_END = /[.!?]["')\]]?$/ +// ★ RUN-ONS (measured 2026-09-28): with no pause between sentences the model joins them with a COMMA, not +// a period ("…immediately afterwards, going along…"), so during non-stop ideation there may be no sentence +// end for 20+ seconds. So a long pending stretch also breaks at a clause: ≥14 words waiting → speak up to the +// last comma (keeping ≥8 words); ≥35 words with no comma at all → speak the first 25, so the robot never +// falls silent for long. +const CLAUSE_END = /[,;:]$/ +function robotOnPartial(full) { + if (!robotOn.value) return + const w = wordsOf(full) + const done = w.length - 1 // the last word may be half-streamed: only words before it are settled + const pending = done - robotSpoken + let end = -1 + for (let i = robotSpoken; i < done; i++) if (SENTENCE_END.test(w[i])) end = i + if (end < 0 && pending >= 14) { + for (let i = robotSpoken + 7; i < done; i++) if (CLAUSE_END.test(w[i])) end = i + if (end < 0 && pending >= 35) end = robotSpoken + 24 + } + if (end >= robotSpoken && end - robotSpoken + 1 >= 3) { + speak(w.slice(robotSpoken, end + 1).join(' ')) + robotSpoken = end + 1 + } +} +function robotOnFinal(text) { + const rest = wordsOf(text).slice(robotSpoken) + robotSpoken = 0 + if (robotOn.value && rest.length) speak(rest.join(' ')) +} + +// A light clean-up of what the ROBOT says (the box keeps exactly what was said): drop filler sounds, collapse +// stuttered repeats ("the the"), tidy spacing, capitalise. Pure text rules, no model, no extra compute. +// A filler takes its commas with it (", um," → " "), but never a full stop that ends a sentence. +const FILLERS = /(?:,\s*)?\b(?:u+m+|u+h+m*|e+r+m+|h+m+|a+h+)\b(?:\s*,)?/gi +function cleanForSpeech(t) { + let s = (t || '').replace(FILLERS, ' ') + s = s.replace(/\b(\w+)(?:[,\s]+\1\b)+/gi, '$1') + s = s.replace(/\s+([,.!?;:])/g, '$1').replace(/([,.!?;:])(?=[^\s\d])/g, '$1 ').replace(/\s{2,}/g, ' ').trim() + s = s.replace(/^[,.;:!?\s]+/, '') + return s ? s[0].toUpperCase() + s.slice(1) : '' +} + function speak(text) { - const t = (text || '').trim() + const t = cleanForSpeech(text) if (!t || !robotVoice.value) return sayQueue.push(t) if (!saying) drainSpeech() @@ -440,11 +492,12 @@ function onEvent(data) { case 'partial': partial.value += e.text onPartial(partial.value) + robotOnPartial(partial.value) break case 'final': partial.value = '' onFinal(e.text) - if (robotOn.value) speak(e.text) + robotOnFinal(e.text) break case 'recovering': say(e.message, 'warn') @@ -553,6 +606,7 @@ function closeStream() { // Recording stopped mid-sentence: the streamed words are already in the box, so just let go of them. function flushPartial() { + robotSpoken = 0 clearTimeout(flushTimer) const finals = pendingFinals.splice(0) const p = pendingPartial