mirror of
https://github.com/hanzoai/speech.git
synced 2026-10-04 07:37:19 +00:00
398 lines
14 KiB
Go
398 lines
14 KiB
Go
package main
|
|
|
|
// The native half: every call into sherpa-onnx lives in this file, and nothing
|
|
// outside it knows the runtime exists. The rest of the service talks to the
|
|
// interfaces in speech.go, which is what lets its arithmetic be tested against
|
|
// stand-ins without a weight on disk.
|
|
//
|
|
// sherpa-onnx is C++ over ONNX Runtime. Two of its failure modes end the
|
|
// process rather than return an error — an unknown whisper language and a VAD
|
|
// window of the wrong size both call exit(-1) — so this file validates before it
|
|
// calls, and never hands the runtime an empty buffer (taking &samples[0] of one
|
|
// is a Go panic before it is a C bug).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"unicode"
|
|
|
|
sherpa "github.com/k2-fsa/sherpa-onnx-go-linux"
|
|
)
|
|
|
|
// worker runs one model's calls one at a time, in the order they arrived.
|
|
//
|
|
// One at a time because each call already spreads across every core the pod
|
|
// has (NumThreads below), so a second concurrent call buys a longer queue, not
|
|
// more throughput. In arrival order because the queue IS the fairness: a pass
|
|
// over one transcript waits behind the passes already queued, never ahead of
|
|
// them. An unbuffered channel queues its senders FIFO, so there is no second
|
|
// structure to keep in step.
|
|
type worker chan func()
|
|
|
|
func newWorker() worker {
|
|
w := make(worker)
|
|
go func() {
|
|
for f := range w {
|
|
f()
|
|
}
|
|
}()
|
|
return w
|
|
}
|
|
|
|
func (w worker) do(f func()) {
|
|
done := make(chan struct{})
|
|
w <- func() { f(); close(done) }
|
|
<-done
|
|
}
|
|
|
|
// threads is how many cores one call may spread across: the pod's CPU limit,
|
|
// which GOMAXPROCS already reads from the cgroup.
|
|
func threads() int { return runtime.GOMAXPROCS(0) }
|
|
|
|
// shortest is the least audio handed to a recognizer. A transducer subsamples
|
|
// its input eightfold and whisper wants frames to attend over; a fragment
|
|
// shorter than this is padded with silence, which changes no words.
|
|
const shortest = Rate
|
|
|
|
func padded(pcm []float32) []float32 {
|
|
if len(pcm) >= shortest {
|
|
return pcm
|
|
}
|
|
out := make([]float32, shortest)
|
|
copy(out, pcm)
|
|
return out
|
|
}
|
|
|
|
// recognizer is a sherpa offline recognizer behind a worker.
|
|
type recognizer struct {
|
|
rec *sherpa.OfflineRecognizer
|
|
cfg sherpa.OfflineRecognizerConfig
|
|
whisper bool
|
|
run worker
|
|
}
|
|
|
|
func must(path string) (string, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
return "", fmt.Errorf("weights: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func files(dir string, names ...string) ([]string, error) {
|
|
out := make([]string, len(names))
|
|
for i, n := range names {
|
|
p, err := must(filepath.Join(dir, n))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = p
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// newParakeet loads NVIDIA Parakeet TDT 0.6B v3 (int8): a transducer, so its
|
|
// tokens carry their own start and duration and words are timed for free.
|
|
func newParakeet(dir string) (*recognizer, error) {
|
|
f, err := files(filepath.Join(dir, "parakeet"), "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var c sherpa.OfflineRecognizerConfig
|
|
c.FeatConfig = sherpa.FeatureConfig{SampleRate: Rate, FeatureDim: 80}
|
|
c.ModelConfig.Transducer = sherpa.OfflineTransducerModelConfig{Encoder: f[0], Decoder: f[1], Joiner: f[2]}
|
|
c.ModelConfig.Tokens = f[3]
|
|
c.ModelConfig.ModelType = "nemo_transducer"
|
|
c.ModelConfig.NumThreads = threads()
|
|
c.ModelConfig.Provider = "cpu"
|
|
c.DecodingMethod = "greedy_search"
|
|
return open(c, false)
|
|
}
|
|
|
|
// newWhisper loads Whisper large-v3-turbo (int8). The decoder is our own export
|
|
// with the alignment heads' cross-attention as a fourth output, which is what
|
|
// lets sherpa time each token by DTW; the published export has three outputs and
|
|
// times nothing.
|
|
func newWhisper(dir string) (*recognizer, error) {
|
|
f, err := files(filepath.Join(dir, "whisper"), "encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var c sherpa.OfflineRecognizerConfig
|
|
c.FeatConfig = sherpa.FeatureConfig{SampleRate: Rate, FeatureDim: 128}
|
|
c.ModelConfig.Whisper = sherpa.OfflineWhisperModelConfig{
|
|
Encoder: f[0], Decoder: f[1], Task: "transcribe",
|
|
TailPaddings: -1, EnableTokenTimestamps: 1,
|
|
}
|
|
c.ModelConfig.Tokens = f[2]
|
|
c.ModelConfig.ModelType = "whisper"
|
|
c.ModelConfig.NumThreads = threads()
|
|
c.ModelConfig.Provider = "cpu"
|
|
c.DecodingMethod = "greedy_search"
|
|
return open(c, true)
|
|
}
|
|
|
|
func open(c sherpa.OfflineRecognizerConfig, whisper bool) (*recognizer, error) {
|
|
rec := sherpa.NewOfflineRecognizer(&c)
|
|
if rec == nil {
|
|
return nil, fmt.Errorf("sherpa refused the recognizer config")
|
|
}
|
|
return &recognizer{rec: rec, cfg: c, whisper: whisper, run: newWorker()}, nil
|
|
}
|
|
|
|
// hear decodes one span. lang matters only to whisper: its language is part of
|
|
// the recognizer's config, not the stream's (v1.13.8 reads no per-stream option
|
|
// for whisper), so it is set and the span decoded inside the same worker turn —
|
|
// nothing else can run between the two. lang must already be a code whisper
|
|
// knows (language.go); an unknown one makes sherpa exit the process.
|
|
func (r *recognizer) hear(pcm []float32, lang string) (heard, error) {
|
|
var out heard
|
|
r.run.do(func() {
|
|
if r.whisper && r.cfg.ModelConfig.Whisper.Language != lang {
|
|
r.cfg.ModelConfig.Whisper.Language = lang
|
|
r.rec.SetConfig(&r.cfg)
|
|
}
|
|
s := sherpa.NewOfflineStream(r.rec)
|
|
defer sherpa.DeleteOfflineStream(s)
|
|
s.AcceptWaveform(Rate, padded(pcm))
|
|
r.rec.Decode(s)
|
|
res := s.GetResult()
|
|
out = heard{text: strings.TrimSpace(res.Text), words: words(res.Tokens, res.Timestamps, res.Durations, r.whisper)}
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// words groups tokens into timed words.
|
|
//
|
|
// A word starts where a token starts with a space (whisper's BPE) or "▁"
|
|
// (Parakeet's SentencePiece). Scripts written without spaces — Han, kana, Thai
|
|
// and their neighbours — have no such marker, so each of their tokens is a word
|
|
// of its own, which is how whisper's own word timing splits them.
|
|
//
|
|
// A token ends where its duration says, or where the next one begins when the
|
|
// model gives no duration. No timestamps at all means the model timed nothing,
|
|
// and the answer is no words rather than invented ones.
|
|
func words(tokens []string, at, dur []float32, whisper bool) []word {
|
|
if len(tokens) == 0 || len(at) != len(tokens) {
|
|
return nil
|
|
}
|
|
end := func(i int) float64 {
|
|
if len(dur) == len(tokens) && dur[i] > 0 {
|
|
return float64(at[i] + dur[i])
|
|
}
|
|
if i+1 < len(at) {
|
|
return float64(at[i+1])
|
|
}
|
|
return float64(at[i])
|
|
}
|
|
var out []word
|
|
for i, t := range tokens {
|
|
if whisper && strings.HasPrefix(t, "<|") {
|
|
continue // a special token: language, task, timestamp
|
|
}
|
|
bare := strings.TrimLeft(t, " ▁")
|
|
if bare == "" {
|
|
continue
|
|
}
|
|
fresh := len(out) == 0 || strings.HasPrefix(t, " ") || strings.HasPrefix(t, "▁") || unspaced(bare) || unspaced(out[len(out)-1].Word)
|
|
if fresh {
|
|
out = append(out, word{Word: bare, Start: float64(at[i]), End: end(i)})
|
|
continue
|
|
}
|
|
w := &out[len(out)-1]
|
|
w.Word += bare
|
|
w.End = end(i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unspaced reports whether s is written in a script that does not put spaces
|
|
// between words.
|
|
func unspaced(s string) bool {
|
|
for _, r := range s {
|
|
if unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Thai, unicode.Lao, unicode.Myanmar, unicode.Khmer) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// identifier is spoken language identification on a small whisper (tiny,
|
|
// int8): one encoder pass and one decoder step, a fraction of a decode.
|
|
type identifier struct {
|
|
lid *sherpa.SpokenLanguageIdentification
|
|
run worker
|
|
}
|
|
|
|
func newIdentifier(dir string) (*identifier, error) {
|
|
f, err := files(filepath.Join(dir, "lid"), "encoder.int8.onnx", "decoder.int8.onnx")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c := sherpa.SpokenLanguageIdentificationConfig{
|
|
Whisper: sherpa.SpokenLanguageIdentificationWhisperConfig{Encoder: f[0], Decoder: f[1], TailPaddings: -1},
|
|
NumThreads: threads(),
|
|
Provider: "cpu",
|
|
}
|
|
lid := sherpa.NewSpokenLanguageIdentification(&c)
|
|
if lid == nil {
|
|
return nil, fmt.Errorf("sherpa refused the language identifier config")
|
|
}
|
|
return &identifier{lid: lid, run: newWorker()}, nil
|
|
}
|
|
|
|
func (l *identifier) identify(pcm []float32) (string, error) {
|
|
var lang string
|
|
l.run.do(func() {
|
|
s := l.lid.CreateStream()
|
|
defer sherpa.DeleteOfflineStream(s)
|
|
s.AcceptWaveform(Rate, padded(pcm))
|
|
lang = l.lid.Compute(s).Lang
|
|
})
|
|
if lang == "" {
|
|
return "", fmt.Errorf("language identification returned nothing")
|
|
}
|
|
return lang, nil
|
|
}
|
|
|
|
// kokoro is Kokoro-82M multi-lang v1.0: 54 voices, one model, the language of
|
|
// each request taken from its voice and passed per call.
|
|
type kokoro struct {
|
|
tts *sherpa.OfflineTts
|
|
run worker
|
|
}
|
|
|
|
func newKokoro(dir string) (*kokoro, error) {
|
|
f, err := files(filepath.Join(dir, "kokoro"), "model.onnx", "voices.bin", "tokens.txt", "lexicon-zh.txt", "espeak-ng-data")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Han characters are read through the Chinese lexicon; everything else goes
|
|
// through espeak-ng in the language the request passes (sherpa 1.13.8 takes
|
|
// that path whenever a language is set, and the model's own default is
|
|
// en-us, so it always is). That is why only the Chinese lexicon ships.
|
|
var c sherpa.OfflineTtsConfig
|
|
c.Model.Kokoro = sherpa.OfflineTtsKokoroModelConfig{
|
|
Model: f[0], Voices: f[1], Tokens: f[2], Lexicon: f[3], DataDir: f[4], LengthScale: 1.0,
|
|
}
|
|
c.Model.NumThreads = threads()
|
|
c.Model.Provider = "cpu"
|
|
c.MaxNumSentences = 1
|
|
c.SilenceScale = 0.2
|
|
t := sherpa.NewOfflineTts(&c)
|
|
if t == nil {
|
|
return nil, fmt.Errorf("sherpa refused the kokoro config")
|
|
}
|
|
if n := t.NumSpeakers(); n != len(kokoroVoices) {
|
|
return nil, fmt.Errorf("kokoro has %d voices; this build names %d", n, len(kokoroVoices))
|
|
}
|
|
if r := t.SampleRate(); r != Spoken {
|
|
return nil, fmt.Errorf("kokoro speaks at %d Hz; this build expects %d", r, Spoken)
|
|
}
|
|
return &kokoro{tts: t, run: newWorker()}, nil
|
|
}
|
|
|
|
func (k *kokoro) speak(text, voice string, speed float32) ([]float32, error) {
|
|
sid, ok := kokoroID[voice]
|
|
if !ok {
|
|
return nil, fmt.Errorf("kokoro has no voice %q", voice)
|
|
}
|
|
extra, _ := json.Marshal(map[string]string{"lang": kokoroLang(voice)})
|
|
var out *sherpa.GeneratedAudio
|
|
k.run.do(func() {
|
|
out = k.tts.GenerateWithConfig(text, &sherpa.GenerationConfig{Sid: sid, Speed: speed, SilenceScale: 0.2, Extra: extra}, nil)
|
|
})
|
|
if out == nil || len(out.Samples) == 0 {
|
|
return nil, fmt.Errorf("kokoro produced no audio for this input")
|
|
}
|
|
return out.Samples, nil
|
|
}
|
|
|
|
// silero is the voice activity detector, used two ways: as a session's squelch
|
|
// (one detector per session, fed push by push, so it remembers the room) and as
|
|
// a segmenter that cuts a span of audio at its pauses.
|
|
type silero struct{ path string }
|
|
|
|
func newSilero(dir string) (*silero, error) {
|
|
p, err := must(filepath.Join(dir, "vad", "silero_vad.onnx"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &silero{path: p}, nil
|
|
}
|
|
|
|
// window is silero's own: 512 samples, 32 ms at 16 kHz. Not a choice.
|
|
const window = 512
|
|
|
|
func (s *silero) detector(silence, longest float32, buffer float32) *sherpa.VoiceActivityDetector {
|
|
c := sherpa.VadModelConfig{
|
|
SileroVad: sherpa.SileroVadModelConfig{
|
|
Model: s.path, Threshold: Open, MinSilenceDuration: silence,
|
|
MinSpeechDuration: 0, WindowSize: window, MaxSpeechDuration: longest,
|
|
},
|
|
SampleRate: Rate, NumThreads: 1, Provider: "cpu",
|
|
}
|
|
return sherpa.NewVoiceActivityDetector(&c, buffer)
|
|
}
|
|
|
|
// gate is one session's squelch detector. silero's own state machine is the
|
|
// squelch's: it opens on a window above Open (0.5), holds through anything above
|
|
// Open-0.15 (0.35, the same distance apart as the thresholds it replaced), and
|
|
// closes after Hang of quiet. MaxSpeechDuration is the session's whole limit so
|
|
// it never switches to its split-forcing thresholds mid-sentence.
|
|
type sileroGate struct{ vad *sherpa.VoiceActivityDetector }
|
|
|
|
func (s *silero) gate() gate {
|
|
return &sileroGate{vad: s.detector(Hang, Limit+1, 30)}
|
|
}
|
|
|
|
// voiced feeds one push and reports whether the squelch was open during any
|
|
// window of it — the same "any window" rule the detector applies inside one call.
|
|
// A push that is not a whole number of windows leaves its tail in the detector,
|
|
// which scores it with the next push rather than padding it with silence that
|
|
// was never in the room. A 256 ms push at this rate is exactly eight windows.
|
|
func (g *sileroGate) voiced(pcm []float32) bool {
|
|
if len(pcm) == 0 {
|
|
return false
|
|
}
|
|
g.vad.AcceptWaveform(pcm)
|
|
open := g.vad.IsSpeech()
|
|
g.vad.Clear() // closed utterances queue inside the detector; nothing reads them here
|
|
return open
|
|
}
|
|
|
|
func (g *sileroGate) close() { sherpa.DeleteVoiceActivityDetector(g.vad) }
|
|
|
|
// regions cuts audio at its pauses: the spans silero calls speech, in samples.
|
|
// A pause is Pause of quiet. A span longer than Longest is split by silero's own
|
|
// rule (it raises its threshold to force a cut) and, failing that, by length in
|
|
// regions' caller, because whisper decodes at most thirty seconds at a time.
|
|
func (s *silero) regions(pcm []float32) []span {
|
|
if len(pcm) == 0 {
|
|
return nil
|
|
}
|
|
v := s.detector(float32(Pause), float32(Longest), 60)
|
|
defer sherpa.DeleteVoiceActivityDetector(v)
|
|
var out []span
|
|
drain := func() {
|
|
for !v.IsEmpty() {
|
|
f := v.Front()
|
|
out = append(out, span{f.Start, f.Start + len(f.Samples)})
|
|
v.Pop()
|
|
}
|
|
}
|
|
// One window per call: the detector stamps a stretch's start from where the
|
|
// call that opened it ENDED, so a longer call puts every start late by up to
|
|
// its own length — a second-long call cut "The quick" off a sentence.
|
|
for at := 0; at < len(pcm); at += window {
|
|
v.AcceptWaveform(pcm[at:min(at+window, len(pcm))])
|
|
drain()
|
|
}
|
|
v.Flush()
|
|
drain()
|
|
return out
|
|
}
|