Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
bde991a
fix(episodes): every LLM transcript labels voices densely, never raw …
Git-on-my-level Oct 23, 2026
dd347bf
test(backend): guard every LLM formatter against raw speaker-id labels
Git-on-my-level Oct 23, 2026
7dd7948
Merge live speaker formatter dependency (#23404)
Git-on-my-level Oct 23, 2026
a3c16a0
fix(speakers): add typed identity, count and presentation guardrails
Git-on-my-level Oct 23, 2026
d78dfa0
chore(api): refresh speaker display contract snapshot
Git-on-my-level Oct 24, 2026
f79f136
chore(clients): regenerate safe speaker metadata types
Git-on-my-level Oct 24, 2026
0f34fb0
docs: align historical reload pointer with main
Git-on-my-level Oct 24, 2026
63a27d4
fix(notes): People names bind to a speaker only with this conversatio…
Git-on-my-level Oct 24, 2026
53249b5
fix(notes): type identity name lists for the backend typecheck
Git-on-my-level Oct 24, 2026
bd421e2
Merge current main including #23472 into PR #23462
Git-on-my-level Oct 24, 2026
12eafa7
test(speakers): repair CI loaders and canonical display assertions
Git-on-my-level Oct 24, 2026
4397f19
test(app): drop duplicate import in speaker names test
Git-on-my-level Oct 24, 2026
2674101
Merge current main projection fixes into PR #23462
Git-on-my-level Oct 24, 2026
0961af8
chore: integrate speaker identity prerequisites for live epochs
Git-on-my-level Oct 24, 2026
46d645c
fix(speakers): defer short live epoch ordinals and gate acoustic grou…
Git-on-my-level Oct 24, 2026
dc9f8ba
test(speakers): align inherited assertions with typed prompt identities
Git-on-my-level Oct 24, 2026
ce89b72
test(backend): wake-word renderer expects opaque speaker prompt keys
Git-on-my-level Oct 24, 2026
0c85589
fix(speakers): keep live excerpt names bound to their evidence
Git-on-my-level Oct 24, 2026
73bb9ef
Merge published speaker CI fixes before the remaining loader repair
Git-on-my-level Oct 24, 2026
c518a37
fix(notes): defer known_spellings import so isolated models loads sta…
Git-on-my-level Oct 24, 2026
79083a0
test(backend): isolate spelling database reads in date harness
Git-on-my-level Oct 24, 2026
38397a1
chore: refresh speaker identity prerequisite
Git-on-my-level Oct 24, 2026
c34e9ce
chore: refresh main before live speaker epoch publication
Git-on-my-level Oct 24, 2026
e505182
fix(app): count distinct participant identities with shared names
Git-on-my-level Oct 24, 2026
6a74ad9
Merge published lazy spelling import repair
Git-on-my-level Oct 24, 2026
f2194d4
test(backend): remove adapters superseded by lazy spelling import
Git-on-my-level Oct 24, 2026
56d8709
Merge current main while retaining canonical speaker presentation
Git-on-my-level Oct 24, 2026
876034f
perf(test): avoid counter subprocesses in isolated worker polling
Git-on-my-level Oct 24, 2026
2c2f04e
test(ci): retain main full-scan speaker exemption boundary
Git-on-my-level Oct 24, 2026
ff78741
test(backend): preserve distinct labels in whole-transcript hash asse…
Git-on-my-level Oct 24, 2026
8999b65
perf(test): wait for tracked worker completions without busy polling
Git-on-my-level Oct 24, 2026
0e77171
test(backend): synchronize heartbeat drain readiness explicitly
Git-on-my-level Oct 24, 2026
d9be3ed
perf(test): avoid unused plugins in isolated pytest workers
Git-on-my-level Oct 24, 2026
f539d67
Merge remote-tracking branch 'origin/main' into ci-fix/speaker-identi…
Git-on-my-level Oct 24, 2026
02a2b6a
fix(backend): type defensive retrieval and duration boundaries accura…
Git-on-my-level Oct 24, 2026
9e9606e
fix(backend): retain nullable query narrowing before text validation
Git-on-my-level Oct 24, 2026
ed60c62
chore: integrate reconciled speaker identity prerequisite
Git-on-my-level Oct 24, 2026
aabf467
chore: integrate current main after speaker guardrails reconciliation
Git-on-my-level Oct 24, 2026
5ce61f3
test(speakers): qualify swapped provider labels after listen reconnect
Git-on-my-level Oct 24, 2026
d33179a
test(speakers): qualify legacy live labels with placed audio clocks
Git-on-my-level Oct 24, 2026
18d27ad
test(backend): initialize Wrapped router dependencies in the class fi…
Git-on-my-level Oct 24, 2026
49041fd
fix(backend): normalize result text explicitly and load pure test dep…
Git-on-my-level Oct 24, 2026
0d935c3
test(backend): load real result-bound helpers in the product-info fix…
Git-on-my-level Oct 24, 2026
2b8313b
test(web): await the scheduled task-row hover control
Git-on-my-level Oct 24, 2026
7ae989f
chore: refresh main before live epoch publication
Git-on-my-level Oct 24, 2026
e826062
Merge current main and preserve validated CI boundary repairs
Git-on-my-level Oct 24, 2026
b6bd0a8
test(backend): initialize isolated payment router in fixture setup
Git-on-my-level Oct 24, 2026
6a08efe
fix(speakers): retain qualified live labels across moment deltas
Git-on-my-level Oct 24, 2026
4b760e4
chore: refresh speaker prerequisite before live epoch publication
Git-on-my-level Oct 24, 2026
ff289bf
chore: refresh main before live epoch publication
Git-on-my-level Oct 24, 2026
c499d99
fix(notes): preserve contact mentions and developer speaker identities
Git-on-my-level Oct 24, 2026
0d323ce
test(speakers): fix timestamps in raw-identity assertions
Git-on-my-level Oct 24, 2026
9292145
chore: integrate refreshed speaker identity prerequisite
Git-on-my-level Oct 24, 2026
9e23b44
chore: reconcile current main with live speaker epochs
Git-on-my-level Oct 24, 2026
d544d26
test(ci): align fixture setup with current main
Git-on-my-level Oct 24, 2026
972cefe
chore: refresh main before live epoch publication
Git-on-my-level Oct 24, 2026
2d2b789
chore: refresh prerequisite before main fixture reconciliation
Git-on-my-level Oct 24, 2026
07c1112
chore: retain current main payment fixture isolation
Git-on-my-level Oct 24, 2026
fe4adf5
chore: refresh main before live epoch publication
Git-on-my-level Oct 24, 2026
5871054
test(ci): keep merged fixtures coherent with current main
Git-on-my-level Oct 24, 2026
ebf26f6
chore: refresh main before route fixture repair
Git-on-my-level Oct 24, 2026
8385c9c
test(speakers): align external route fixture with rendering contract
Git-on-my-level Oct 24, 2026
2f14c47
chore: reconcile episode and registry additions from main
Git-on-my-level Oct 24, 2026
72ad5c1
fix(ci): avoid adjacent additions while preserving guardrail coverage
Git-on-my-level Oct 24, 2026
a201506
chore: integrate prerequisite CI fixture coherence repair
Git-on-my-level Oct 24, 2026
cb9c61f
chore: integrate capture evidence and speaker fixture dependencies
Git-on-my-level Oct 24, 2026
1f1c239
test(ci): compose capture evidence and speaker fixture imports
Git-on-my-level Oct 24, 2026
eaeba35
chore: integrate prerequisite fixture placement repair
Git-on-my-level Oct 24, 2026
5011a0f
chore: reconcile bounded episode receipts with speaker fixtures
Git-on-my-level Oct 24, 2026
16a4fb2
chore: refresh main before live epoch publication
Git-on-my-level Oct 24, 2026
072a983
test(ci): isolate speaker stubs from evolving episode reset fixture
Git-on-my-level Oct 24, 2026
1f55d8f
chore: align shared speaker-name fixture with prerequisite
Git-on-my-level Oct 24, 2026
b8b97bd
chore: refresh main before live epoch publication
Git-on-my-level Oct 24, 2026
7d400b8
chore: integrate merged speaker identity prerequisite from main
Git-on-my-level Oct 25, 2026
4342fb7
Merge origin/main into lane/live-speaker-epochs
Git-on-my-level Oct 25, 2026
65fdab9
fix(speakers): defer provisional cross-epoch owner competition
Git-on-my-level Oct 25, 2026
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
1 change: 1 addition & 0 deletions app/changelog/unreleased/live-speaker-epochs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"change":"Short unidentified live turns stay unnamed across reconnects instead of creating a new numbered speaker for each socket."}
1 change: 1 addition & 0 deletions app/docs/ux-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ participant lists, the speaker filter and every copied, shared or exported trans
- Everyone else is **Speaker N**, N dense per conversation (1, 2, 3… in order of first
appearance, skipping the owner and Omi). Never a raw id, never a gap. Naming a person does not
renumber the others. `TranscriptSegment.getDisplaySpeakerId` returns the same N.
- Live capture passes `deferShortVoices: true`: unnamed voices remain plain **Speaker** until five seconds of non-overlapping placed speech. Unplaced text cannot qualify. Number at qualification time so a short earlier voice cannot renumber an established voice; owner and known-person names still use their existing authority. This display floor does not identify or merge voices.
- When the server could not resolve speakers across the conversation (`speaker_resolution.status`
is `unavailable`), raw ids from different recordings are not comparable, so an unnamed voice is
the plain **Speaker** (`l10n.unnamedSpeakerLabel`), never "Speaker ?" and never a number, and the
Expand Down
3 changes: 2 additions & 1 deletion app/lib/pages/episodes/live_episode_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,8 @@ class LiveEpisodePageState extends State<LiveEpisodePage> with SingleTickerProvi
final key = Object.hash(capture.segmentsPhotosVersion, identityHashCode(people));
if (_namesKey != key || _names == null) {
_namesKey = key;
_names = SpeakerNames.forSegments(capture.segments, people: people ?? const <Person>[], l10n: context.l10n);
_names = SpeakerNames.forSegments(capture.segments,
people: people ?? const <Person>[], l10n: context.l10n, deferShortVoices: true);
}
return _names!;
}
Expand Down
3 changes: 2 additions & 1 deletion app/lib/pages/episodes/live_transcript_view.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';

Check warning on line 1 in app/lib/pages/episodes/live_transcript_view.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/pages/episodes/live_transcript_view.dart is 842 lines; consider splitting files over 800 lines.

import 'package:provider/provider.dart';

Expand Down Expand Up @@ -175,7 +175,8 @@
// list, while its old delegate still has the previous itemCount. Keep one
// membership snapshot for that delegate, its names and decorations.
_timelineSegments = List<TranscriptSegment>.of(provider.segments);
_timelineNames = SpeakerNames.forSegments(_timelineSegments!, people: people, l10n: context.l10n);
_timelineNames =
SpeakerNames.forSegments(_timelineSegments!, people: people, l10n: context.l10n, deferShortVoices: true);
_timelineDecorations = LiveTranscriptDecorations.of(widget.episode, conversationId, _timelineSegments!);
_timelinePhotos = List<ConversationPhoto>.from(provider.photos)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
Expand Down
53 changes: 50 additions & 3 deletions app/lib/ui/format/speaker_names.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import 'package:omi/ui/format/diarization_label.dart';
/// ```
class SpeakerNames {
static const maxUnnamedVoices = 8;
SpeakerNames._(this._ordinals, this._peopleById, this._ownerName, this._l10n, this._unresolved, this._collapsed);

// Display qualification only; voice linking still requires backend acoustic evidence.
static const minLiveVoiceSeconds = 5.0;
SpeakerNames._(this._ordinals, this._peopleById, this._ownerName, this._l10n, this._unresolved, this._collapsed,
this._deferShortVoices);

/// Resolver for [segments] (the whole conversation, so numbering matches every surface).
///
Expand All @@ -37,15 +41,20 @@ class SpeakerNames {
List<Person> people = const [],
String? ownerName,
bool unresolved = false,
bool deferShortVoices = false,
required AppLocalizations l10n,
}) {
return SpeakerNames._(
{for (final entry in denseOrdinals(segments).entries) DiarizationLabel.fromRaw(entry.key): entry.value},
{
for (final entry in (deferShortVoices ? _qualifiedLiveOrdinals(segments) : denseOrdinals(segments)).entries)
DiarizationLabel.fromRaw(entry.key): entry.value
},
{for (final person in people) person.id: person},
ownerName == null || ownerName.trim().isEmpty ? null : ownerName.trim(),
l10n,
unresolved,
_surplusVoices(segments, people),
deferShortVoices,
);
}

Expand All @@ -55,6 +64,7 @@ class SpeakerNames {
final String? _ownerName;
final AppLocalizations _l10n;
final bool _unresolved;
final bool _deferShortVoices;

/// The name for [segment]'s speaker. [person] overrides the people lookup (callers that already
/// resolved it).
Expand Down Expand Up @@ -91,7 +101,9 @@ class SpeakerNames {
/// "Speaker N" for a diarized [speakerId], with N from [ordinalFor].
String anonymousName(int speakerId) {
if (speakerId == omiSpeakerId) return _l10n.omiAppName;
if (_unresolved) return _l10n.unnamedSpeakerLabel;
if (_unresolved || (_deferShortVoices && !_ordinals.containsKey(DiarizationLabel.fromRaw(speakerId)))) {
return _l10n.unnamedSpeakerLabel;
}
if (_collapsed.contains(DiarizationLabel.fromRaw(speakerId))) return _l10n.othersSpeakerLabel;
return _l10n.speakerWithId('${ordinalFor(speakerId)}');
}
Expand All @@ -112,6 +124,41 @@ class SpeakerNames {
return ordinals;
}

/// Allocate at the turn that completes five non-overlapping placed seconds.
/// A short early voice qualifying later cannot renumber an already qualified voice.
static Map<int, int> _qualifiedLiveOrdinals(List<TranscriptSegment> segments) {
final ranges = <int, List<(double, double)>>{};
for (final segment in segments) {
if (segment.isUser || segment.speakerId == omiSpeakerId) continue;
final start = segment.start, end = segment.end;
if (!start.isFinite || !end.isFinite || start < 0 || end <= start) continue;
ranges.putIfAbsent(segment.speakerId, () => []).add((start, end));
}
final qualifiedAt = <int, double>{};
for (final entry in ranges.entries) {
final voiceRanges = entry.value..sort((a, b) => a.$1.compareTo(b.$1));
var coveredEnd = double.negativeInfinity, seconds = 0.0;
for (final range in voiceRanges) {
final uncoveredStart = range.$1 > coveredEnd ? range.$1 : coveredEnd;
final fresh = range.$2 > uncoveredStart ? range.$2 - uncoveredStart : 0.0;
if (seconds + fresh >= minLiveVoiceSeconds) {
qualifiedAt[entry.key] = uncoveredStart + minLiveVoiceSeconds - seconds;
break;
}
seconds += fresh;
if (range.$2 > coveredEnd) coveredEnd = range.$2;
}
}
final appearance = {for (final entry in ranges.keys.indexed) entry.$2: entry.$1};
final qualified = qualifiedAt.keys.toList()
..sort((a, b) {
final byTime = qualifiedAt[a]!.compareTo(qualifiedAt[b]!);
return byTime != 0 ? byTime : appearance[a]!.compareTo(appearance[b]!);
});
final ordinals = {for (final entry in qualified.indexed) entry.$2: entry.$1 + 1};
return ordinals;
}

/// Presentation mitigation only: keep at most seven substantial unknown voices plus Others.
/// Known people and the owner are never merged. Raw identity remains available for correction.
static Set<DiarizationLabel> _surplusVoices(List<TranscriptSegment> segments, List<Person> people) {
Expand Down
1 change: 1 addition & 0 deletions app/lib/widgets/transcript.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:convert';

Check warning on line 1 in app/lib/widgets/transcript.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/widgets/transcript.dart is 1590 lines; consider splitting files over 800 lines.
import 'dart:math';

import 'package:omi/utils/platform/platform_manager.dart';
Expand Down Expand Up @@ -809,6 +809,7 @@
people: people,
l10n: context.l10n,
unresolved: widget.unresolvedSpeakers,
deferShortVoices: widget.followLatest,
);
final searchBarHeight = widget.searchQuery.isNotEmpty ? 100.0 : 0.0;
final transcriptList = NotificationListener<ScrollMetricsNotification>(
Expand Down Expand Up @@ -944,7 +945,7 @@
);
}

Widget _buildSegmentItem(int segmentIdx, List<Person> people, SpeakerNames names, Set<String> askSegmentIds) {

Check warning on line 948 in app/lib/widgets/transcript.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

Widget _buildSegmentItem(int segmentIdx, List<Person> people, SpeakerNames names is 218 lines; consider extracting focused helpers over 150 lines.
if (widget.isConversationDetail) return _buildDetailLine(segmentIdx, people, names, askSegmentIds);
final data = widget.segments[segmentIdx];
final Person? person = personById(people, data.personId);
Expand Down
39 changes: 39 additions & 0 deletions app/test/unit/ui_format/speaker_names_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
Person(id: id, name: name, createdAt: DateTime(2026), updatedAt: DateTime(2026));

/// One speaker vocabulary on every surface: "You", people's names, "Omi", and dense "Speaker N".
void main() {

Check warning on line 36 in app/test/unit/ui_format/speaker_names_test.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

void main() is 208 lines; consider extracting focused helpers over 150 lines.
final en = lookupAppLocalizations(const Locale('en'));

test('on-device engines carry no speaker label in UI or transcript exports', () {
Expand All @@ -59,6 +59,45 @@
expect(SpeakerNames.forSegments(before, l10n: en).forSegment(before[2]), 'Speaker 3');
});

test('live short socket epochs stay unnamed and do not spend ordinals', () {
final rows = [for (var id = 4; id <= 8; id++) seg(id)];
final names = SpeakerNames.forSegments(rows, deferShortVoices: true, l10n: en);
expect(rows.map(names.forSegment).toSet(), {'Speaker'});
expect(rows.map((r) => r.speakerId).toSet().length, 5);
final qualified = [...rows, seg(20)..end = 5, seg(21)..end = 5];
final next = SpeakerNames.forSegments(qualified, deferShortVoices: true, l10n: en);
expect(qualified.take(5).map(next.forSegment).toSet(), {'Speaker'});
expect(qualified.skip(5).map(next.forSegment), ['Speaker 1', 'Speaker 2']);
expect(next.anonymousName(50), 'Speaker', reason: 'new arrivals have no evidence yet');
});

test('live evidence pools short turns once and allocates in qualification order', () {
final first = seg(4)..end = 2.5;
final second = seg(4)
..start = 3
..end = 5.5;
final substantial = seg(8)..end = 5;
final rows = [first, substantial, second];
final names = SpeakerNames.forSegments(rows, deferShortVoices: true, l10n: en);
expect(names.forSegment(substantial), 'Speaker 1');
expect(names.forSegment(first), 'Speaker 2');
final repeats = [
first,
seg(4)..end = 4.99,
seg(9)
..start = -1
..end = -1
];
final pending = SpeakerNames.forSegments(repeats, deferShortVoices: true, l10n: en);
expect(repeats.map(pending.forSegment).toSet(), {'Speaker'});
});

test('live deferral preserves owner and manual names without guessing another owner', () {
final rows = [seg(4, isUser: true), seg(5, personId: 'ada'), seg(6), seg(7, isUser: true)];
final names = SpeakerNames.forSegments(rows, people: [person('ada', 'Ada')], deferShortVoices: true, l10n: en);
expect(rows.map(names.forSegment), ['You', 'Ada', 'Speaker', 'You']);
});

test('Omi is "Omi", never "Speaker 100", and never takes a number', () {
final segments = [seg(omiSpeakerId), seg(4)];
final names = SpeakerNames.forSegments(segments, l10n: en);
Expand Down
26 changes: 26 additions & 0 deletions app/test/widgets/transcript_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:convert';

Check warning on line 1 in app/test/widgets/transcript_test.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/test/widgets/transcript_test.dart is 1143 lines; consider splitting files over 800 lines.

import 'package:flutter/gestures.dart' show kDoubleTapTimeout;
import 'package:flutter/material.dart';
Expand All @@ -18,7 +18,7 @@
import 'package:omi/providers/people_provider.dart';
import 'package:provider/provider.dart';

void main() {

Check warning on line 21 in app/test/widgets/transcript_test.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

void main() is 1123 lines; consider extracting focused helpers over 150 lines.
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
Expand Down Expand Up @@ -90,6 +90,32 @@
expect(find.text('Speaker 1'), findsOneWidget);
});

testWidgets('live short epochs render unnamed while preserving correction identities', (tester) async {
await setupSharedPreferences();
await tester.binding.setSurfaceSize(const Size(800, 1000));
addTearDown(() => tester.binding.setSurfaceSize(null));
final rows = [for (var id = 4; id <= 8; id++) segmentFor('epoch-$id', id)];
final edited = <int>[];
await tester.pumpWidget(MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: TranscriptWidget(
segments: rows,
followLatest: true,
editSegment: (_, id) => edited.add(id),
)),
));
await tester.pumpAndSettle();
expect(find.text('Speaker'), findsNWidgets(5));
for (var id = 1; id <= 8; id++) {
expect(find.text('Speaker $id'), findsNothing);
}
await tester.tap(find.text('Speaker').at(1));
await tester.pump();
expect(edited, [5]);
});

group('Speaker label display', () {
for (final source in ['auto', 'manual']) {
testWidgets('Omi speaker with $source label stays inert', (tester) async {
Expand Down
62 changes: 62 additions & 0 deletions backend/docs/listen_pusher_pipeline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6800,3 +6800,65 @@ part receipt and advances the owner watermark only with the final assignment
receipt transaction. Successors can drain while held uploads stay incomplete
and outside completion retention. See [owner repair](runbooks/batch-projection-repair.md)
for the dry-run operator tool, 15-minute obligation metric, and lead-only recovery.


## Live speaker epochs: bounded safe repair

Soniox idle reopen continues to stamp a new socket epoch. Listen reconnect
continues to hydrate conversation-local reservations without equating provider
labels across scopes. Raw and manual correction keys are unchanged.

Live moments and Flutter live transcript naming defer a new unnamed ordinal
until the voice has at least the shared policy's five seconds of non-overlapping,
placed speech. Unknown short epochs keep separate identity keys and a plain
unnamed label; they consume no live moment voice-order ledger slots. Names and
owner attribution still require their existing authorities. Display duration is
not acoustic evidence and never links identities. Saved transcript numbering
retains its existing behavior.

The persisted moment voice-order ledger also carries display qualification:
later short or unplaced replies with the same conversation and scoped voice
keep their established ordinal. A new scope or conversation cannot borrow that
qualification and must satisfy the floor independently.

The live owner's existing cross-epoch acoustic competition grouping now requires
five seconds of extracted evidence on **both** voices, the shared `CROSS_EPOCH`
strict cosine-distance boundary and `MATCH_MARGIN`, and complete-link agreement
with every peer. Same-scope provider voices cannot join. Shortened owner
continuity proofs keep their separate receipt-fenced policy and cannot supply
voice-grouping evidence. Until the five-second floor, those provisional voices
sit out cross-scope owner competition without being grouped; each still needs
its own valid owner-continuity proof. Same-scope voices, manual reservations and
retained competition-only evidence continue to constrain owner claims. Once both
voices qualify, ordinary strict grouping and joint arbitration apply again.
Ambiguous, invalid or weak vectors abstain.

Acceptance: `tests/unit/test_live_speaker_epochs.py` drives the real Soniox idle
close/reopen adapter, epoch stamping/allocation, short-turn embedding pooling,
owner arbitration and a fresh listen matcher. Deterministic embeddings prove
control-flow policy, not production acoustic accuracy. Flutter naming tests
verify short Speaker 4–8 epochs spend no ordinals and qualified distinct IDs
retain distinct labels.

### Remaining live anonymous linking (lead follow-up)

This safe repair does **not** reuse anonymous conversation IDs across epochs or
replicas. A voice with five seconds in each of several epochs can still acquire
several anonymous ordinals. Owner matching re-acquires against enrollment after
reconnect; no owner is inferred from a provider label or from being the only voice.

Full anonymous linking needs a bounded encrypted conversation-voice evidence
handoff, tied to conversation and manual-receipt generations, model/policy
version, and audio provenance. Persist a separate reversible presentation alias
rather than changing manual correction keys. Require five seconds on each side,
a strict cross-epoch threshold and runner-up margin, complete-link agreement,
and same-scope/overlap cannot-link constraints. Recheck manual authority before
publication, retract aliases on contradictory evidence/correction, and fence
concurrent replicas and finalization. Qualify with real audio across socket and
replica reconnects before rollout. Do not copy the owner-only continuity capsule
into a generic voice cache without those fences.

Unplaced segments still bypass matching. Their capture-placement repair belongs
to the live timeline lane; this change never invents offsets to obtain evidence.
No production configuration flip is required or performed. Backend/listen images
and a Flutter build are required to deliver the corresponding safe changes.
40 changes: 20 additions & 20 deletions backend/routers/listen/speakers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Speaker assignment state and embedding matching for listen sessions."""

Check warning on line 1 in backend/routers/listen/speakers.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/routers/listen/speakers.py is 1499 lines; consider splitting files over 800 lines.

from __future__ import annotations

Expand Down Expand Up @@ -29,11 +29,11 @@
from utils.speaker_sample import download_sample_audio
from utils.speaker_sample_migration import maybe_migrate_person_samples
from utils.manual_speaker_assignments import manual_owner_reserved, manual_rejected_speakers
from utils.speakers.policy import CROSS_EPOCH
from utils.stt.owner_profile import load_owner_embedding, validated_embedding
from utils.stt.speaker_embedding import compare_embeddings, extract_embedding_from_bytes
from utils.stt.speaker_match import (
select_speaker_match,
group_epoch_voices,
SPEAKER_MATCH_MAX_CLIPS,
SPEAKER_MATCH_MIN_EVIDENCE_SECONDS,
SpeakerMatchDecision,
Expand Down Expand Up @@ -199,7 +199,7 @@
"""Manual receipt ids copied by this rollover; automatic ids join after validation."""
self._pending_rollover_carry = set(carried_speaker_ids)

async def refresh_for_conversation(

Check warning on line 202 in backend/routers/listen/speakers.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

refresh_for_conversation is 230 lines; consider extracting focused helpers over 150 lines.
self,
conversation_id: str,
*,
Expand Down Expand Up @@ -397,6 +397,8 @@
{v: d for v, d in self._voice_decisions.items() if v not in rejected},
owner_reserved=final_authority.owner_reserved,
voice_groups=self._provider_epoch_voice_groups(),
voice_scopes=self._voice_scopes,
evidence_seconds=self._provider_epoch_evidence_seconds(),
)
# Runner-up evidence constrains the owner but never automatically
# carries a non-owner identity across a conversation boundary.
Expand Down Expand Up @@ -610,6 +612,8 @@
automatic,
owner_reserved=final_authority.owner_reserved,
voice_groups=self._provider_epoch_voice_groups(),
voice_scopes=self._voice_scopes,
evidence_seconds=self._provider_epoch_evidence_seconds(),
)
self._publish_decisions(decisions)
await self.continuity.update(receipt, force=True, generation=generation)
Expand Down Expand Up @@ -1005,7 +1009,7 @@
return None
return decision

async def _match_unmapped(

Check warning on line 1012 in backend/routers/listen/speakers.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

_match_unmapped is 307 lines; consider extracting focused helpers over 150 lines.
self, speaker_id: int, segment: dict[str, Any], generation: int, conversation_id: Optional[str]
) -> None:
short_reconnect = False
Expand Down Expand Up @@ -1231,6 +1235,8 @@
{v: d for v, d in self._voice_decisions.items() if v not in rejected},
owner_reserved=owner_reserved,
voice_groups=voice_groups,
voice_scopes=self._voice_scopes,
evidence_seconds=self._provider_epoch_evidence_seconds(),
)
decision = decisions.get(speaker_id)
self._rejectors.comparison(speaker_id, decision, evidence_seconds)
Expand Down Expand Up @@ -1436,26 +1442,20 @@
"""
reconcile_pinned_suggestion(self, voice, result, pinned, segment_id, assigned)

def _provider_epoch_evidence_seconds(self) -> Dict[int, float]:
return {
voice: sum(seconds for _, seconds in evidence)
for voice, evidence in self.speaker_evidence.items()
if voice not in self._competition_only
}

def _provider_epoch_voice_groups(self) -> Dict[int, int]:
"""Reconcile a voice only across stamped provider epochs with close audio."""
groups: Dict[int, int] = {}
members: Dict[int, list[int]] = {}
for voice, centroid in self._voice_centroids.items():
scope = self._voice_scopes.get(voice)
group = voice
if scope:
for representative, peers in members.items():
if all(
self._voice_scopes.get(peer)
and self._voice_scopes[peer] != scope
and compare_embeddings(centroid, self._voice_centroids[peer]) < CROSS_EPOCH.distance
for peer in peers
):
group = representative
break
groups[voice] = group
members.setdefault(group, []).append(voice)
return groups
return group_epoch_voices(
self._voice_centroids,
self._voice_scopes,
self._provider_epoch_evidence_seconds(),
distance=compare_embeddings,
)

async def drain(self, *, timeout: float, label: str) -> None:
if self.tasks:
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/unit/test_episodes_incremental_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_render_moment_joins_voices_and_bounds_long_text():
{'text': ' '},
]
)
assert rendered.text == 'You: hello there still me\nSpeaker A: hi\nMinh: named'
assert rendered.text == 'You: hello there still me\nUnknown speaker: hi\nMinh: named'
assert rendered.words == 6
long = incremental.render_moment([{'text': 'word ' * 4000, 'is_user': True}])
assert len(long.text) <= config.MOMENT_TEXT_MAX_CHARS and '[…]' in long.text
Expand Down
Loading
Loading