77888c1a9c
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.
155 lines
5.0 KiB
Go
155 lines
5.0 KiB
Go
package capture
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
"github.com/kami/maven/internal/media"
|
|
)
|
|
|
|
// A frame for a session that already ended must not land in the next one. The
|
|
// recorder used to be addressed as "whatever is running now", so a client whose
|
|
// session was reaped went on appending its microphone into a meeting somebody
|
|
// else had started.
|
|
func TestAppendWithTheWrongTokenIsRefused(t *testing.T) {
|
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
|
s, err := r.Start("первая")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append("someone-elses-token", frame(1)); !errors.Is(err, ErrWrongSession) {
|
|
t.Fatalf("append = %v, want ErrWrongSession", err)
|
|
}
|
|
if _, err := r.Stop(context.Background(), "someone-elses-token"); !errors.Is(err, ErrWrongSession) {
|
|
t.Fatalf("stop = %v, want ErrWrongSession", err)
|
|
}
|
|
if r.Abort("someone-elses-token") {
|
|
t.Fatal("Abort discarded a session it does not own")
|
|
}
|
|
if err := r.Append(s.Token, frame(1)); err != nil {
|
|
t.Fatalf("the owner is still refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A client that simply stops sending — a phone whose tab was closed — used to
|
|
// hold the single session slot forever, and every later Start answered ErrBusy
|
|
// with a meeting from last week.
|
|
func TestStaleSessionIsReapedByTheWallClock(t *testing.T) {
|
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{MaxDuration: time.Minute})
|
|
now := time.Now().UTC()
|
|
r.now = func() time.Time { return now }
|
|
s, err := r.Start("брошенная")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("start = %v, want ErrBusy", err)
|
|
}
|
|
now = now.Add(time.Minute + StaleGrace + time.Second)
|
|
next, err := r.Start("вторая")
|
|
if err != nil {
|
|
t.Fatalf("a stale session was not reaped: %v", err)
|
|
}
|
|
if next.Token == s.Token {
|
|
t.Fatal("the new session reused the stale token")
|
|
}
|
|
if err := r.Append(s.Token, frame(1)); !errors.Is(err, ErrWrongSession) {
|
|
t.Fatalf("the reaped client can still write: %v", err)
|
|
}
|
|
// The abandoned recording is not kept: nobody is waiting for it, and storing
|
|
// it would mean keeping a meeting on the strength of a dropped connection.
|
|
if _, err := os.Stat(s.path); !os.IsNotExist(err) {
|
|
t.Fatalf("the reaped spool file survived: %v", err)
|
|
}
|
|
}
|
|
|
|
// One window failing used to fail the whole transcription, which threw away
|
|
// every other window of an hour-long meeting. The hole is marked instead, so the
|
|
// summary cannot silently read as if nothing was missing.
|
|
func TestOneFailedWindowIsMarkedNotFatal(t *testing.T) {
|
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{STTWindow: time.Second})
|
|
r.tr = &windowTranscriber{failOn: 2}
|
|
s, err := r.Start("встреча")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(s.Token, frame(3)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := r.Stop(context.Background(), s.Token)
|
|
if err != nil {
|
|
t.Fatalf("stop: %v", err)
|
|
}
|
|
if !strings.Contains(res.Transcript, gapMarker) {
|
|
t.Errorf("no gap marker in %q", res.Transcript)
|
|
}
|
|
if !strings.Contains(res.Transcript, "окно1") || !strings.Contains(res.Transcript, "окно3") {
|
|
t.Errorf("the surviving windows were dropped: %q", res.Transcript)
|
|
}
|
|
}
|
|
|
|
// The audio not fitting the store is not a reason to lose the words. Stop used
|
|
// to return early on a store failure, so a recording over the blob cap produced
|
|
// neither a blob nor a transcript.
|
|
func TestStoreFailureStillTranscribes(t *testing.T) {
|
|
blobs, err := media.OpenWithBudget(t.TempDir(), 512, 1024, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := &fakeTranscriber{}
|
|
r, err := New(blobs, tr, nil, Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := r.Start("длинная встреча")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(s.Token, frame(2)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The store failure is reported, but as a degraded success: the Result is
|
|
// filled in, and the caller keeps it rather than treating the error as
|
|
// nothing having happened.
|
|
res, err := r.Stop(context.Background(), s.Token)
|
|
if err == nil {
|
|
t.Fatal("the store failure was not reported")
|
|
}
|
|
if res.BlobID != "" {
|
|
t.Errorf("blob id = %q, want none", res.BlobID)
|
|
}
|
|
if !errors.Is(res.StoreErr, media.ErrTooLarge) {
|
|
t.Errorf("StoreErr = %v, want ErrTooLarge", res.StoreErr)
|
|
}
|
|
if res.Transcript == "" {
|
|
t.Fatal("the words were lost with the audio")
|
|
}
|
|
// The spool file is cleaned up even on the failure path.
|
|
glob, _ := filepath.Glob(filepath.Join(blobs.Dir(), "spool", "*"))
|
|
if len(glob) != 0 {
|
|
t.Errorf("spool leaked: %v", glob)
|
|
}
|
|
}
|
|
|
|
// windowTranscriber answers per window and fails a chosen one, which is what a
|
|
// whisper timeout in the middle of a meeting looks like.
|
|
type windowTranscriber struct {
|
|
calls int
|
|
failOn int
|
|
}
|
|
|
|
func (w *windowTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
|
w.calls++
|
|
if w.calls == w.failOn {
|
|
return "", 0, errors.New("whisper timed out")
|
|
}
|
|
return fmt.Sprintf("окно%d", w.calls), 1.0, nil
|
|
}
|