aa1a26532c
Maven can record a meeting when she is told to, transcribe it through the STT she already has, and write a summary note. The audio lives in the blob store #252 introduced, under the same retention loop. Nothing here listens. Recorder.Append is the only way audio enters and it refuses every frame unless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered. The plan document asked for a keyword trigger ("maven record" heard in the room) and that is refused: noticing a keyword means listening to the room, which is the one behaviour this capability must not have. Off unless configured twice over. No media block means nowhere to keep audio, no capture block means no recorder, and in either case the four IPC methods answer ErrUnknownMethod. On an unconfigured box there is no wire path that begins a recording at all. A forgotten session ends itself at max_minutes, checked on every append, and the audio collected before the cap is kept. Stop with discard set is what "забудь, не записывай" maps to and it leaves nothing behind. The verbatim transcript is not saved unless save_transcript says so; the summary is. Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune windows rather than by truncation, because a truncated meeting summary reads as complete and is not. Transcription is windowed at five minutes so the whisper worker stays responsive to the voice path. No second STT: internal/capture takes the stt.Transcriber the voice path already holds. Capture with voice off is refused rather than degraded, since hours of unreadable audio of other people is worse than no recording. The three write methods are AuthWrite, not AuthStepUp: step-up needs a passkey gesture the voice path cannot make, which would leave "запиши встречу" impossible by voice. capture_status is AuthRead. make build and make test both pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
364 lines
10 KiB
Go
364 lines
10 KiB
Go
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(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()); !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 {
|
|
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) {
|
|
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{})
|
|
|
|
if _, err := r.Start("встреча с подрядчиком"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if err := r.Append(frame(2)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
res, err := r.Stop(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("stop: %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})
|
|
if _, err := r.Start("длинная"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(frame(3)); err != nil {
|
|
t.Fatalf("first frame: %v", err)
|
|
}
|
|
if err := r.Append(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) {
|
|
t.Fatalf("post-expiry frame: %v", err)
|
|
}
|
|
res, err := r.Stop(context.Background())
|
|
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{})
|
|
if _, err := r.Start("x"); 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) {
|
|
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{})
|
|
if _, err := r.Start("зря начали"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(frame(5)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !r.Abort() {
|
|
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() {
|
|
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")
|
|
}
|
|
if _, err := r.Start("планёрка"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(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})
|
|
if _, err := r.Start("длинная"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(frame(9)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := r.Stop(context.Background())
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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 {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(frame(2)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := r.Stop(context.Background())
|
|
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{})
|
|
if _, err := r.Start("встреча"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(frame(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := r.Stop(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("stop: %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{})
|
|
if _, err := r.Start("встреча"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.Append(frame(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := r.Stop(context.Background())
|
|
if err == nil {
|
|
t.Fatal("summary failure was not reported")
|
|
}
|
|
if res.Transcript == "" {
|
|
t.Error("transcript lost to a summary failure")
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|