Files

306 lines
7.4 KiB
Go

package whisper
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/sjzar/chatlog/pkg/util/silk"
)
// WebServiceConfig controls the behaviour of the HTTP Whisper webservice backend.
type WebServiceConfig struct {
BaseURL string
OutputFormat string
WordTimestamps bool
VADFilter bool
Encode *bool
Diarize bool
MinSpeakers int
MaxSpeakers int
RequestTimeout time.Duration
DefaultOptions Options
}
// WebServiceTranscriber implements transcription against a HTTP whisper-asr-webservice instance.
type WebServiceTranscriber struct {
client *http.Client
cfg WebServiceConfig
baseURL string
}
// Close releases resources held by the webservice transcriber. No-op for HTTP client.
func (t *WebServiceTranscriber) Close() {}
// NewWebServiceTranscriber constructs a transcriber for whisper-asr-webservice.
func NewWebServiceTranscriber(cfg WebServiceConfig) (*WebServiceTranscriber, error) {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
if baseURL == "" {
return nil, fmt.Errorf("webservice base URL cannot be empty")
}
httpClient := &http.Client{}
if cfg.RequestTimeout > 0 {
httpClient.Timeout = cfg.RequestTimeout
}
if cfg.OutputFormat == "" {
cfg.OutputFormat = "json"
}
return &WebServiceTranscriber{
client: httpClient,
cfg: cfg,
baseURL: baseURL,
}, nil
}
// TranscribePCM handles PCM16 input.
func (t *WebServiceTranscriber) TranscribePCM(ctx context.Context, samples []float32, sampleRate int, opts Options) (*Result, error) {
merged := t.mergeOptions(opts)
if len(samples) == 0 {
return nil, nil
}
if sampleRate <= 0 {
sampleRate = 24000
}
pcm := float32ToPCM16(samples)
wav, err := encodePCM16AsWAV(pcm, sampleRate)
if err != nil {
return nil, err
}
duration := pcmDuration(len(pcm), sampleRate)
return t.transcribeWAV(ctx, wav, duration, merged)
}
// TranscribeSilk handles SILK input by converting it into PCM before forwarding to PCM handler.
func (t *WebServiceTranscriber) TranscribeSilk(ctx context.Context, silkData []byte, opts Options) (*Result, error) {
merged := t.mergeOptions(opts)
if len(silkData) == 0 {
return nil, nil
}
samples, sampleRate, err := silk.Silk2PCM16(silkData)
if err != nil {
return nil, err
}
wav, err := encodePCM16AsWAV(samples, sampleRate)
if err != nil {
return nil, err
}
duration := pcmDuration(len(samples), sampleRate)
return t.transcribeWAV(ctx, wav, duration, merged)
}
func (t *WebServiceTranscriber) transcribeWAV(ctx context.Context, wav []byte, fallbackDuration time.Duration, opts Options) (*Result, error) {
if ctx == nil {
ctx = context.Background()
}
requestURL, err := t.buildRequestURL(opts)
if err != nil {
return nil, err
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
fileWriter, err := writer.CreateFormFile("audio_file", "audio.wav")
if err != nil {
return nil, err
}
if _, err = io.Copy(fileWriter, bytes.NewReader(wav)); err != nil {
return nil, err
}
if t.cfg.Encode != nil {
if err := writer.WriteField("encode", strconv.FormatBool(*t.cfg.Encode)); err != nil {
return nil, err
}
}
if t.cfg.Diarize {
if err := writer.WriteField("diarize", "true"); err != nil {
return nil, err
}
if t.cfg.MinSpeakers > 0 {
if err := writer.WriteField("min_speakers", strconv.Itoa(t.cfg.MinSpeakers)); err != nil {
return nil, err
}
}
if t.cfg.MaxSpeakers > 0 {
if err := writer.WriteField("max_speakers", strconv.Itoa(t.cfg.MaxSpeakers)); err != nil {
return nil, err
}
}
}
if err = writer.Close(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if len(raw) == 0 {
return nil, fmt.Errorf("webservice returned status %d", resp.StatusCode)
}
return nil, fmt.Errorf("webservice error (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
if t.cfg.OutputFormat != "json" {
// Only JSON can be mapped into the common Result structure for now.
return &Result{Text: string(raw), Duration: fallbackDuration}, nil
}
var payload webServiceResponse
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("decode webservice response: %w", err)
}
result := &Result{
Text: strings.TrimSpace(payload.Text),
Language: strings.TrimSpace(payload.Language),
}
var maxSegmentEnd float64
for _, seg := range payload.Segments {
segment := Segment{
Start: time.Duration(seg.Start * float64(time.Second)),
End: time.Duration(seg.End * float64(time.Second)),
Text: seg.Text,
}
result.Segments = append(result.Segments, segment)
if seg.End > maxSegmentEnd {
maxSegmentEnd = seg.End
}
}
if maxSegmentEnd > 0 {
result.Duration = time.Duration(maxSegmentEnd * float64(time.Second))
} else {
result.Duration = fallbackDuration
}
if result.Duration == 0 {
result.Duration = fallbackDuration
}
translated := opts.TranslateSet && opts.Translate
if result.Language == "" {
result.Language = fallbackLanguage(opts, translated)
}
result.Text = strings.TrimSpace(result.Text)
return result, nil
}
func (t *WebServiceTranscriber) buildRequestURL(opts Options) (string, error) {
if t.baseURL == "" {
return "", fmt.Errorf("webservice base URL is empty")
}
target, err := url.Parse(t.baseURL)
if err != nil {
return "", fmt.Errorf("parse base URL: %w", err)
}
trimmedPath := strings.TrimSuffix(target.Path, "/")
target.Path = trimmedPath + "/asr"
query := target.Query()
query.Set("output", t.cfg.OutputFormat)
if opts.TranslateSet {
if opts.Translate {
query.Set("task", "translate")
} else {
query.Set("task", "transcribe")
}
}
if opts.LanguageSet && opts.Language != "" {
query.Set("language", opts.Language)
}
if t.cfg.WordTimestamps {
query.Set("word_timestamps", "true")
}
if t.cfg.VADFilter {
query.Set("vad_filter", "true")
}
target.RawQuery = query.Encode()
return target.String(), nil
}
func (t *WebServiceTranscriber) mergeOptions(overrides Options) Options {
merged := t.cfg.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
}
// webServiceResponse models the JSON payload returned by whisper-asr-webservice.
type webServiceResponse struct {
Text string `json:"text"`
Segments []webServiceSegment `json:"segments"`
Language string `json:"language"`
}
type webServiceSegment struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}