Files
record-asr/internal/transcribe/pipeline.go
T

191 lines
5.5 KiB
Go

// Package transcribe ties the silero VAD and the SenseVoice recognizer together
// into a single "audio file -> transcript" pipeline.
//
// The VAD is stateful, so segmentation of one file cannot interleave with
// another; the manager guards VAD access with a mutex. Recognition of the
// resulting speech segments is stateless and could be parallelized.
package transcribe
import (
"fmt"
"strings"
sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
"git.misaka.ren/m1saka/record-asr/internal/audio"
"git.misaka.ren/m1saka/record-asr/internal/models"
)
// Segment is one VAD-detected speech region with its transcript.
type Segment struct {
Text string `json:"text"`
Start float64 `json:"start"` // seconds from file start
End float64 `json:"end"` // seconds from file start
Lang string `json:"lang"`
}
// Result is the full transcript for one audio file.
type Result struct {
Text string `json:"text"` // all segments joined by newline
Lang string `json:"lang"` // dominant detected language
Emotion string `json:"emotion"` // dominant detected emotion
Duration float64 `json:"duration"` // audio duration in seconds
Segments []Segment `json:"segments"`
}
// New returns a Pipeline bound to the given model manager.
func New(m *models.Manager) *Pipeline {
return &Pipeline{m: m}
}
// Pipeline wraps a model manager to provide file transcription.
type Pipeline struct {
m *models.Manager
}
// Transcribe decodes the audio file at path, runs VAD to isolate speech, and
// recognizes each segment with SenseVoice.
func (p *Pipeline) Transcribe(path string) (*Result, error) {
samples, sampleRate, err := audio.Decode(path)
if err != nil {
return nil, err
}
if sampleRate != audio.TargetSampleRate {
return nil, fmt.Errorf("internal: expected 16kHz samples, got %d", sampleRate)
}
duration := float64(len(samples)) / float64(audio.TargetSampleRate)
segments, err := p.segment(samples)
if err != nil {
return nil, err
}
res := &Result{
Duration: duration,
Segments: make([]Segment, 0, len(segments)),
}
var texts []string
for _, seg := range segments {
// Recognize, splitting extremely long segments (should not happen
// given the VAD MaxSpeechDuration cap, but guards against pathological
// inputs that exceed SenseVoice's ~30s comfort window).
parts := splitForRecognize(seg.Samples, 28*audio.TargetSampleRate)
var segTexts []string
var segLang, segEmotion string
segStart := float64(seg.Start) / float64(audio.TargetSampleRate)
for _, part := range parts {
txt, lang, emo, perr := p.recognize(part)
if perr != nil {
return nil, fmt.Errorf("recognize segment at %.2fs: %w", segStart, perr)
}
if txt != "" {
segTexts = append(segTexts, txt)
}
if segLang == "" {
segLang, segEmotion = lang, emo
}
}
segText := strings.Join(segTexts, " ")
segEnd := segStart + float64(len(seg.Samples))/float64(audio.TargetSampleRate)
if segText == "" {
segText = " "
}
res.Segments = append(res.Segments, Segment{
Text: segText,
Start: segStart,
End: segEnd,
Lang: segLang,
})
texts = append(texts, segText)
if res.Lang == "" {
res.Lang, res.Emotion = segLang, segEmotion
}
}
res.Text = strings.Join(texts, "\n")
return res, nil
}
// segment runs the silero VAD over the full sample buffer and returns the
// completed speech segments. The VAD is stateful and shared, so this is the
// only place that touches it, under the manager's lock, and it resets the
// detector before use so no state leaks between files.
func (p *Pipeline) segment(samples []float32) ([]sherpa.SpeechSegment, error) {
if p.m.VAD == nil {
return nil, fmt.Errorf("VAD not loaded")
}
p.m.VadMu().Lock()
defer p.m.VadMu().Unlock()
vad := p.m.VAD
vad.Reset()
// Feed in window-sized chunks; drain completed segments after each feed.
w := 512 // silero window at 16kHz
if len(samples) > 0 && w > len(samples) {
w = len(samples)
}
var out []sherpa.SpeechSegment
drain := func() {
for !vad.IsEmpty() {
seg := vad.Front()
if seg == nil {
break
}
// Copy because Front()'s backing memory is freed on Pop().
cp := sherpa.SpeechSegment{Start: seg.Start, Samples: append([]float32(nil), seg.Samples...)}
out = append(out, cp)
vad.Pop()
}
}
for i := 0; i < len(samples); i += w {
end := i + w
if end > len(samples) {
end = len(samples)
}
vad.AcceptWaveform(samples[i:end])
drain()
}
vad.Flush()
drain()
return out, nil
}
// recognize runs SenseVoice over a single contiguous speech chunk.
func (p *Pipeline) recognize(samples []float32) (text, lang, emotion string, err error) {
if len(samples) == 0 {
return "", "", "", nil
}
if p.m.Recognizer == nil {
return "", "", "", fmt.Errorf("recognizer not loaded")
}
stream := sherpa.NewOfflineStream(p.m.Recognizer)
if stream == nil {
return "", "", "", fmt.Errorf("create offline stream failed")
}
defer sherpa.DeleteOfflineStream(stream)
stream.AcceptWaveform(audio.TargetSampleRate, samples)
p.m.Recognizer.Decode(stream)
r := stream.GetResult()
if r == nil {
return "", "", "", nil
}
return r.Text, r.Lang, r.Emotion, nil
}
// splitForRecognize breaks a sample slice into chunks no longer than maxLen to
// stay within the recognizer's comfortable input window.
func splitForRecognize(samples []float32, maxLen int) [][]float32 {
if maxLen <= 0 || len(samples) <= maxLen {
return [][]float32{samples}
}
var parts [][]float32
for i := 0; i < len(samples); i += maxLen {
end := i + maxLen
if end > len(samples) {
end = len(samples)
}
parts = append(parts, samples[i:end])
}
return parts
}