265 lines
6.8 KiB
Go
265 lines
6.8 KiB
Go
package whisper
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
whis "github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/sjzar/chatlog/pkg/util/silk"
|
|
)
|
|
|
|
// WhisperCPPConfig controls the on-device whisper.cpp backend.
|
|
type WhisperCPPConfig struct {
|
|
ModelPath string
|
|
Threads int
|
|
DefaultOptions Options
|
|
}
|
|
|
|
// WhisperCPPTranscriber wraps a whisper.cpp model for local transcription.
|
|
type WhisperCPPTranscriber struct {
|
|
model whis.Model
|
|
defaultOptions Options
|
|
defaultThreads int
|
|
}
|
|
|
|
// NewWhisperCPPTranscriber loads a whisper.cpp model for on-device speech recognition.
|
|
func NewWhisperCPPTranscriber(cfg WhisperCPPConfig) (*WhisperCPPTranscriber, error) {
|
|
modelPath := strings.TrimSpace(cfg.ModelPath)
|
|
if modelPath == "" {
|
|
return nil, fmt.Errorf("whisper.cpp model path is empty")
|
|
}
|
|
|
|
model, err := whis.New(modelPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load whisper.cpp model: %w", err)
|
|
}
|
|
|
|
return &WhisperCPPTranscriber{
|
|
model: model,
|
|
defaultOptions: cfg.DefaultOptions,
|
|
defaultThreads: cfg.Threads,
|
|
}, nil
|
|
}
|
|
|
|
// Close releases resources held by the whisper.cpp model.
|
|
func (t *WhisperCPPTranscriber) Close() {
|
|
if t.model != nil {
|
|
if err := t.model.Close(); err != nil {
|
|
log.Debug().Err(err).Msg("whispercpp: model close failed")
|
|
}
|
|
t.model = nil
|
|
}
|
|
}
|
|
|
|
// TranscribePCM runs whisper.cpp against raw PCM samples.
|
|
func (t *WhisperCPPTranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) {
|
|
if t.model == nil {
|
|
return nil, errors.New("whisper.cpp model not initialised")
|
|
}
|
|
merged := t.mergeOptions(opts)
|
|
|
|
if len(samples) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
if sampleRate <= 0 {
|
|
sampleRate = int(whis.SampleRate)
|
|
}
|
|
|
|
processed := resampleIfNeeded(samples, sampleRate, int(whis.SampleRate))
|
|
|
|
ctxInstance, err := t.model.NewContext()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create whisper.cpp context: %w", err)
|
|
}
|
|
|
|
threads := t.defaultThreads
|
|
if merged.ThreadsSet && merged.Threads > 0 {
|
|
threads = merged.Threads
|
|
}
|
|
if threads > 0 {
|
|
ctxInstance.SetThreads(uint(threads))
|
|
}
|
|
|
|
lang := "auto"
|
|
if merged.LanguageSet {
|
|
trimmed := strings.TrimSpace(merged.Language)
|
|
if trimmed != "" {
|
|
lang = trimmed
|
|
}
|
|
}
|
|
if err := ctxInstance.SetLanguage(lang); err != nil {
|
|
log.Warn().Err(err).Str("language", lang).Msg("whispercpp: set language failed")
|
|
}
|
|
|
|
if merged.TranslateSet {
|
|
ctxInstance.SetTranslate(merged.Translate)
|
|
}
|
|
if merged.InitialPromptSet {
|
|
ctxInstance.SetInitialPrompt(merged.InitialPrompt)
|
|
}
|
|
if merged.TemperatureSet {
|
|
ctxInstance.SetTemperature(merged.Temperature)
|
|
}
|
|
if merged.TemperatureFloorSet {
|
|
ctxInstance.SetTemperatureFallback(merged.TemperatureFloor)
|
|
}
|
|
|
|
if err := ctxInstance.Process(processed, nil, nil, nil); err != nil {
|
|
return nil, fmt.Errorf("whisper.cpp process pcm: %w", err)
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
segments []Segment
|
|
builder strings.Builder
|
|
lastEnd time.Duration
|
|
)
|
|
|
|
for {
|
|
seg, err := ctxInstance.NextSegment()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return nil, fmt.Errorf("whisper.cpp next segment: %w", err)
|
|
}
|
|
|
|
text := strings.TrimSpace(seg.Text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
|
|
if builder.Len() > 0 {
|
|
builder.WriteByte(' ')
|
|
}
|
|
builder.WriteString(text)
|
|
|
|
segments = append(segments, Segment{
|
|
ID: seg.Num,
|
|
Start: seg.Start,
|
|
End: seg.End,
|
|
Text: text,
|
|
})
|
|
if seg.End > lastEnd {
|
|
lastEnd = seg.End
|
|
}
|
|
}
|
|
|
|
detected := strings.TrimSpace(ctxInstance.DetectedLanguage())
|
|
if detected == "" {
|
|
detected = fallbackLanguage(merged, merged.TranslateSet && merged.Translate)
|
|
}
|
|
|
|
return &Result{
|
|
Text: strings.TrimSpace(builder.String()),
|
|
Language: detected,
|
|
Duration: lastEnd,
|
|
Segments: segments,
|
|
}, nil
|
|
}
|
|
|
|
// TranscribeSilk decodes SILK payloads before invoking whisper.cpp.
|
|
func (t *WhisperCPPTranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) {
|
|
if len(silkData) == 0 {
|
|
return nil, nil
|
|
}
|
|
samples16, sampleRate, err := silk.Silk2PCM16(silkData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
floatSamples := make([]float32, len(samples16))
|
|
const scale = 1.0 / 32768.0
|
|
for i, sample := range samples16 {
|
|
floatSamples[i] = float32(float64(sample) * scale)
|
|
}
|
|
|
|
return t.TranscribePCM(ctx, floatSamples, sampleRate, opts)
|
|
}
|
|
|
|
func (t *WhisperCPPTranscriber) mergeOptions(overrides Options) Options {
|
|
merged := t.defaultOptions
|
|
|
|
if overrides.LanguageSet {
|
|
merged.Language = overrides.Language
|
|
merged.LanguageSet = true
|
|
}
|
|
if overrides.TranslateSet {
|
|
merged.Translate = overrides.Translate
|
|
merged.TranslateSet = true
|
|
}
|
|
if overrides.ThreadsSet {
|
|
merged.Threads = overrides.Threads
|
|
merged.ThreadsSet = true
|
|
}
|
|
if overrides.InitialPromptSet {
|
|
merged.InitialPrompt = overrides.InitialPrompt
|
|
merged.InitialPromptSet = true
|
|
}
|
|
if overrides.TemperatureSet {
|
|
merged.Temperature = overrides.Temperature
|
|
merged.TemperatureSet = true
|
|
}
|
|
if overrides.TemperatureFloorSet {
|
|
merged.TemperatureFloor = overrides.TemperatureFloor
|
|
merged.TemperatureFloorSet = true
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
func resampleIfNeeded(samples []float32, fromRate, toRate int) []float32 {
|
|
if fromRate <= 0 {
|
|
fromRate = toRate
|
|
}
|
|
if fromRate == toRate || len(samples) == 0 {
|
|
dst := make([]float32, len(samples))
|
|
copy(dst, samples)
|
|
return dst
|
|
}
|
|
|
|
ratio := float64(fromRate) / float64(toRate)
|
|
if ratio <= 0 {
|
|
dst := make([]float32, len(samples))
|
|
copy(dst, samples)
|
|
return dst
|
|
}
|
|
|
|
outLen := int(math.Ceil(float64(len(samples)) / ratio))
|
|
if outLen <= 0 {
|
|
outLen = len(samples)
|
|
}
|
|
|
|
dst := make([]float32, outLen)
|
|
for i := range dst {
|
|
srcPos := float64(i) * ratio
|
|
idx := int(math.Floor(srcPos))
|
|
frac := srcPos - float64(idx)
|
|
|
|
if idx >= len(samples)-1 {
|
|
dst[i] = samples[len(samples)-1]
|
|
continue
|
|
}
|
|
|
|
a := samples[idx]
|
|
b := samples[idx+1]
|
|
dst[i] = float32(float64(a)*(1-frac) + float64(b)*frac)
|
|
}
|
|
|
|
return dst
|
|
} |