mirror of
https://github.com/hanzoai/voice.git
synced 2026-10-03 10:14:33 +00:00
Audio in, audio out, on an IAM bearer, billed to whoever spoke. speech holds the models; this holds the conversation. The wire is the OpenAI realtime protocol so existing clients work unchanged. hanzoai/go-openai-realtime already carries the event types but is a client, so it can only unmarshal the server's half; Read supplies the other direction. A browser cannot put a header on a WebSocket, so the bearer is spent on a POST and the URL carries a single-use thirty-second ticket naming a principal already verified here. It has no claims and cannot be verified elsewhere, so a copy lifted from a log buys nothing. Replies are synthesised a sentence at a time: 452 ms to first sound against 2267 ms for a whole reply, and every sentence after the first is free because synthesis outruns speech. Measured end to end at 1.15-1.55 s from the speaker stopping, of which 500 ms is the silence detector that Turn exists to replace. Turn, Mind and Meter are the seams. Hush ends a turn on silence alone, which cuts people off mid-thought and waits through gaps it should have taken; it is there so the surface works while a detector that reads intent is built. Co-authored-by: Hanzo Dev <[email protected]>
84 lines
2.2 KiB
Go
84 lines
2.2 KiB
Go
package voice
|
|
|
|
import "strings"
|
|
|
|
// Sentences cuts a stream of text into utterances a synthesiser can speak
|
|
// before the rest of the text exists.
|
|
//
|
|
// This is the whole latency argument. Synthesising a reply in one call pays for
|
|
// every sentence before the first is heard; measured on kokoro that is 2267 ms
|
|
// against 452 ms for the first sentence alone. After the first, synthesis runs
|
|
// while the previous clip plays and costs nothing, because the models run
|
|
// several times faster than speech.
|
|
//
|
|
// The cut is on terminal punctuation followed by a space, which is where a
|
|
// speaker would breathe. A decimal point, an abbreviation or an ellipsis is
|
|
// not such a place, so `atEnd` refuses those; a clause that never terminates
|
|
// is cut at `most` anyway, since silence while a model runs on is worse than
|
|
// an early breath.
|
|
func Sentences(in <-chan string, most int) <-chan string {
|
|
out := make(chan string)
|
|
go func() {
|
|
defer close(out)
|
|
var held strings.Builder
|
|
flush := func() {
|
|
if s := strings.TrimSpace(held.String()); s != "" {
|
|
out <- s
|
|
}
|
|
held.Reset()
|
|
}
|
|
for piece := range in {
|
|
for _, r := range piece {
|
|
held.WriteRune(r)
|
|
if held.Len() >= most && r == ' ' {
|
|
flush()
|
|
continue
|
|
}
|
|
if r == ' ' && atEnd(held.String()) {
|
|
flush()
|
|
}
|
|
}
|
|
}
|
|
flush()
|
|
}()
|
|
return out
|
|
}
|
|
|
|
// atEnd reports whether text ends a sentence rather than merely a token that
|
|
// happens to carry a dot.
|
|
func atEnd(text string) bool {
|
|
t := strings.TrimRight(text, " ")
|
|
if t == "" {
|
|
return false
|
|
}
|
|
last := t[len(t)-1]
|
|
if last != '.' && last != '!' && last != '?' {
|
|
return false
|
|
}
|
|
// "..." is a pause inside a thought, not the end of one.
|
|
if strings.HasSuffix(t, "...") {
|
|
return false
|
|
}
|
|
body := t[:len(t)-1]
|
|
if body == "" {
|
|
return false
|
|
}
|
|
// A digit before the dot is a decimal or a list number: "3.14", "1. ".
|
|
if c := body[len(body)-1]; c >= '0' && c <= '9' {
|
|
return false
|
|
}
|
|
// A single letter before the dot is an initial or an abbreviation: "J.", "e.g."
|
|
word := body
|
|
if i := strings.LastIndexAny(body, " \t"); i >= 0 {
|
|
word = body[i+1:]
|
|
}
|
|
if len(word) == 1 {
|
|
return false
|
|
}
|
|
switch strings.ToLower(word) {
|
|
case "mr", "mrs", "ms", "dr", "prof", "st", "vs", "etc", "e.g", "i.e", "no":
|
|
return false
|
|
}
|
|
return true
|
|
}
|