feat: models test and fixes
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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, ","))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user