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:
kami
2026-08-01 14:36:17 +04:00
parent 71b42e31bd
commit 77888c1a9c
3 changed files with 518 additions and 136 deletions
+61 -48
View File
@@ -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)
}
}