package capture import ( "context" "errors" "fmt" "strings" "testing" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/media" ) // fakeTranscriber returns a fixed phrase per call so a windowed transcription is // visible in the joined output. type fakeTranscriber struct { calls int err error phrase string } func (f *fakeTranscriber) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) { f.calls++ if f.err != nil { return "", 0, f.err } p := f.phrase if p == "" { p = "окно" } return fmt.Sprintf("%s%d", p, f.calls), 1.0, nil } // fakeCompleter records prompts and replies from a script. type fakeCompleter struct { replies []string systems []string users []string err error } func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) { f.systems = append(f.systems, system) f.users = append(f.users, user) if f.err != nil { return "", f.err } if len(f.replies) == 0 { return "итог", nil } r := f.replies[0] f.replies = f.replies[1:] return r, nil } // frame builds n seconds of silence in the canonical format. func frame(seconds float64) audio.Audio { n := int(seconds*16000) * 2 return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, n)} } func testRecorder(t *testing.T, tr *fakeTranscriber, sum *Summarizer, cfg Config) (*Recorder, *media.Store) { t.Helper() blobs, err := media.Open(t.TempDir(), 0, 0) if err != nil { t.Fatal(err) } r, err := New(blobs, tr, sum, cfg) if err != nil { t.Fatal(err) } return r, blobs } func TestNewRequiresStoreAndTranscriber(t *testing.T) { blobs, err := media.Open(t.TempDir(), 0, 0) if err != nil { t.Fatal(err) } if _, err := New(nil, &fakeTranscriber{}, nil, Config{}); err == nil { t.Error("recorder built with no blob store") } if _, err := New(blobs, nil, nil, Config{}); err == nil { t.Error("recorder built with no transcriber") } } // The invariant that matters most: audio arriving at a recorder nobody started // is refused. There is no ambient path in. func TestAppendWithoutStartIsRefused(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{}) if err := r.Append("no-token", frame(1)); !errors.Is(err, ErrNoSession) { t.Fatalf("got %v, want ErrNoSession", err) } if r.Status().Running { t.Error("a refused frame started a session") } } func TestStopWithoutStartIsRefused(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{}) 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{}) 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(), s.Token); !errors.Is(err, ErrEmptyCapture) { t.Fatalf("empty stop: %v", err) } // The slot is free again after a stop, even a failed one. if _, err := r.Start("третья"); err != nil { t.Errorf("slot not released: %v", err) } } func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) { tr := &fakeTranscriber{phrase: "совещание"} sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil) r, blobs := testRecorder(t, tr, sum, Config{}) s, err := r.Start("встреча с подрядчиком") if err != nil { t.Fatal(err) } for i := 0; i < 3; i++ { if err := r.Append(s.Token, frame(2)); err != nil { t.Fatal(err) } } 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") } blob, data, err := blobs.Read(res.BlobID) if err != nil { t.Fatalf("blob unreadable: %v", err) } if blob.Kind != media.KindAudio || blob.Source != "capture:meeting" { t.Errorf("blob metadata = %+v", blob) } if string(data[:4]) != "RIFF" { t.Error("audio was not stored as a playable WAV") } if res.Transcript == "" { t.Error("no transcript") } if !strings.Contains(res.Summary, "насос") { t.Errorf("summary = %q", res.Summary) } if !strings.Contains(res.Summary, "встреча с подрядчиком") { t.Errorf("label missing from summary: %q", res.Summary) } if res.Duration != 6*time.Second { t.Errorf("duration = %v, want 6s", res.Duration) } } // A forgotten session stops itself, and the audio collected before the cap is // kept rather than thrown away. func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) { tr := &fakeTranscriber{} r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second}) s, err := r.Start("длинная") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(3)); err != nil { t.Fatalf("first frame: %v", err) } 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(s.Token, frame(3)); !errors.Is(err, ErrExpired) { t.Fatalf("post-expiry frame: %v", err) } res, err := r.Stop(context.Background(), s.Token) if err != nil { t.Fatalf("stop after expiry: %v", err) } if res.Duration != 6*time.Second { t.Errorf("duration = %v, want the 6s collected before the cap", res.Duration) } } func TestAppendRejectsWrongFormat(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{}) 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(s.Token, bad); !errors.Is(err, ErrBadFormat) { t.Fatalf("got %v, want ErrBadFormat", err) } } // "забудь, не записывай" must leave nothing behind — no blob, no transcript. func TestAbortLeavesNothing(t *testing.T) { tr := &fakeTranscriber{} r, blobs := testRecorder(t, tr, nil, Config{}) s, err := r.Start("зря начали") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(5)); err != nil { t.Fatal(err) } if !r.Abort(s.Token) { t.Fatal("Abort reported nothing running") } if r.Status().Running { t.Error("session survived Abort") } list, err := blobs.List(media.KindAudio) if err != nil { t.Fatal(err) } if len(list) != 0 { t.Errorf("Abort stored %d blob(s)", len(list)) } if tr.calls != 0 { t.Errorf("Abort transcribed anyway (%d calls)", tr.calls) } if r.Abort(s.Token) { t.Error("second Abort reported a session") } } func TestStatusReportsTheRunningSession(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{}) if got := r.Status(); got.Running { t.Error("idle recorder reports running") } s, err := r.Start("планёрка") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(10)); err != nil { t.Fatal(err) } st := r.Status() if !st.Running || st.Label != "планёрка" { t.Fatalf("status = %+v", st) } if st.Duration != 10*time.Second { t.Errorf("duration = %v", st.Duration) } if st.Bytes != 10*16000*2 { t.Errorf("bytes = %d", st.Bytes) } } // Long audio goes to the transcriber in windows: handing a whisper worker an // hour of PCM in one call blocks the voice path for minutes. func TestLongAudioIsTranscribedInWindows(t *testing.T) { tr := &fakeTranscriber{} r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second}) s, err := r.Start("длинная") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(9)); err != nil { t.Fatal(err) } res, err := r.Stop(context.Background(), s.Token) if err != nil { t.Fatalf("stop: %v", err) } if tr.calls != 5 { // 2+2+2+2+1 t.Errorf("transcriber called %d times, want 5", tr.calls) } if !strings.Contains(res.Transcript, "окно5") { t.Errorf("last window missing from transcript: %q", res.Transcript) } } // 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{}) s, err := r.Start("встреча") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(2)); err != nil { t.Fatal(err) } res, err := r.Stop(context.Background(), s.Token) if err == nil { t.Fatal("transcription failure was not reported") } if res.BlobID == "" { t.Fatal("no blob id to retry with") } if _, _, err := blobs.Read(res.BlobID); err != nil { t.Errorf("audio was not kept: %v", err) } } // No llama-server ⇒ transcript only. That is the honest degradation, not an // error. func TestNoSummarizerStillProducesATranscript(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{}) s, err := r.Start("встреча") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(1)); err != nil { t.Fatal(err) } 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") } if res.Summary != "" { t.Errorf("summary appeared from nowhere: %q", res.Summary) } } // A summariser failure is a degraded success: the words exist and are returned. func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) { sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil) r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{}) s, err := r.Start("встреча") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(1)); err != nil { t.Fatal(err) } 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 == "" { t.Error("transcript lost to a summary failure") } } 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) } }