// Package capture is Maven's meeting recorder (Vikunja #253, // docs/plans/08-hearing.md). // // One session at a time, with an explicit start and an explicit stop: // // Start("встреча") → audio frames appended → Stop() → transcript → summary // // # Nothing here listens // // This is the most invasive capability in the backlog and the design is // constrained accordingly. The constraints are the code, not a preamble: // // - There is no ambient path. `Session.Append` is the only way audio enters, // and it only accepts frames while a session someone started is running. // A keyword-triggered recorder ("maven record" heard in the room) was in the // plan document and is refused: it requires listening in order to notice the // keyword, which is the exact behaviour this capability must not have. // - A session that is not stopped stops itself. MaxDuration is a hard cap // checked on every Append, not a suggestion; a forgotten recording is a // recording that ends, not one that runs until the disk is full. // - Audio is stored under internal/media, which means retention prunes it and // it never leaves the box. Both the audio blob and the transcript stay // local; only the summary is written where he will read it. // - The transcript is never search input for anything outside this box. It is // text about a conversation with other people in it. // // # Long audio against a 4096-token context // // The resident model is a Thinking variant at n_ctx 4096, so an hour of meeting // transcript does not fit in one prompt and never will. summarize.go does the // obvious map-reduce: split the transcript on sentence boundaries into windows // that fit, summarise each, then summarise the summaries. That is handled // explicitly rather than by truncation, because a truncated meeting summary is // worse than none — it looks complete and is not. // // # Transcription // // There is exactly one STT in Maven and this package does not add a second: it // takes an stt.Transcriber, which in deploy is the whisper.cpp worker behind // cmd/mavsttd. Long audio is transcribed in windows too (see chunkAudio), for // the same reason whisper itself works in 30s windows — handing a worker an hour // of PCM in one call is a request that either times out or blocks everything // else for minutes. package capture import ( "context" "errors" "fmt" "strings" "sync" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/media" "github.com/kami/maven/internal/stt" ) // DefaultMaxDuration — how long one capture may run before it stops itself. // Two hours covers a long meeting and bounds the damage of a forgotten session: // at 16 kHz mono that is about 230 MB of PCM, which is over media's default // per-blob cap, so a session at the limit is stored truncated rather than // refused. That trade is deliberate — a partial recording of a meeting he asked // for beats an error after two hours. const DefaultMaxDuration = 2 * time.Hour // DefaultSTTWindow — how much audio goes to the transcriber in one call. Five // minutes of 16 kHz mono is under 10 MB, transcribes in well under whisper's // own timeout on this box, and keeps the worker responsive to the voice path // between windows. const DefaultSTTWindow = 5 * time.Minute // Errors callers distinguish. var ( // ErrDisabled — capture is not configured. A capability is off unless // configured, and a recorder most of all. ErrDisabled = errors.New("capture: not configured") // ErrBusy — a session is already running. One at a time: two concurrent // recordings would make "хватит" ambiguous. ErrBusy = errors.New("capture: a session is already running") // ErrNoSession — stop or append with nothing running. ErrNoSession = errors.New("capture: nothing is being recorded") // ErrBadFormat — a frame is not the canonical 16 kHz mono PCM shape. ErrBadFormat = errors.New("capture: audio format not supported") // ErrEmptyCapture — the session ended with no audio in it. ErrEmptyCapture = errors.New("capture: nothing was recorded") // ErrExpired — the session hit MaxDuration and was closed. Returned from // Append so the caller stops sending; the audio collected so far is kept. ErrExpired = errors.New("capture: session reached its time limit") ) // Session — one recording in progress. Not created directly; Recorder.Start // makes it. Guarded by a mutex because frames arrive from a network goroutine // while a status call may read from another. type Session struct { Label string Started time.Time mu sync.Mutex pcm []byte format audio.Format expired bool } // Duration is how much audio has been collected, from the bytes rather than the // wall clock: a stream that dropped frames should report the audio that exists, // not the time that passed. func (s *Session) Duration() time.Duration { s.mu.Lock() defer s.mu.Unlock() return s.duration() } func (s *Session) duration() time.Duration { a := audio.Audio{Format: s.format, Bytes: s.pcm} return time.Duration(a.Duration() * float64(time.Second)) } // Bytes is how much PCM has been collected. For a status line. func (s *Session) Bytes() int { s.mu.Lock() defer s.mu.Unlock() return len(s.pcm) } // Status — what a "что записываешь?" answer needs, and what /dash shows. It is // the read side of a running session and is safe to ask for at any time. type Status struct { Running bool `json:"running"` Label string `json:"label,omitempty"` Started time.Time `json:"started,omitempty"` Duration time.Duration `json:"duration,omitempty"` Bytes int `json:"bytes,omitempty"` } // Recorder owns the single session slot, the blob store and the two models a // finished capture needs. Build it with New; a zero Recorder is not usable. type Recorder struct { blobs *media.Store tr stt.Transcriber sum *Summarizer maxDuration time.Duration sttWindow time.Duration now func() time.Time mu sync.Mutex current *Session } // Config — the recorder's knobs, built from config.CaptureConfig by the daemon. type Config struct { // MaxDuration — hard cap on one session. 0 ⇒ DefaultMaxDuration. MaxDuration time.Duration // STTWindow — audio per transcription call. 0 ⇒ DefaultSTTWindow. STTWindow time.Duration } // New builds a Recorder. blobs and tr are required — a recorder with nowhere to // put the audio, or nothing to transcribe it with, is not a recorder. sum may be // nil: the transcript is still produced and stored, and the summary is simply // absent, which is the honest degradation when there is no llama-server. func New(blobs *media.Store, tr stt.Transcriber, sum *Summarizer, cfg Config) (*Recorder, error) { if blobs == nil { return nil, errors.New("capture: no blob store") } if tr == nil { return nil, errors.New("capture: no transcriber") } maxDur := cfg.MaxDuration if maxDur <= 0 { maxDur = DefaultMaxDuration } window := cfg.STTWindow if window <= 0 { window = DefaultSTTWindow } return &Recorder{ blobs: blobs, tr: tr, sum: sum, maxDuration: maxDur, sttWindow: window, now: time.Now, }, nil } // MaxDuration is the configured hard cap. For the reply that tells him how long // she will keep going if he forgets to say "хватит". func (r *Recorder) MaxDuration() time.Duration { return r.maxDuration } // Start opens a session. label is what the meeting is called ("встреча с // подрядчиком"); it ends up in the summary note so the note is findable. // ErrBusy if one is already running — the caller says so rather than silently // discarding the first recording. func (r *Recorder) Start(label string) (*Session, error) { r.mu.Lock() defer r.mu.Unlock() if r.current != nil { return nil, fmt.Errorf("%w: %q since %s", ErrBusy, r.current.Label, r.current.Started.Format(time.Kitchen)) } s := &Session{ Label: strings.TrimSpace(label), Started: r.now().UTC(), format: audio.PCM16kMono, } r.current = s return s, nil } // Append adds one frame to the running session. ErrNoSession when nothing is // running, which is the guard that makes an ambient path impossible: a stream // arriving at a Recorder nobody started is refused frame by frame. // // ErrExpired once the session is at MaxDuration. The audio collected so far is // kept and Stop still works — the cap ends the recording, it does not throw it // away. func (r *Recorder) Append(a audio.Audio) error { if !a.Format.IsValid() { return fmt.Errorf("%w: %+v", ErrBadFormat, a.Format) } r.mu.Lock() s := r.current r.mu.Unlock() if s == nil { return ErrNoSession } s.mu.Lock() defer s.mu.Unlock() if s.expired { return ErrExpired } s.pcm = append(s.pcm, a.Bytes...) if s.duration() >= r.maxDuration { s.expired = true return ErrExpired } return nil } // Status reports the running session, or Running=false. func (r *Recorder) Status() Status { r.mu.Lock() s := r.current r.mu.Unlock() if s == nil { return Status{} } return Status{ Running: true, Label: s.Label, Started: s.Started, Duration: s.Duration(), Bytes: s.Bytes(), } } // Result — a finished capture. type Result struct { // BlobID — the stored audio, content-addressed. Empty only if storing failed. BlobID string // Label / Started / Duration — what was recorded and when. Label string Started time.Time Duration time.Duration // Transcript — the full text, joined across STT windows. Transcript string // Summary — the map-reduced summary, or empty when no summarizer was wired // or the model failed. Empty summary with a non-empty transcript is a // degraded success, not a failure: the words are there. Summary string // Chunks — how many windows the transcript was summarised in. 1 means it fit // in one prompt. Reported so a suspiciously vague summary can be explained. Chunks int } // Stop ends the session and produces the result: store the audio, transcribe it // in windows, summarise it in windows. The session slot is freed before any of // the slow work starts, so a stuck model cannot block the next recording. // // The order matters and is the same as vision's: the audio is stored FIRST. If // transcription or summarisation fails, the recording is still on disk and can // be run again; a meeting that happened once must not be lost to a model error. func (r *Recorder) Stop(ctx context.Context) (Result, error) { r.mu.Lock() s := r.current r.current = nil r.mu.Unlock() if s == nil { return Result{}, ErrNoSession } s.mu.Lock() pcm := s.pcm format := s.format s.mu.Unlock() res := Result{Label: s.Label, Started: s.Started} if len(pcm) == 0 { return res, ErrEmptyCapture } full := audio.Audio{Format: format, Bytes: pcm} res.Duration = time.Duration(full.Duration() * float64(time.Second)) // Stored as WAV, not headerless PCM: a blob on disk that `aplay` and whisper // can both open without being told the format is worth 44 bytes. wav, err := audio.WAVFromPCM(format, pcm) if err != nil { return res, fmt.Errorf("capture: wav: %w", err) } blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav) if err != nil { // Over the per-blob cap is the expected case for a very long meeting. // Report it and keep going: a transcript without the audio still beats // nothing, and the words are what he will read. return res, fmt.Errorf("capture: store audio: %w", err) } res.BlobID = blob.ID text, err := r.transcribe(ctx, full) if err != nil { return res, fmt.Errorf("capture: transcribe: %w", err) } res.Transcript = text if strings.TrimSpace(text) == "" { return res, ErrEmptyCapture } if r.sum == nil { return res, nil } summary, chunks, err := r.sum.Summarize(ctx, s.Label, text) res.Chunks = chunks if err != nil { // Degraded success: the transcript is real and stored, only the summary // is missing. The caller writes the transcript note and says so. return res, fmt.Errorf("capture: summarize: %w", err) } res.Summary = summary return res, nil } // Abort throws the running session away without transcribing or storing it. // This is what "забудь, не записывай" must map to: a recording someone changed // their mind about leaves nothing behind, not a blob with a note saying it was // abandoned. Returns whether anything was running. func (r *Recorder) Abort() bool { r.mu.Lock() defer r.mu.Unlock() if r.current == nil { return false } r.current = nil return true } // transcribe runs the transcriber over the audio in windows and joins the text. // A window that fails is fatal: a summary of a meeting with a silent hole in the // middle is a summary that misleads. func (r *Recorder) transcribe(ctx context.Context, a audio.Audio) (string, error) { windows := chunkAudio(a, r.sttWindow) parts := make([]string, 0, len(windows)) for i, w := range windows { text, _, err := r.tr.Transcribe(ctx, w) if err != nil { return "", fmt.Errorf("window %d/%d: %w", i+1, len(windows), err) } if t := strings.TrimSpace(text); t != "" { parts = append(parts, t) } } return strings.Join(parts, " "), nil } // chunkAudio splits audio into windows of at most window duration, cut on // sample boundaries. A window shorter than one sample is impossible; audio // shorter than one window comes back as a single element, so the caller never // special-cases the short case. func chunkAudio(a audio.Audio, window time.Duration) []audio.Audio { bytesPerSample := a.Format.SampleBits / 8 * a.Format.Channels if bytesPerSample <= 0 || a.Format.SampleRate <= 0 || window <= 0 { return []audio.Audio{a} } per := int(window.Seconds()) * a.Format.SampleRate * bytesPerSample if per <= 0 || len(a.Bytes) <= per { return []audio.Audio{a} } var out []audio.Audio for off := 0; off < len(a.Bytes); off += per { end := off + per if end > len(a.Bytes) { end = len(a.Bytes) } // Never cut mid-sample: a split inside an int16 shifts every following // sample by a byte and turns the tail of the window into noise. end -= (end - off) % bytesPerSample if end <= off { break } out = append(out, audio.Audio{Format: a.Format, Bytes: a.Bytes[off:end]}) } return out }