mirror of
https://github.com/hanzoai/speech.git
synced 2026-10-04 07:37:19 +00:00
363 lines
12 KiB
Go
363 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
)
|
|
|
|
// Rates the models actually work at. Neither is a choice: every recognizer here
|
|
// reads 16 kHz, and both synthesizers speak 24 kHz.
|
|
const (
|
|
Rate = 16000 // what a recognizer hears
|
|
Width = 2 // bytes per pcm16 sample
|
|
Second = Rate * Width // bytes of raw pcm16 per second
|
|
Spoken = 24000 // what a synthesizer says
|
|
)
|
|
|
|
// How audio is cut into the spans a recognizer decodes. A span is speech
|
|
// between pauses, so a cut never lands inside a word, and whisper's spans stay
|
|
// under the thirty seconds it can attend over.
|
|
const (
|
|
Pause = 0.3 // quiet this long ends a stretch of speech
|
|
Pad = 0.5 // audio kept either side of a stretch, for its onset and its tail
|
|
Gap = 2.0 // whisper decodes stretches closer than this together
|
|
Longest = 20.0 // and merges none past this length
|
|
Hardest = 28.0 // and decodes none longer than this: its ceiling is 30
|
|
Search = 3.0 // a cut that must fall inside speech looks this far back for a lull
|
|
Lull = 0.08 // the stretch of audio a cut point is weighed by
|
|
)
|
|
|
|
// How a parakeet request that names no language finds out which one it is.
|
|
const (
|
|
Hearing = 2.0 // audio a growing transcript waits for before it asks
|
|
Listen = 30.0 // the most audio language identification reads
|
|
)
|
|
|
|
// word is one word and when it was said, in seconds from the start of the audio
|
|
// the caller sent — what a caption cuts on.
|
|
type word struct {
|
|
Word string `json:"word"`
|
|
Start float64 `json:"start"`
|
|
End float64 `json:"end"`
|
|
}
|
|
|
|
// heard is one decode: its text and, when the model times its tokens, its words
|
|
// in seconds from the start of the span it was handed.
|
|
type heard struct {
|
|
text string
|
|
words []word
|
|
}
|
|
|
|
// An ear turns 16 kHz mono samples into words. lang is a code the ear's model
|
|
// knows, or "" to let it decide.
|
|
type ear interface {
|
|
hear(pcm []float32, lang string) (heard, error)
|
|
}
|
|
|
|
// A lid says which language is being spoken.
|
|
type lid interface {
|
|
identify(pcm []float32) (string, error)
|
|
}
|
|
|
|
// A mouth turns text into 24 kHz mono samples in one of its voices.
|
|
type mouth interface {
|
|
speak(text, voice string, speed float32) ([]float32, error)
|
|
}
|
|
|
|
// A gate is one session's squelch detector: it remembers the room across pushes.
|
|
type gate interface {
|
|
voiced(pcm []float32) bool
|
|
close()
|
|
}
|
|
|
|
// span is a stretch of samples, [start, end).
|
|
type span struct{ start, end int }
|
|
|
|
func (s span) seconds() (float64, float64) { return float64(s.start) / Rate, float64(s.end) / Rate }
|
|
|
|
// A vad finds speech: a gate per session, and the stretches of speech in a span.
|
|
type vad interface {
|
|
gate() gate
|
|
regions(pcm []float32) []span
|
|
}
|
|
|
|
// speech is every model the service serves, behind the interfaces above.
|
|
type speech struct {
|
|
ears map[string]ear // "parakeet", "whisper"
|
|
lid lid // routes parakeet's foreign speech to whisper
|
|
mouths map[string]mouth // "kokoro"
|
|
vad vad
|
|
}
|
|
|
|
// heard and spoken list the model ids, in the order /v1/models names them.
|
|
var (
|
|
heardBy = []string{"parakeet", "whisper"}
|
|
spokenBy = []string{"kokoro"}
|
|
)
|
|
|
|
func (sp *speech) models() []string {
|
|
var out []string
|
|
for _, m := range heardBy {
|
|
if sp.ears[m] != nil {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
for _, m := range spokenBy {
|
|
if sp.mouths[m] != nil {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// route decides which ear decodes a request, and in which language.
|
|
//
|
|
// whisper decodes everything, in the language named or the one it detects.
|
|
// parakeet decodes its 25 languages and hands the rest to whisper: a language
|
|
// named outside its set goes straight there, and with none named the audio is
|
|
// identified first. heard is the speech identification reads — the caller's
|
|
// audio with its silences cut out, at most Listen seconds of it — and is read
|
|
// only when it has to be.
|
|
//
|
|
// The language handed to whisper on a fallback is "" when it was identified
|
|
// rather than named: whisper's own detection runs on a model forty times the
|
|
// size of the identifier's, and it is one decoder step.
|
|
func (sp *speech) route(model, lang string, heard func() []float32) (ear, string, string, error) {
|
|
switch model {
|
|
case "whisper":
|
|
return sp.ears["whisper"], "whisper", lang, nil
|
|
case "parakeet":
|
|
if lang != "" {
|
|
if parakeetLanguages[lang] {
|
|
return sp.ears["parakeet"], "parakeet", lang, nil
|
|
}
|
|
return sp.ears["whisper"], "whisper", lang, nil
|
|
}
|
|
pcm := heard()
|
|
if len(pcm) == 0 {
|
|
return sp.ears["parakeet"], "parakeet", "", nil
|
|
}
|
|
said, err := sp.lid.identify(pcm)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
if parakeetLanguages[said] {
|
|
return sp.ears["parakeet"], "parakeet", "", nil
|
|
}
|
|
slog.Info("parakeet hands a language it lacks to whisper", "identified", said)
|
|
return sp.ears["whisper"], "whisper", "", nil
|
|
}
|
|
return nil, "", "", fmt.Errorf("model %q transcribes nothing", model)
|
|
}
|
|
|
|
// merges says which recognizers decode neighbouring stretches of speech as one
|
|
// span. whisper does: it attends over the whole span, so the sentence before
|
|
// is context for the sentence after, and each decode carries a fixed cost
|
|
// (about half a second on four cores) that merging pays once. Parakeet does
|
|
// not: measured on its int8 export, a span holding two speakers with room tone
|
|
// between them lost the second speaker in 24 of 64 trials; decoded stretch by
|
|
// stretch, with a stretch ending at Pause of quiet, none of 72 did.
|
|
var merges = map[string]bool{"whisper": true}
|
|
|
|
// stretches cuts audio into the spans that are decoded: speech regions, merged
|
|
// while they are close together and short enough when the recognizer merges,
|
|
// and split by length if one is still too long for whisper. A split lands where
|
|
// quiet says, which is the quietest moment before the length runs out: cut
|
|
// mid-word, the decode on either side answers with the whole word.
|
|
func stretches(regions []span, merge bool, quiet func(lo, hi int) int) []span {
|
|
var out []span
|
|
for _, r := range regions {
|
|
if n := len(out); merge && n > 0 {
|
|
last := &out[n-1]
|
|
if float64(r.start-last.end) < Gap*Rate && float64(r.end-last.start) <= Longest*Rate {
|
|
last.end = r.end
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
var cut []span
|
|
hard := int(Hardest * Rate)
|
|
for _, s := range out {
|
|
for s.end-s.start > hard {
|
|
at := quiet(s.start+hard-int(Search*Rate), s.start+hard)
|
|
cut = append(cut, span{s.start, at})
|
|
s.start = at
|
|
}
|
|
cut = append(cut, s)
|
|
}
|
|
return cut
|
|
}
|
|
|
|
// quietest is where to cut pcm somewhere in [lo, hi): the middle of the quietest
|
|
// Lull of it, weighed in 10 ms steps. A recognizer handed half a word answers
|
|
// with a word, so the half before a cut and the half after both come back whole
|
|
// — "music to to children", "a tiny cab. Cafe". Between words, or at a comma,
|
|
// there is nothing to hear twice.
|
|
func quietest(pcm []float32, lo, hi int) int {
|
|
lo, hi = max(lo, 0), min(hi, len(pcm))
|
|
lull, step := int(Lull*Rate), Rate/100
|
|
if hi-lo <= lull {
|
|
return hi
|
|
}
|
|
energy := func(a int) float64 {
|
|
var e float64
|
|
for _, x := range pcm[a : a+lull] {
|
|
e += float64(x) * float64(x)
|
|
}
|
|
return e
|
|
}
|
|
best, at := energy(lo), lo
|
|
for a := lo + step; a+lull <= hi; a += step {
|
|
if e := energy(a); e <= best { // the latest of equals: the more is committed, the smaller the window
|
|
best, at = e, a
|
|
}
|
|
}
|
|
return at + lull/2
|
|
}
|
|
|
|
// split cuts any region that runs across at into the part before it and the part
|
|
// after, so a cut that has to fall inside speech still has spans to decode on
|
|
// either side of it.
|
|
func split(regions []span, at int) []span {
|
|
out := make([]span, 0, len(regions)+1)
|
|
for _, r := range regions {
|
|
if r.start < at && at < r.end {
|
|
out = append(out, span{r.start, at}, span{at, r.end})
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reach is the audio a stretch is decoded from: Pad either side of its speech,
|
|
// but never past the midpoint to a neighbouring stretch, so no word is heard
|
|
// twice and no other speaker's tail rides in. prev and next are the stretch
|
|
// ends either side, or -1 where there is none.
|
|
func reach(s span, prev, next int) (int, int) {
|
|
pad := int(Pad * Rate)
|
|
lo, hi := s.start-pad, s.end+pad
|
|
if prev >= 0 {
|
|
lo = max(lo, (prev+s.start)/2)
|
|
}
|
|
if next >= 0 {
|
|
hi = min(hi, (s.end+next)/2)
|
|
}
|
|
return lo, hi
|
|
}
|
|
|
|
// excerpt is pcm[lo:hi] with silence wherever the range runs past the audio.
|
|
// Every decode is handed Pad of audio either side of its speech, and a word at
|
|
// the very first sample still needs that lead-in: decoded with none, "The quick
|
|
// brown fox" came back as "Quick brown fox" and "A quick brown fox".
|
|
func excerpt(pcm []float32, lo, hi int) []float32 {
|
|
out := make([]float32, hi-lo)
|
|
a, b := max(lo, 0), min(hi, len(pcm))
|
|
if a < b {
|
|
copy(out[a-lo:], pcm[a:b])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// spoken is the audio of the stretches, end to end, up to Listen seconds: what
|
|
// language identification reads, with the room's silences left out.
|
|
func spoken(pcm []float32, parts []span) []float32 {
|
|
limit := int(Listen * Rate)
|
|
var out []float32
|
|
for _, s := range parts {
|
|
out = append(out, pcm[s.start:s.end]...)
|
|
if len(out) >= limit {
|
|
return out[:limit]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// segment is one decoded stretch of a batch transcription, shaped as OpenAI's
|
|
// verbose_json segment for the fields this decoder actually knows.
|
|
type segment struct {
|
|
ID int `json:"id"`
|
|
Seek int `json:"seek"`
|
|
Start float64 `json:"start"`
|
|
End float64 `json:"end"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// transcription is one batch transcription: the text, the length of the audio
|
|
// submitted (the billable quantity), and where every stretch and word fell.
|
|
type transcription struct {
|
|
text string
|
|
duration float64
|
|
segments []segment
|
|
words []word
|
|
}
|
|
|
|
// transcribe decodes a whole recording.
|
|
//
|
|
// duration is the audio SUBMITTED, not the speech found in it: a caller is
|
|
// charged for what they asked us to listen to, not for how much of it turned
|
|
// out to be talking — the same rule the Python service billed by.
|
|
func (sp *speech) transcribe(model, lang string, pcm []float32) (transcription, error) {
|
|
out := transcription{duration: float64(len(pcm)) / Rate}
|
|
regions := sp.vad.regions(pcm)
|
|
if len(regions) == 0 {
|
|
return out, nil
|
|
}
|
|
e, engine, code, err := sp.route(model, lang, func() []float32 { return spoken(pcm, regions) })
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
parts := stretches(regions, merges[engine], func(lo, hi int) int { return quietest(pcm, lo, hi) })
|
|
for i, s := range parts {
|
|
prev, next := -1, -1
|
|
if i > 0 {
|
|
prev = parts[i-1].end
|
|
}
|
|
if i+1 < len(parts) {
|
|
next = parts[i+1].start
|
|
}
|
|
lo, hi := reach(s, prev, next)
|
|
h, err := e.hear(excerpt(pcm, lo, hi), code)
|
|
if err != nil {
|
|
return out, fmt.Errorf("%s: %w", engine, err)
|
|
}
|
|
start, end := s.seconds()
|
|
out.segments = append(out.segments, segment{ID: i, Seek: s.start / (Rate / 100), Start: round(start), End: round(end), Text: h.text})
|
|
at := float64(lo) / Rate
|
|
for _, w := range h.words {
|
|
out.words = append(out.words, word{Word: w.Word, Start: round(clamp(at+w.Start, out.duration)), End: round(clamp(at+w.End, out.duration))})
|
|
}
|
|
out.text = join(out.text, h.text)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// join puts two pieces of text together with a space, unless both sides of the
|
|
// seam are a script written without one.
|
|
func join(a, b string) string {
|
|
a, b = strings.TrimSpace(a), strings.TrimSpace(b)
|
|
switch {
|
|
case a == "":
|
|
return b
|
|
case b == "":
|
|
return a
|
|
}
|
|
ra, rb := []rune(a), []rune(b)
|
|
if unspaced(string(ra[len(ra)-1])) && unspaced(string(rb[0])) {
|
|
return a + b
|
|
}
|
|
return a + " " + b
|
|
}
|
|
|
|
func clamp(x, top float64) float64 { return max(0, min(x, top)) }
|
|
|
|
func round(x float64) float64 {
|
|
const k = 1000
|
|
if x < 0 {
|
|
return -round(-x)
|
|
}
|
|
return float64(int64(x*k+0.5)) / k
|
|
}
|