Rudder's robot voice keeps a sentence whole across a breath, and splits only real run-ons
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

First live use: one sentence came out as two.
- A pause is not a sentence end: the engine finalises after 0.8 s of silence, a breath mid-sentence
  included, and the robot spoke that fragment alone. A final that doesn't end in . ! or ? is now held and
  joined to what's said next; spoken as it is after 2.5 s of silence or on Stop.
- The comma break for run-ons now needs ≥20 words waiting (was 14) and keeps ≥10 in the piece, so ordinary
  long sentences are no longer cut at their comma.
- Checked by running the page's own functions on scripted streams: a sentence paused mid-way → one spoken
  sentence; a 30-word run-on → two clause-sized pieces; a 16-word sentence with a comma → not split; an
  unfinished thought then Stop → still spoken.
This commit is contained in:
Doxa Maps 2026-09-28 14:45:56 +00:00
parent 971162a4b6
commit ac21afea21

View file

@ -146,6 +146,8 @@ const SENTENCE_END = /[.!?]["')\]]?$/
// 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.
// ★ OVER-SPLITTING (measured 2026-09-28, first live use): at ≥14 words the comma rule also cut ordinary
// long sentences in two. Raised to ≥20 words waiting, keeping ≥10 in the piece spoken.
const CLAUSE_END = /[,;:]$/
function robotOnPartial(full) {
if (!robotOn.value) return
@ -154,19 +156,42 @@ function robotOnPartial(full) {
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 >= 20) {
for (let i = robotSpoken + 9; 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(' '))
speak(takeHeld() + w.slice(robotSpoken, end + 1).join(' '))
robotSpoken = end + 1
}
}
// ★ A PAUSE IS NOT A SENTENCE END. The engine finalises after 0.8 s of silence — a breath in the middle of a
// sentence included — and the robot used to speak that fragment on its own, so one sentence came out as two.
// A final that doesn't end in . ! or ? is HELD and joined to whatever is said next; if nothing follows within
// 2.5 s (or recording stops) it is spoken as it is.
let robotHeld = ''
let robotHoldTimer = null
function takeHeld() {
clearTimeout(robotHoldTimer)
const h = robotHeld
robotHeld = ''
return h ? h + ' ' : ''
}
function speakHeld() {
const h = takeHeld().trim()
if (h && robotOn.value) speak(h)
}
function robotOnFinal(text) {
const rest = wordsOf(text).slice(robotSpoken)
robotSpoken = 0
if (robotOn.value && rest.length) speak(rest.join(' '))
if (!robotOn.value || !rest.length) return
if (SENTENCE_END.test(rest[rest.length - 1])) {
speak(takeHeld() + rest.join(' '))
} else {
robotHeld = (robotHeld ? robotHeld + ' ' : '') + rest.join(' ')
clearTimeout(robotHoldTimer)
robotHoldTimer = setTimeout(speakHeld, 2500)
}
}
// A light clean-up of what the ROBOT says (the box keeps exactly what was said): drop filler sounds, collapse
@ -607,6 +632,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
speakHeld()
clearTimeout(flushTimer)
const finals = pendingFinals.splice(0)
const p = pendingPartial