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/conference/teams/teams.go b/internal/conference/teams/teams.go
index fba5bca..5442a22 100644
--- a/internal/conference/teams/teams.go
+++ b/internal/conference/teams/teams.go
@@ -76,9 +76,16 @@ func (p *Provider) ParsePoll(jsonValue string) ([]conference.Participant, error)
const snapshotJS = `(function() {
return JSON.stringify(
Array.from(document.querySelectorAll('[data-tid="voice-level-stream-outline"]')).map(function(el) {
- var p = el.parentElement;
- var tid = p ? p.getAttribute('data-tid') : null;
- var name = (tid && tid.length > 2 && tid.length < 80) ? tid : null;
+ var node = el.parentElement;
+ var name = null;
+ for (var i = 0; i < 3 && node; i++) {
+ var tid = node.getAttribute('data-tid');
+ if (tid && tid.length > 2 && tid.length < 80 && tid.indexOf('video-item-container') !== 0) {
+ name = tid;
+ break;
+ }
+ node = node.parentElement;
+ }
var classes = el.className.split(/\s+/);
return {name: name, classes: classes};
}).filter(function(x) { return x.name; })
@@ -88,11 +95,19 @@ const snapshotJS = `(function() {
const pollJSTemplate = `(function() {
return JSON.stringify(
Array.from(document.querySelectorAll('[data-tid="voice-level-stream-outline"]')).map(function(el) {
- var p = el.parentElement;
- var tid = p ? p.getAttribute('data-tid') : null;
- if (!tid || tid.length <= 2) return null;
+ var node = el.parentElement;
+ var name = null;
+ for (var i = 0; i < 3 && node; i++) {
+ var tid = node.getAttribute('data-tid');
+ if (tid && tid.length > 2 && tid.length < 80 && tid.indexOf('video-item-container') !== 0) {
+ name = tid;
+ break;
+ }
+ node = node.parentElement;
+ }
+ if (!name) return null;
var speaking = el.classList.contains('%s');
- return {name: tid, speaking: speaking};
+ return {name: name, speaking: speaking};
}).filter(Boolean)
);
})()`
diff --git a/internal/conference/teams/teams_dom_test.go b/internal/conference/teams/teams_dom_test.go
new file mode 100644
index 0000000..c8e2855
--- /dev/null
+++ b/internal/conference/teams/teams_dom_test.go
@@ -0,0 +1,286 @@
+package teams_test
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "testing"
+
+ "github.com/odsod/recorder/internal/conference/teams"
+)
+
+// TestSnapshotJS evaluates the snapshot JavaScript expression against a fixture
+// DOM that replicates both Teams nesting patterns (name at parent vs grandparent).
+// This catches regressions where Teams changes their DOM hierarchy.
+func TestSnapshotJS(t *testing.T) {
+ requireNode(t)
+
+ p := teams.New()
+ js := p.SnapshotExpression()
+
+ result := evalWithFixture(t, js, teamsFixtureHTML)
+
+ snapshots, err := p.ParseSnapshot(result)
+ if err != nil {
+ t.Fatalf("ParseSnapshot: %v", err)
+ }
+
+ want := map[string]bool{
+ "Alice Direct": true, // name on parent (depth 1)
+ "Bob Nested": true, // name on grandparent (depth 2)
+ "Carol Wrapped": true, // name on grandparent, with video-item-container in between
+ }
+
+ if len(snapshots) != len(want) {
+ t.Fatalf("expected %d participants, got %d: %+v", len(want), len(snapshots), snapshots)
+ }
+ for _, s := range snapshots {
+ if !want[s.Name] {
+ t.Errorf("unexpected participant: %q", s.Name)
+ }
+ if len(s.Classes) == 0 {
+ t.Errorf("participant %q has no classes", s.Name)
+ }
+ }
+}
+
+// TestPollJS evaluates the poll JavaScript expression and verifies it correctly
+// reports speaking state across both nesting patterns.
+func TestPollJS(t *testing.T) {
+ requireNode(t)
+
+ p := teams.New()
+ pollJS, err := p.PollExpression("speaking-active")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result := evalWithFixture(t, pollJS, teamsFixtureHTML)
+
+ participants, err := p.ParsePoll(result)
+ if err != nil {
+ t.Fatalf("ParsePoll: %v", err)
+ }
+
+ want := map[string]bool{
+ "Alice Direct": true, // has speaking-active class
+ "Bob Nested": false, // does not have speaking-active class
+ "Carol Wrapped": true, // has speaking-active class
+ }
+
+ if len(participants) != len(want) {
+ t.Fatalf("expected %d participants, got %d: %+v", len(want), len(participants), participants)
+ }
+ for _, p := range participants {
+ wantSpeaking, ok := want[p.Name]
+ if !ok {
+ t.Errorf("unexpected participant: %q", p.Name)
+ continue
+ }
+ if p.Speaking != wantSpeaking {
+ t.Errorf("participant %q: speaking = %v, want %v", p.Name, p.Speaking, wantSpeaking)
+ }
+ }
+}
+
+// TestSnapshotJS_SkipsShortAndLongNames verifies the name length filter.
+func TestSnapshotJS_SkipsShortAndLongNames(t *testing.T) {
+ requireNode(t)
+
+ p := teams.New()
+ js := p.SnapshotExpression()
+
+ html := `
+
+
+
+ `
+
+ result := evalWithFixture(t, js, html)
+
+ snapshots, err := p.ParseSnapshot(result)
+ if err != nil {
+ t.Fatalf("ParseSnapshot: %v", err)
+ }
+
+ if len(snapshots) != 1 {
+ t.Fatalf("expected 1 participant (length-filtered), got %d: %+v", len(snapshots), snapshots)
+ }
+ if snapshots[0].Name != "Valid Name" {
+ t.Errorf("expected 'Valid Name', got %q", snapshots[0].Name)
+ }
+}
+
+// teamsFixtureHTML replicates the two nesting patterns observed in live Teams meetings:
+// - "Direct": name data-tid on the immediate parent of voice-level-stream-outline
+// - "Nested": an extra wrapper div between voice-level-stream-outline and the named ancestor
+// - "Wrapped": video-item-container intermediate that should be skipped
+const teamsFixtureHTML = `
+
+`
+
+func requireNode(t *testing.T) {
+ t.Helper()
+ if _, err := exec.LookPath("node"); err != nil {
+ t.Skip("node not available")
+ }
+}
+
+func longName(n int) string {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = 'x'
+ }
+ return string(b)
+}
+
+// evalWithFixture evaluates a JavaScript expression against a fixture HTML
+// document using Node.js with a minimal DOM shim (linkedom-compatible subset).
+func evalWithFixture(t *testing.T, jsExpr, html string) string {
+ t.Helper()
+
+ script := domShimScript(html, jsExpr)
+
+ f, err := os.CreateTemp("", "teams-js-test-*.mjs")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = os.Remove(f.Name()) }()
+
+ if _, err := f.WriteString(script); err != nil {
+ t.Fatal(err)
+ }
+ _ = f.Close()
+
+ ctx := context.Background()
+ cmd := exec.CommandContext(ctx, "node", f.Name())
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("node failed: %v\noutput: %s", err, out)
+ }
+
+ // Validate it's valid JSON
+ var raw json.RawMessage
+ if err := json.Unmarshal(out, &raw); err != nil {
+ t.Fatalf("output is not valid JSON: %s", out)
+ }
+
+ return string(out)
+}
+
+// domShimScript builds a self-contained Node.js script that parses HTML with a
+// minimal DOM implementation and evaluates the given expression.
+func domShimScript(html, jsExpr string) string {
+ htmlJSON, _ := json.Marshal(html)
+ exprJSON, _ := json.Marshal(jsExpr)
+
+ return `
+// Minimal DOM shim: implements only the subset used by Teams JS expressions.
+class Element {
+ constructor(tagName, attrs, parent) {
+ this.tagName = tagName;
+ this.attributes = new Map(Object.entries(attrs || {}));
+ this.parentElement = parent || null;
+ this.children = [];
+ this.className = this.attributes.get('class') || '';
+ const classSet = new Set(this.className.split(/\s+/).filter(Boolean));
+ this.classList = { contains: (c) => classSet.has(c) };
+ }
+ getAttribute(name) { return this.attributes.get(name) || null; }
+ querySelectorAll(selector) { return querySelectorAll(this, selector); }
+}
+
+function querySelectorAll(root, selector) {
+ const match = selector.match(/^\[([^=]+)="([^"]+)"\]$/);
+ if (!match) throw new Error('unsupported selector: ' + selector);
+ const [, attr, value] = match;
+ const results = [];
+ function walk(el) {
+ if (el.getAttribute(attr) === value) results.push(el);
+ for (const child of el.children) walk(child);
+ }
+ walk(root);
+ return results;
+}
+
+function parseHTML(html) {
+ // Simple HTML parser: extracts nested div elements with their attributes.
+ const root = new Element('BODY', {}, null);
+ const stack = [root];
+
+ const tagRe = /<\/?([a-z]+)([^>]*)>/gi;
+ let m;
+ while ((m = tagRe.exec(html)) !== null) {
+ const [full, tag, attrStr] = m;
+ if (full.startsWith('')) {
+ if (stack.length > 1) stack.pop();
+ continue;
+ }
+ if (tag === '!--' || tag === 'html' || tag === 'head' || tag === 'meta' || tag === 'link') continue;
+
+ const attrs = {};
+ const attrRe = /([a-z][\w-]*)="([^"]*)"/gi;
+ let am;
+ while ((am = attrRe.exec(attrStr)) !== null) {
+ attrs[am[1]] = am[2];
+ }
+
+ const parent = stack[stack.length - 1];
+ const el = new Element(tag.toUpperCase(), attrs, parent);
+ parent.children.push(el);
+
+ // Self-closing tags
+ if (!full.endsWith('/>') && !['br','hr','img','input'].includes(tag)) {
+ stack.push(el);
+ }
+ }
+ return root;
+}
+
+const html = ` + string(htmlJSON) + `;
+const document = parseHTML(html);
+
+// Make Array.from work on our arrays (it already does in Node)
+const expr = ` + string(exprJSON) + `;
+const result = eval(expr);
+process.stdout.write(result);
+`
+}
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)
+}