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