diff --git a/ROADMAP.md b/ROADMAP.md index b68324d..09f3749 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,54 @@ # Recorder Roadmap +## Current Status + +### Completed Foundation + +- Whisper `verbose_json` request and segment parsing +- Absolute-time speech segment normalization in `internal/speech` +- Segment-level speech emitter: + - cleanup + - speaker attribution lookup + - mic/system dedup + - transcript event construction +- Speaker attribution formatting in `internal/speech` +- Ranked speaker coverage API in `internal/timeline` +- Debounced speaker tracker and `SpeakerCollector.PollOnce` +- Chunk transcription orchestration in `internal/recorder` +- System dedup reference tracking in `internal/speech` + +### Work To Be Done + +- Live validation: + - run recorder in Google Meet and Teams + - confirm long chunks emit multiple transcript events + - confirm back-and-forth speech gets segment-level speaker attribution + - confirm ambiguous attribution is useful in real meetings + - confirm no stale speaker carry-over across meeting/tab changes +- Diagnostics: + - keep stdout on `slog.TextHandler` for human-readable daemon watching + - mirror logs to configured JSONL file with `slog.JSONHandler` for agent review + - use `slog.NewMultiHandler` when file logging is enabled + - log debounced speaker transitions + - log segment attribution decisions at debug level + - include chunk number, segment window, candidates, and thresholds + - avoid raw poll-sample logs by default +- Cleanup quality: + - compare per-segment cleanup with prior chunk-level cleanup + - keep per-segment cleanup unless quality noticeably regresses + - design segment-preserving cleanup only if needed +- Dedup tuning: + - validate mic/system dedup threshold against real overlap + - check prior-system fallback behavior after silent system chunks + - keep threshold changes separate from structural refactors +- Test hardening: + - add end-to-end recorder orchestration coverage for one chunk producing multiple events + - add stale-speaker regression coverage across meeting changes + - add realistic mixed-speaker transcript fixture coverage +- Roadmap cleanup: + - archive completed implementation notes after live validation + - keep this file focused on remaining decisions and risk + ## Speaker Attribution Improvements ### Goals diff --git a/internal/recorder/chunk_transcriber.go b/internal/recorder/chunk_transcriber.go index be100a4..780bf12 100644 --- a/internal/recorder/chunk_transcriber.go +++ b/internal/recorder/chunk_transcriber.go @@ -3,7 +3,6 @@ package recorder import ( "context" "errors" - "strings" "github.com/odsod/recorder/internal/protocol/whisper" "github.com/odsod/recorder/internal/speech" @@ -25,7 +24,7 @@ type ChunkTranscriber struct { Transcriber Transcriber SpeechEmitter SpeechEmitter - lastSystemText string + SystemRefs speech.SystemReferenceTracker } // ChunkTranscription contains the speech events and errors produced for one chunk. @@ -69,16 +68,9 @@ func (t *ChunkTranscriber) Transcribe(ctx context.Context, chunk AudioChunk) Chu out.SystemSpeechDetected = len(sysSegments) > 0 out.MicSpeechDetected = len(micSegments) > 0 - priorSystemText := t.lastSystemText out.SystemEvents, out.SystemEmitErr = t.SpeechEmitter.Emit(ctx, "sys", sysSegments, nil) - if len(out.SystemEvents) > 0 { - t.lastSystemText = joinEventText(out.SystemEvents) - } - - micDedupEvents := out.SystemEvents - if len(micDedupEvents) == 0 && priorSystemText != "" { - micDedupEvents = []transcript.Event{{Time: chunk.StartTime, Text: priorSystemText}} - } + micDedupEvents := t.SystemRefs.MicRefs(chunk.StartTime, out.SystemEvents) + t.SystemRefs.Update(out.SystemEvents) out.MicEvents, out.MicEmitErr = t.SpeechEmitter.Emit(ctx, "mic", micSegments, micDedupEvents) out.Err = errors.Join( @@ -89,13 +81,3 @@ func (t *ChunkTranscriber) Transcribe(ctx context.Context, chunk AudioChunk) Chu ) return out } - -func joinEventText(events []transcript.Event) string { - parts := make([]string, 0, len(events)) - for _, e := range events { - if e.Text != "" { - parts = append(parts, e.Text) - } - } - return strings.Join(parts, " ") -} diff --git a/internal/speech/refs.go b/internal/speech/refs.go new file mode 100644 index 0000000..70a842f --- /dev/null +++ b/internal/speech/refs.go @@ -0,0 +1,42 @@ +package speech + +import ( + "strings" + "time" + + "github.com/odsod/recorder/internal/transcript" +) + +// SystemReferenceTracker tracks emitted system text for mic dedup references. +type SystemReferenceTracker struct { + lastSystemText string +} + +// MicRefs returns current system events or a prior system-text fallback. +func (t *SystemReferenceTracker) MicRefs(start time.Time, systemEvents []transcript.Event) []transcript.Event { + if len(systemEvents) > 0 { + return systemEvents + } + if t.lastSystemText == "" { + return nil + } + return []transcript.Event{{Time: start, Text: t.lastSystemText}} +} + +// Update records the text from emitted system events. +func (t *SystemReferenceTracker) Update(systemEvents []transcript.Event) { + if len(systemEvents) == 0 { + return + } + t.lastSystemText = joinEventText(systemEvents) +} + +func joinEventText(events []transcript.Event) string { + parts := make([]string, 0, len(events)) + for _, e := range events { + if e.Text != "" { + parts = append(parts, e.Text) + } + } + return strings.Join(parts, " ") +} diff --git a/internal/speech/refs_test.go b/internal/speech/refs_test.go new file mode 100644 index 0000000..b005d28 --- /dev/null +++ b/internal/speech/refs_test.go @@ -0,0 +1,96 @@ +package speech + +import ( + "reflect" + "testing" + "time" + + "github.com/odsod/recorder/internal/transcript" +) + +func TestSystemReferenceTracker_MicRefsReturnsCurrentEvents(t *testing.T) { + start := refsStart() + systemEvents := []transcript.Event{ + {Time: start, Text: "current system"}, + } + tracker := SystemReferenceTracker{lastSystemText: "previous system"} + + got := tracker.MicRefs(start, systemEvents) + + if !reflect.DeepEqual(got, systemEvents) { + t.Fatalf("MicRefs() = %+v, want %+v", got, systemEvents) + } +} + +func TestSystemReferenceTracker_MicRefsWithoutPriorReturnsNil(t *testing.T) { + tracker := SystemReferenceTracker{} + + got := tracker.MicRefs(refsStart(), nil) + + if got != nil { + t.Fatalf("MicRefs() = %+v, want nil", got) + } +} + +func TestSystemReferenceTracker_MicRefsUsesPriorTextAtStart(t *testing.T) { + start := refsStart() + tracker := SystemReferenceTracker{lastSystemText: "previous system"} + + got := tracker.MicRefs(start, nil) + want := []transcript.Event{{Time: start, Text: "previous system"}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("MicRefs() = %+v, want %+v", got, want) + } +} + +func TestSystemReferenceTracker_UpdateJoinsEventTextInOrder(t *testing.T) { + start := refsStart() + tracker := SystemReferenceTracker{} + + tracker.Update([]transcript.Event{ + {Time: start, Text: "first"}, + {Time: start.Add(time.Second), Text: "second"}, + }) + got := tracker.MicRefs(start.Add(time.Minute), nil) + want := []transcript.Event{{Time: start.Add(time.Minute), Text: "first second"}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("MicRefs() after Update() = %+v, want %+v", got, want) + } +} + +func TestSystemReferenceTracker_UpdateIgnoresEmptyText(t *testing.T) { + start := refsStart() + tracker := SystemReferenceTracker{} + + tracker.Update([]transcript.Event{ + {Time: start, Text: ""}, + {Time: start.Add(time.Second), Text: "system"}, + {Time: start.Add(2 * time.Second), Text: ""}, + {Time: start.Add(3 * time.Second), Text: "text"}, + }) + got := tracker.MicRefs(start.Add(time.Minute), nil) + want := []transcript.Event{{Time: start.Add(time.Minute), Text: "system text"}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("MicRefs() after Update() = %+v, want %+v", got, want) + } +} + +func TestSystemReferenceTracker_EmptyUpdateDoesNotOverwritePriorText(t *testing.T) { + start := refsStart() + tracker := SystemReferenceTracker{lastSystemText: "previous system"} + + tracker.Update(nil) + got := tracker.MicRefs(start, nil) + want := []transcript.Event{{Time: start, Text: "previous system"}} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("MicRefs() after empty Update() = %+v, want %+v", got, want) + } +} + +func refsStart() time.Time { + return time.Date(2026, 6, 3, 9, 0, 0, 0, time.UTC) +}