capture: spool the meeting to disk, own it by token, reap it by the clock
Four invariants the comments claimed and the code did not hold. The recording lived in mavend's heap as one growing []byte, doubled at Stop when the WAV was built. Frames now go to a spool file and the transcript is read back off disk one window at a time, so memory is flat whatever the length. A store failure returned before transcription ran, so a meeting over the blob cap produced no transcript, no summary and no note. It now records the failure and keeps going, and the spool file survives until the words have been read off it. The session had no owner. Any module on the write rung could call stop on a recording it did not start and receive the verbatim words of everyone in the room. Start hands back a token and append, stop and abort require it. The duration cap was only checked when a frame arrived, so a phone whose tab was closed left the slot occupied and every later start answered ErrBusy with a meeting from last week. The wall clock is checked in start, status, append and stop. Smaller things in the same pass. Append compares the frame format against the session format, so a client that switches sample rate mid meeting no longer has its frames concatenated under a header that lies. One failed STT window leaves a marker instead of discarding the other twenty four. Summarize is separate from Stop and assigns the salvaged per chunk text before it reports the error. Found in review of #73.
This commit is contained in:
@@ -90,7 +90,7 @@ func TestNewRequiresStoreAndTranscriber(t *testing.T) {
|
||||
// is refused. There is no ambient path in.
|
||||
func TestAppendWithoutStartIsRefused(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if err := r.Append(frame(1)); !errors.Is(err, ErrNoSession) {
|
||||
if err := r.Append("no-token", frame(1)); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("got %v, want ErrNoSession", err)
|
||||
}
|
||||
if r.Status().Running {
|
||||
@@ -100,20 +100,21 @@ func TestAppendWithoutStartIsRefused(t *testing.T) {
|
||||
|
||||
func TestStopWithoutStartIsRefused(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrNoSession) {
|
||||
if _, err := r.Stop(context.Background(), "no-token"); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("got %v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneSessionAtATime(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
s, err := r.Start("встреча")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("got %v, want ErrBusy", err)
|
||||
}
|
||||
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrEmptyCapture) {
|
||||
if _, err := r.Stop(context.Background(), s.Token); !errors.Is(err, ErrEmptyCapture) {
|
||||
t.Fatalf("empty stop: %v", err)
|
||||
}
|
||||
// The slot is free again after a stop, even a failed one.
|
||||
@@ -127,18 +128,22 @@ func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
|
||||
sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil)
|
||||
r, blobs := testRecorder(t, tr, sum, Config{})
|
||||
|
||||
if _, err := r.Start("встреча с подрядчиком"); err != nil {
|
||||
s, err := r.Start("встреча с подрядчиком")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.Append(frame(2)); err != nil {
|
||||
if err := r.Append(s.Token, frame(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
res, err := r.Stop(context.Background(), s.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := r.Summarize(context.Background(), &res); err != nil {
|
||||
t.Fatalf("summarize: %v", err)
|
||||
}
|
||||
if res.BlobID == "" {
|
||||
t.Error("no audio blob stored")
|
||||
}
|
||||
@@ -171,21 +176,22 @@ func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
|
||||
func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
|
||||
tr := &fakeTranscriber{}
|
||||
r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second})
|
||||
if _, err := r.Start("длинная"); err != nil {
|
||||
s, err := r.Start("длинная")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(3)); err != nil {
|
||||
if err := r.Append(s.Token, frame(3)); err != nil {
|
||||
t.Fatalf("first frame: %v", err)
|
||||
}
|
||||
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
|
||||
if err := r.Append(s.Token, frame(3)); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("got %v, want ErrExpired", err)
|
||||
}
|
||||
// Further frames keep being refused, so a client that ignores the error
|
||||
// cannot grow the recording past the cap.
|
||||
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
|
||||
if err := r.Append(s.Token, frame(3)); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("post-expiry frame: %v", err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
res, err := r.Stop(context.Background(), s.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("stop after expiry: %v", err)
|
||||
}
|
||||
@@ -196,11 +202,12 @@ func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
|
||||
|
||||
func TestAppendRejectsWrongFormat(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Start("x"); err != nil {
|
||||
s, err := r.Start("x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 100)}
|
||||
if err := r.Append(bad); !errors.Is(err, ErrBadFormat) {
|
||||
if err := r.Append(s.Token, bad); !errors.Is(err, ErrBadFormat) {
|
||||
t.Fatalf("got %v, want ErrBadFormat", err)
|
||||
}
|
||||
}
|
||||
@@ -209,13 +216,14 @@ func TestAppendRejectsWrongFormat(t *testing.T) {
|
||||
func TestAbortLeavesNothing(t *testing.T) {
|
||||
tr := &fakeTranscriber{}
|
||||
r, blobs := testRecorder(t, tr, nil, Config{})
|
||||
if _, err := r.Start("зря начали"); err != nil {
|
||||
s, err := r.Start("зря начали")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(5)); err != nil {
|
||||
if err := r.Append(s.Token, frame(5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Abort() {
|
||||
if !r.Abort(s.Token) {
|
||||
t.Fatal("Abort reported nothing running")
|
||||
}
|
||||
if r.Status().Running {
|
||||
@@ -231,7 +239,7 @@ func TestAbortLeavesNothing(t *testing.T) {
|
||||
if tr.calls != 0 {
|
||||
t.Errorf("Abort transcribed anyway (%d calls)", tr.calls)
|
||||
}
|
||||
if r.Abort() {
|
||||
if r.Abort(s.Token) {
|
||||
t.Error("second Abort reported a session")
|
||||
}
|
||||
}
|
||||
@@ -241,10 +249,11 @@ func TestStatusReportsTheRunningSession(t *testing.T) {
|
||||
if got := r.Status(); got.Running {
|
||||
t.Error("idle recorder reports running")
|
||||
}
|
||||
if _, err := r.Start("планёрка"); err != nil {
|
||||
s, err := r.Start("планёрка")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(10)); err != nil {
|
||||
if err := r.Append(s.Token, frame(10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := r.Status()
|
||||
@@ -264,13 +273,14 @@ func TestStatusReportsTheRunningSession(t *testing.T) {
|
||||
func TestLongAudioIsTranscribedInWindows(t *testing.T) {
|
||||
tr := &fakeTranscriber{}
|
||||
r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second})
|
||||
if _, err := r.Start("длинная"); err != nil {
|
||||
s, err := r.Start("длинная")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(9)); err != nil {
|
||||
if err := r.Append(s.Token, frame(9)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
res, err := r.Stop(context.Background(), s.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
@@ -282,18 +292,19 @@ func TestLongAudioIsTranscribedInWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A hole in the middle of a meeting summary would mislead, so a failed window is
|
||||
// fatal — but the audio is already stored and re-runnable.
|
||||
// Every window failing is a transcription failure — but the audio is already
|
||||
// stored and re-runnable.
|
||||
func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
|
||||
tr := &fakeTranscriber{err: errors.New("whisper is down")}
|
||||
r, blobs := testRecorder(t, tr, nil, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
s, err := r.Start("встреча")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(2)); err != nil {
|
||||
if err := r.Append(s.Token, frame(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
res, err := r.Stop(context.Background(), s.Token)
|
||||
if err == nil {
|
||||
t.Fatal("transcription failure was not reported")
|
||||
}
|
||||
@@ -309,16 +320,20 @@ func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
|
||||
// error.
|
||||
func TestNoSummarizerStillProducesATranscript(t *testing.T) {
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
s, err := r.Start("встреча")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(1)); err != nil {
|
||||
if err := r.Append(s.Token, frame(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
res, err := r.Stop(context.Background(), s.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := r.Summarize(context.Background(), &res); err != nil {
|
||||
t.Fatalf("summarize with no summarizer: %v", err)
|
||||
}
|
||||
if res.Transcript == "" {
|
||||
t.Error("no transcript")
|
||||
}
|
||||
@@ -331,14 +346,18 @@ func TestNoSummarizerStillProducesATranscript(t *testing.T) {
|
||||
func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
|
||||
sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil)
|
||||
r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{})
|
||||
if _, err := r.Start("встреча"); err != nil {
|
||||
s, err := r.Start("встреча")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Append(frame(1)); err != nil {
|
||||
if err := r.Append(s.Token, frame(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := r.Stop(context.Background())
|
||||
if err == nil {
|
||||
res, err := r.Stop(context.Background(), s.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := r.Summarize(context.Background(), &res); err == nil {
|
||||
t.Fatal("summary failure was not reported")
|
||||
}
|
||||
if res.Transcript == "" {
|
||||
@@ -346,18 +365,12 @@ func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkAudioNeverCutsMidSample(t *testing.T) {
|
||||
a := audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000*2*5+1)}
|
||||
for _, w := range chunkAudio(a, 2*time.Second) {
|
||||
if len(w.Bytes)%2 != 0 {
|
||||
t.Fatalf("window of %d bytes cuts an int16 in half", len(w.Bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkAudioShortInputIsOneWindow(t *testing.T) {
|
||||
a := frame(1)
|
||||
if got := chunkAudio(a, time.Minute); len(got) != 1 {
|
||||
t.Errorf("got %d windows, want 1", len(got))
|
||||
func TestWindowBytesNeverCutsMidSample(t *testing.T) {
|
||||
if got := windowBytes(audio.PCM16kMono, 2*time.Second); got%2 != 0 || got != 2*16000*2 {
|
||||
t.Fatalf("windowBytes = %d", got)
|
||||
}
|
||||
odd := audio.Format{SampleRate: 16000, Channels: 1, SampleBits: 16, Encoding: "pcm_s16le"}
|
||||
if got := windowBytes(odd, 0); got != 0 {
|
||||
t.Fatalf("a zero window must produce zero, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user