Rudder's robot voice keeps up with non-stop talk, skips fillers, and defaults to Male voice 5
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

- 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.
This commit is contained in:
Doxa Maps 2026-09-28 14:42:09 +00:00
parent 45c2ce9bcc
commit 971162a4b6

View file

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