306 lines
7.4 KiB
Go
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"`
|
|
}
|