feat: models test and fixes

This commit is contained in:
leonardotrapani
2026-02-02 01:13:33 +01:00
parent 2729882b9d
commit 195f9f5115
34 changed files with 507 additions and 154 deletions
+25 -1
View File
@@ -36,6 +36,7 @@ type DeepgramAdapter struct {
// finalization signaling
finalizeDone chan struct{}
finalizing bool // true when Finalize() has been called
}
// deepgramCloseStream message to signal end of audio
@@ -235,7 +236,8 @@ func (a *DeepgramAdapter) buildURL() (string, error) {
q.Set("language", lang)
}
if len(a.keywords) > 0 {
// nova-3 uses "keyterm" (singular), others use "keywords" (plural)
if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") {
q.Set("keywords", strings.Join(a.keywords, ","))
}
@@ -277,6 +279,20 @@ func (a *DeepgramAdapter) readLoop() {
default:
}
// check if we're finalizing - normal close after finalize is expected
a.mu.Lock()
finalizing := a.finalizing
a.mu.Unlock()
if finalizing {
// expected close after finalization, signal done and exit gracefully
select {
case a.finalizeDone <- struct{}{}:
default:
}
return
}
// attempt reconnection
log.Printf("deepgram: read error: %v, attempting reconnection", err)
if !a.reconnect() {
@@ -411,6 +427,11 @@ func (a *DeepgramAdapter) Finalize(ctx context.Context) error {
default:
}
// mark as finalizing to prevent reconnection attempts on normal close
a.mu.Lock()
a.finalizing = true
a.mu.Unlock()
// send CloseStream message
msg := deepgramCloseStream{Type: "CloseStream"}
@@ -447,6 +468,9 @@ func (a *DeepgramAdapter) Close() error {
return nil
}
// mark as finalizing to prevent reconnection attempts
a.finalizing = true
// cancel context first to signal reader to stop
if a.cancel != nil {
a.cancel()
+15 -4
View File
@@ -49,21 +49,31 @@ func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, l
// Transcribe sends audio data to Deepgram's pre-recorded API
func (a *DeepgramBatchAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if len(audioData) == 0 {
return "", nil
}
// convert raw PCM to WAV format
wavData, err := convertToWAV(audioData)
if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err)
}
// build URL with query parameters
apiURL, err := a.buildURL()
if err != nil {
return "", fmt.Errorf("build url: %w", err)
}
// create request with audio data as body
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(audioData))
// create request with WAV data as body
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(wavData))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
// set headers
req.Header.Set("Authorization", "Token "+a.apiKey)
req.Header.Set("Content-Type", "audio/wav") // we send raw PCM wrapped as WAV
req.Header.Set("Content-Type", "audio/wav")
// send request
resp, err := http.DefaultClient.Do(req)
@@ -123,7 +133,8 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) {
q.Set("language", lang)
}
if len(a.keywords) > 0 {
// nova-3 uses "keyterm" (singular), others use "keywords" (plural)
if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") {
q.Set("keywords", strings.Join(a.keywords, ","))
}
+6 -7
View File
@@ -82,13 +82,12 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s
}
}
if len(a.keywords) > 0 {
keytermsJSON, err := json.Marshal(a.keywords)
if err != nil {
return "", fmt.Errorf("marshal keyterms: %w", err)
}
if err := writer.WriteField("keyterms", string(keytermsJSON)); err != nil {
return "", fmt.Errorf("write keyterms: %w", err)
// keyterms only supported on scribe_v2, not scribe_v1
if a.model != "scribe_v1" {
for _, keyword := range a.keywords {
if err := writer.WriteField("keyterms", keyword); err != nil {
return "", fmt.Errorf("write keyterms: %w", err)
}
}
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -242,6 +243,9 @@ func (a *ElevenLabsStreamingAdapter) readLoop() {
_, message, err := conn.ReadMessage()
if err != nil {
if a.handleFatalClose(err) {
return
}
// check if context was cancelled (normal shutdown)
select {
case <-a.ctx.Done():
@@ -291,21 +295,92 @@ func (a *ElevenLabsStreamingAdapter) readLoop() {
case "error", "auth_error", "quota_exceeded", "rate_limited",
"queue_overflow", "resource_exhausted", "session_time_limit_exceeded",
"input_error", "chunk_size_exceeded", "insufficient_audio_activity",
"transcriber_error", "commit_throttled", "unaccepted_terms":
"transcriber_error", "commit_throttled", "unaccepted_terms", "invalid_request":
// error message
errMsg := msg.Error
if errMsg == "" {
errMsg = msg.MessageType
}
log.Printf("elevenlabs-streaming: error: %s", errMsg)
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("elevenlabs: %s", errMsg)}
err := fmt.Errorf("elevenlabs: %s", errMsg)
if isElevenLabsFatalMessageType(msg.MessageType) {
a.handleFatalError(err)
return
}
a.emitResultError(err)
default:
log.Printf("elevenlabs-streaming: unknown message type: %s", msg.MessageType)
log.Printf("elevenlabs-streaming: unknown message type: %s payload=%s", msg.MessageType, strings.TrimSpace(string(message)))
}
}
}
func (a *ElevenLabsStreamingAdapter) emitResultError(err error) {
select {
case a.resultsCh <- TranscriptionResult{Error: err}:
default:
}
}
func (a *ElevenLabsStreamingAdapter) handleFatalError(err error) {
fatalErr := NewFatalTranscriptionError(err)
log.Printf("elevenlabs-streaming: fatal error: %v", err)
a.emitResultError(fatalErr)
a.closeConn()
if a.cancel != nil {
a.cancel()
}
}
func (a *ElevenLabsStreamingAdapter) handleFatalClose(err error) bool {
var closeErr *websocket.CloseError
if !errors.As(err, &closeErr) {
return false
}
if !isElevenLabsFatalCloseCode(closeErr.Code) {
return false
}
reason := strings.TrimSpace(closeErr.Text)
if reason == "" {
reason = "no reason provided"
}
a.handleFatalError(fmt.Errorf("elevenlabs websocket closed (%d): %s", closeErr.Code, reason))
return true
}
func (a *ElevenLabsStreamingAdapter) closeConn() {
a.mu.Lock()
conn := a.conn
a.conn = nil
a.mu.Unlock()
if conn != nil {
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = conn.Close()
}
}
func isElevenLabsFatalCloseCode(code int) bool {
switch code {
case websocket.ClosePolicyViolation,
websocket.CloseUnsupportedData,
websocket.CloseInvalidFramePayloadData,
websocket.CloseMessageTooBig,
websocket.CloseProtocolError:
return true
default:
return false
}
}
func isElevenLabsFatalMessageType(messageType string) bool {
switch messageType {
case "auth_error", "unaccepted_terms", "invalid_request", "input_error", "chunk_size_exceeded":
return true
default:
return false
}
}
// SendChunk sends audio data to the WebSocket
func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error {
a.mu.Lock()
+34
View File
@@ -0,0 +1,34 @@
package transcriber
import "errors"
// FatalTranscriptionError marks an error as non-recoverable for the current session.
type FatalTranscriptionError struct {
Err error
}
func (e *FatalTranscriptionError) Error() string {
if e == nil || e.Err == nil {
return "fatal transcription error"
}
return e.Err.Error()
}
func (e *FatalTranscriptionError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func NewFatalTranscriptionError(err error) error {
if err == nil {
return nil
}
return &FatalTranscriptionError{Err: err}
}
func IsFatalTranscriptionError(err error) bool {
var fatal *FatalTranscriptionError
return errors.As(err, &fatal)
}
+60 -1
View File
@@ -2,6 +2,7 @@ package transcriber
import (
"context"
"errors"
"log"
"strings"
"sync"
@@ -19,6 +20,7 @@ type StreamingTranscriber struct {
// accumulated final text
finalText strings.Builder
mu sync.Mutex
fatalErr error
// coordination
ctx context.Context
@@ -66,6 +68,24 @@ func (t *StreamingTranscriber) sendAudio(frameCh <-chan recording.AudioFrame, er
return
}
if err := t.adapter.SendChunk(frame.Data); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
if t.ctx.Err() == nil && t.cancel != nil {
t.cancel()
}
return
}
if IsFatalTranscriptionError(err) {
if t.setFatalErr(err) {
select {
case errCh <- err:
default:
}
}
if t.cancel != nil {
t.cancel()
}
return
}
select {
case errCh <- err:
default:
@@ -98,6 +118,19 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
func (t *StreamingTranscriber) processResult(result TranscriptionResult, errCh chan<- error) {
if result.Error != nil {
if IsFatalTranscriptionError(result.Error) {
if t.setFatalErr(result.Error) {
select {
case errCh <- result.Error:
default:
}
}
log.Printf("streaming transcriber: result error: %v", result.Error)
if t.cancel != nil {
t.cancel()
}
return
}
select {
case errCh <- result.Error:
default:
@@ -154,11 +187,37 @@ func (t *StreamingTranscriber) Stop(ctx context.Context) error {
t.wg.Wait()
// close the adapter
return t.adapter.Close()
closeErr := t.adapter.Close()
if fatalErr := t.getFatalErr(); fatalErr != nil {
return fatalErr
}
return closeErr
}
func (t *StreamingTranscriber) GetFinalTranscription() (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.fatalErr != nil {
return "", t.fatalErr
}
return t.finalText.String(), nil
}
func (t *StreamingTranscriber) setFatalErr(err error) bool {
if err == nil {
return false
}
t.mu.Lock()
defer t.mu.Unlock()
if t.fatalErr != nil {
return false
}
t.fatalErr = err
return true
}
func (t *StreamingTranscriber) getFatalErr() error {
t.mu.Lock()
defer t.mu.Unlock()
return t.fatalErr
}
+7 -5
View File
@@ -83,14 +83,16 @@ func NewTranscriber(config Config) (Transcriber, error) {
config.Language = ""
}
// determine if we should use streaming mode
useStreaming := config.Streaming && model.SupportsStreaming
// fail if streaming-only model is used without streaming enabled
if !useStreaming && !model.SupportsBatch {
// validate streaming/batch mode compatibility
if config.Streaming && !model.SupportsStreaming {
return nil, fmt.Errorf("model %s does not support streaming mode", model.ID)
}
if !config.Streaming && !model.SupportsBatch {
return nil, fmt.Errorf("model %s requires streaming mode (set streaming = true in config)", model.ID)
}
useStreaming := config.Streaming
// streaming mode: use StreamingTranscriber
if useStreaming {
// pick the right adapter type for streaming
+11
View File
@@ -145,6 +145,17 @@ func TestNewTranscriber(t *testing.T) {
},
wantErr: false,
},
{
name: "elevenlabs batch model with streaming enabled fails",
config: Config{
Provider: "elevenlabs",
APIKey: "test-key",
Language: "en",
Model: "scribe_v2",
Streaming: true,
},
wantErr: true,
},
{
name: "deepgram streaming model creates StreamingTranscriber",
config: Config{