531 lines
16 KiB
Go
531 lines
16 KiB
Go
package whisperx
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHTTPClientTranscribeSuccess(t *testing.T) {
|
|
var gotLanguage string
|
|
var gotFileField string
|
|
var gotFileSize int
|
|
var gotFileData string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Fatalf("method = %s, want POST", r.Method)
|
|
}
|
|
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
|
t.Fatalf("ParseMultipartForm() error = %v", err)
|
|
}
|
|
|
|
gotLanguage = r.FormValue("language")
|
|
file, fh, err := r.FormFile("file")
|
|
if err != nil {
|
|
t.Fatalf("FormFile(file) error = %v", err)
|
|
}
|
|
defer file.Close()
|
|
gotFileField = fh.Filename
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll(file) error = %v", err)
|
|
}
|
|
gotFileSize = len(data)
|
|
gotFileData = string(data)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"schema":"speaker_transcript.v1","segments":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "10ms", 0)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
outPath := filepath.Join(t.TempDir(), "raw.json")
|
|
|
|
res, err := client.Transcribe(context.Background(), TranscribeRequest{
|
|
SpeakerID: "alice",
|
|
AudioPath: audioPath,
|
|
OutputRawTranscriptPath: outPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Transcribe() error = %v", err)
|
|
}
|
|
if res.Attempts != 1 {
|
|
t.Fatalf("Attempts = %d, want 1", res.Attempts)
|
|
}
|
|
if res.HTTPStatus != http.StatusOK {
|
|
t.Fatalf("HTTPStatus = %d, want %d", res.HTTPStatus, http.StatusOK)
|
|
}
|
|
if gotLanguage != "en" {
|
|
t.Fatalf("language = %q, want en", gotLanguage)
|
|
}
|
|
if gotFileField != "audio.flac" {
|
|
t.Fatalf("file field filename = %q, want audio.flac", gotFileField)
|
|
}
|
|
if gotFileSize == 0 {
|
|
t.Fatal("file size = 0, want >0")
|
|
}
|
|
if gotFileData != "audio-data" {
|
|
t.Fatalf("file data = %q, want exact payload", gotFileData)
|
|
}
|
|
verifyJSONFile(t, outPath)
|
|
}
|
|
|
|
func TestHTTPClientRetriesOnTransientAndSucceeds(t *testing.T) {
|
|
var calls atomic.Int32
|
|
var payloads []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
n := calls.Add(1)
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
t.Fatalf("FormFile(file) error = %v", err)
|
|
}
|
|
data, err := io.ReadAll(file)
|
|
_ = file.Close()
|
|
if err != nil {
|
|
t.Fatalf("ReadAll(file) error = %v", err)
|
|
}
|
|
payloads = append(payloads, string(data))
|
|
if n == 1 {
|
|
http.Error(w, "temporary", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 2)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
outPath := filepath.Join(t.TempDir(), "raw.json")
|
|
res, err := client.Transcribe(context.Background(), TranscribeRequest{
|
|
AudioPath: audioPath,
|
|
OutputRawTranscriptPath: outPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Transcribe() error = %v", err)
|
|
}
|
|
if res.Attempts != 2 {
|
|
t.Fatalf("Attempts = %d, want 2", res.Attempts)
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Fatalf("calls = %d, want 2", calls.Load())
|
|
}
|
|
if len(payloads) != 2 || payloads[0] != "audio-data" || payloads[1] != "audio-data" {
|
|
t.Fatalf("retry payloads = %#v, want two exact audio payloads", payloads)
|
|
}
|
|
verifyJSONFile(t, outPath)
|
|
}
|
|
|
|
func TestHTTPClientDoesNotRetryOnNonRetryableStatus(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 3)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
outPath := filepath.Join(t.TempDir(), "raw.json")
|
|
_, err = client.Transcribe(context.Background(), TranscribeRequest{
|
|
AudioPath: audioPath,
|
|
OutputRawTranscriptPath: outPath,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Transcribe() error = nil, want non-nil")
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("calls = %d, want 1", calls.Load())
|
|
}
|
|
if _, statErr := os.Stat(outPath); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("output should not exist, stat err = %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientInvalidJSONFailsAndDoesNotInstallOutput(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`not-json`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 0)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
outPath := filepath.Join(t.TempDir(), "raw.json")
|
|
_, err = client.Transcribe(context.Background(), TranscribeRequest{
|
|
AudioPath: audioPath,
|
|
OutputRawTranscriptPath: outPath,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Transcribe() error = nil, want non-nil")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "json") {
|
|
t.Fatalf("error = %q, want json context", err.Error())
|
|
}
|
|
if _, statErr := os.Stat(outPath); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("output should not exist, stat err = %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientTimeoutRetryDeterministic(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
time.Sleep(150 * time.Millisecond)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "50ms", "1ms", 1)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
outPath := filepath.Join(t.TempDir(), "raw.json")
|
|
res, err := client.Transcribe(context.Background(), TranscribeRequest{
|
|
AudioPath: audioPath,
|
|
OutputRawTranscriptPath: outPath,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Transcribe() error = nil, want timeout error")
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Fatalf("calls = %d, want 2 attempts", calls.Load())
|
|
}
|
|
if res.Attempts != 2 {
|
|
t.Fatalf("Attempts = %d, want 2", res.Attempts)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientCancelDoesNotRetry(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
time.Sleep(100 * time.Millisecond)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 3)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
outPath := filepath.Join(t.TempDir(), "raw.json")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err = client.Transcribe(ctx, TranscribeRequest{
|
|
AudioPath: audioPath,
|
|
OutputRawTranscriptPath: outPath,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Transcribe() error = nil, want cancellation error")
|
|
}
|
|
if calls.Load() > 1 {
|
|
t.Fatalf("calls = %d, want <= 1 when already canceled", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientConstructorValidation(t *testing.T) {
|
|
_, err := NewHTTPClientFromConfigValues("", "en", "30m", "2s", 1)
|
|
if err == nil {
|
|
t.Fatal("expected missing URL error")
|
|
}
|
|
_, err = NewHTTPClientFromConfigValues("https://example.com", "en", "bad", "2s", 1)
|
|
if err == nil {
|
|
t.Fatal("expected bad timeout error")
|
|
}
|
|
_, err = NewHTTPClientFromConfigValues("https://example.com", "en", "30m", "bad", 1)
|
|
if err == nil {
|
|
t.Fatal("expected bad retry_delay error")
|
|
}
|
|
for _, endpoint := range []string{"ftp://example.com/transcribe", "file:///tmp/transcribe", "//example.com/transcribe", "https:/missing-host"} {
|
|
if _, err := NewHTTPClientFromConfigValues(endpoint, "en", "30m", "2s", 1); err == nil {
|
|
t.Errorf("NewHTTPClientFromConfigValues(%q) error = nil, want endpoint validation error", endpoint)
|
|
}
|
|
}
|
|
for _, endpoint := range []string{"http://example.com/transcribe", "https://example.com/transcribe"} {
|
|
if _, err := NewHTTPClientFromConfigValues(endpoint, "en", "30m", "2s", 1); err != nil {
|
|
t.Errorf("NewHTTPClientFromConfigValues(%q) error = %v", endpoint, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientStreamsUploadBeforeSourceCompletes(t *testing.T) {
|
|
release := make(chan struct{})
|
|
source := newGatedReadCloser([]byte("audio-data"), release)
|
|
firstByteReceived := make(chan struct{})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
part := firstMultipartFilePart(t, r)
|
|
buf := make([]byte, 1)
|
|
if _, err := part.Read(buf); err != nil {
|
|
t.Errorf("Read(file) error = %v", err)
|
|
return
|
|
}
|
|
close(firstByteReceived)
|
|
if _, err := io.Copy(io.Discard, part); err != nil {
|
|
t.Errorf("discard remaining file data: %v", err)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := newTestHTTPClient(t, srv.URL)
|
|
client.openAudio = func(string) (io.ReadCloser, error) { return source, nil }
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.Transcribe(context.Background(), TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case <-firstByteReceived:
|
|
close(release)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("server did not receive streamed audio before source completed")
|
|
}
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("Transcribe() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientSourceReadFailureReachesCaller(t *testing.T) {
|
|
sourceErr := errors.New("source read failed")
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := newTestHTTPClient(t, srv.URL)
|
|
client.openAudio = func(string) (io.ReadCloser, error) {
|
|
return &failingReadCloser{first: []byte("partial"), err: sourceErr}, nil
|
|
}
|
|
|
|
_, err := client.Transcribe(context.Background(), TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")})
|
|
if !errors.Is(err, sourceErr) {
|
|
t.Fatalf("Transcribe() error = %v, want source read failure", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientEarlyServerResponseReleasesBlockedProducer(t *testing.T) {
|
|
release := make(chan struct{})
|
|
source := newGatedReadCloser([]byte("audio-data"), release)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := newTestHTTPClient(t, srv.URL)
|
|
client.openAudio = func(string) (io.ReadCloser, error) { return source, nil }
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.Transcribe(context.Background(), TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("Transcribe() error = nil, want HTTP status error")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Transcribe() did not finish after server closed the request early")
|
|
}
|
|
select {
|
|
case <-source.closed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("blocked audio source was not closed")
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) {
|
|
release := make(chan struct{})
|
|
source := newGatedReadCloser([]byte("audio-data"), release)
|
|
firstByteReceived := make(chan struct{})
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
part := firstMultipartFilePart(t, r)
|
|
buf := make([]byte, 1)
|
|
if _, err := part.Read(buf); err != nil {
|
|
t.Errorf("Read(file) error = %v", err)
|
|
return
|
|
}
|
|
close(firstByteReceived)
|
|
<-r.Context().Done()
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := newTestHTTPClient(t, srv.URL)
|
|
client.openAudio = func(string) (io.ReadCloser, error) { return source, nil }
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.Transcribe(ctx, TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case <-firstByteReceived:
|
|
cancel()
|
|
case <-time.After(time.Second):
|
|
cancel()
|
|
t.Fatal("server did not receive initial streamed audio")
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Transcribe() error = %v, want context cancellation", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Transcribe() did not finish after cancellation")
|
|
}
|
|
select {
|
|
case <-source.closed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("blocked audio source was not closed on cancellation")
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientBoundsWhisperXResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client, err := NewHTTPClient(HTTPClientConfig{TranscribeURL: srv.URL, Language: "en", Timeout: time.Second, MaxResponseBytes: 4})
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClient() error = %v", err)
|
|
}
|
|
audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data")
|
|
_, err = client.Transcribe(context.Background(), TranscribeRequest{AudioPath: audioPath, OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")})
|
|
if err == nil || !strings.Contains(err.Error(), "whisperx response exceeds configured limit") {
|
|
t.Fatalf("Transcribe() error = %v, want bounded WhisperX response error", err)
|
|
}
|
|
}
|
|
|
|
func newTestHTTPClient(t *testing.T, endpoint string) *HTTPClient {
|
|
t.Helper()
|
|
client, err := NewHTTPClientFromConfigValues(endpoint, "en", "2s", "1ms", 0)
|
|
if err != nil {
|
|
t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err)
|
|
}
|
|
return client
|
|
}
|
|
|
|
func firstMultipartFilePart(t *testing.T, r *http.Request) *multipart.Part {
|
|
t.Helper()
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
t.Fatalf("MultipartReader() error = %v", err)
|
|
}
|
|
part, err := reader.NextPart()
|
|
if err != nil {
|
|
t.Fatalf("NextPart() error = %v", err)
|
|
}
|
|
if part.FormName() != "file" {
|
|
t.Fatalf("first form field = %q, want file", part.FormName())
|
|
}
|
|
return part
|
|
}
|
|
|
|
type gatedReadCloser struct {
|
|
first []byte
|
|
release <-chan struct{}
|
|
closed chan struct{}
|
|
sent bool
|
|
once atomic.Bool
|
|
}
|
|
|
|
func newGatedReadCloser(first []byte, release <-chan struct{}) *gatedReadCloser {
|
|
return &gatedReadCloser{first: first, release: release, closed: make(chan struct{})}
|
|
}
|
|
|
|
func (r *gatedReadCloser) Read(p []byte) (int, error) {
|
|
if !r.sent {
|
|
r.sent = true
|
|
return copy(p, r.first), nil
|
|
}
|
|
select {
|
|
case <-r.release:
|
|
return 0, io.EOF
|
|
case <-r.closed:
|
|
return 0, errors.New("audio source closed")
|
|
}
|
|
}
|
|
|
|
func (r *gatedReadCloser) Close() error {
|
|
if r.once.CompareAndSwap(false, true) {
|
|
close(r.closed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type failingReadCloser struct {
|
|
first []byte
|
|
err error
|
|
sent bool
|
|
}
|
|
|
|
func (r *failingReadCloser) Read(p []byte) (int, error) {
|
|
if !r.sent {
|
|
r.sent = true
|
|
return copy(p, r.first), nil
|
|
}
|
|
return 0, r.err
|
|
}
|
|
|
|
func (r *failingReadCloser) Close() error { return nil }
|
|
|
|
func writeWhisperXTestFile(t *testing.T, name, contents string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), name)
|
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func verifyJSONFile(t *testing.T, path string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
|
}
|
|
var v any
|
|
if err := json.Unmarshal(data, &v); err != nil {
|
|
t.Fatalf("json unmarshal output %q: %v", path, err)
|
|
}
|
|
}
|