Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 3 additions & 21 deletions internal/recorder/chunk_transcriber.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package recorder
import (
"context"
"errors"
"strings"

"github.com/odsod/recorder/internal/protocol/whisper"
"github.com/odsod/recorder/internal/speech"
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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, " ")
}
42 changes: 42 additions & 0 deletions internal/speech/refs.go
Original file line number Diff line number Diff line change
@@ -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, " ")
}
96 changes: 96 additions & 0 deletions internal/speech/refs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading