Skip to content

feat(meeting): in-person mode with per-speaker mic diarization - #100

Open
gbrunoo wants to merge 2 commits into
sebsto:mainfrom
gbrunoo:feat/in-person-meeting-mode
Open

feat(meeting): in-person mode with per-speaker mic diarization#100
gbrunoo wants to merge 2 commits into
sebsto:mainfrom
gbrunoo:feat/in-person-meeting-mode

Conversation

@gbrunoo

@gbrunoo gbrunoo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Add an Online/In person capture mode to the meeting window. In-person records the microphone only (no system audio, no Screen Recording permission) and diarizes the mic track so each person in the room becomes a numbered speaker, with no privileged "You".

  • MeetingMode persisted on MeetingTranscript (decodeIfPresent, defaults to online so archived transcripts keep rendering)
  • Timestamped MicAudioChunk so the diarizer has a mic timeline
  • In-person forces diarization on and bypasses echo suppression (no remote audio to echo); labels reuse .others(speakerIndex:)
  • Header mode toggle (locked while recording); in-person shows a single Room level; up to four participants (Sortformer limit)
  • History sidebar now closed by default; opening it widens the panel and closing restores the prior compact width unless the user resized it
  • Header controls never wrap: Start/Stop and the level indicator collapse to icon-only / hide when the window is too narrow
image image image

Add an Online/In person capture mode to the meeting window. In-person
records the microphone only (no system audio, no Screen Recording
permission) and diarizes the mic track so each person in the room becomes
a numbered speaker, with no privileged "You".

- MeetingMode persisted on MeetingTranscript (decodeIfPresent, defaults
  to online so archived transcripts keep rendering)
- Timestamped MicAudioChunk so the diarizer has a mic timeline
- In-person forces diarization on and bypasses echo suppression (no
  remote audio to echo); labels reuse .others(speakerIndex:)
- Header mode toggle (locked while recording); in-person shows a single
  Room level; up to four participants (Sortformer limit)
- History sidebar now closed by default; opening it widens the panel and
  closing restores the prior compact width unless the user resized it
- Header controls never wrap: Start/Stop and the level indicator collapse
  to icon-only / hide when the window is too narrow

@sebsto sebsto left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the #97 design — this is a faithful Model B implementation, no silent divergence. Everyone in person becomes a numbered .others(speakerIndex:) with no privileged "You" (so speakerColor/displayName needed no change), MeetingMode rides on MeetingTranscript with a decodeIfPresent .online default, the timestamped MicAudioChunk mirrors SystemAudioChunk (the sample→startTime bookkeeping is line-for-line the same as the system path), diarization is forced on in person, echo suppression is bypassed, the header toggle is locked while recording, and .inPerson returns before touching ScreenCaptureKit so the Screen Recording prompt is genuinely avoided. Cold-start chunks fall back to .others(nil) → "Others", matching the answer to Q3.

Privacy/security is clean: no new network or telemetry, and the debug logs carry sample counts and timestamps, never audio or transcript text. Concurrency is tidy too — the diarizer is driven by exactly one path per mode, so there's no concurrent-ingest race, and the new AppKit callback captures [weak self].

Everything below is Low. The one worth acting on is coverage: the three new behaviors (mode back-compat, the in-person label path, and the echo-suppression bypass) all ship without tests, and this repo normally tests exactly this kind of change.

(No build verification from my side — Apple-platform code; CI on the Mac runners is green.)

self.speakerNames =
try container.decodeIfPresent([String: String].self, forKey: .speakerNames) ?? [:]
self.title = try container.decodeIfPresent(String.self, forKey: .title)
self.mode = try container.decodeIfPresent(MeetingMode.self, forKey: .mode) ?? .online

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Tests] No back-compat decode test for mode. The repo already tests this exact class of change — TranscriptStoreTests.swift:75 ("Legacy JSON without title decodes with no title") and :31 for speakerNames. Worth the sibling test: a legacy transcript JSON with no mode key decodes as .online, and .inPerson survives an encode/decode round-trip. Cheap to add and it locks the archived-file guarantee in place.

/// room saying similar short phrases).
private func record(_ entry: MeetingTranscriptEntry) {
guard settingsStore.meetingEchoSuppressionEnabled else {
guard mode == .online, settingsStore.meetingEchoSuppressionEnabled else {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Maintainability] This reads the live mode rather than the frozen transcript.mode. transcript.mode is documented (MeetingTranscript.swift:89-92) as "Fixed at startMeeting() and never changed mid-session" — it's the canonical session mode, and it's already set at start. Gating on it here (and in transcribeMicAudio/warmUpDiarizerIfEnabled) would make the echo-suppression bypass depend on that invariant instead of on the header staying locked. It's correct today only because the toggle is disabled while recording.

[Tests] The in-person bypass itself is uncovered — MeetingEchoSuppressionTests drive appendSuppressingEcho at the transcript level, not the mode gate in record(), so nothing asserts that in person a would-be echo is kept.

speakerIndex = await diarizer.dominantSpeaker(
in: chunk.startTime...chunkEnd)
}
speaker = .others(speakerIndex: speakerIndex)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Tests] The in-person label path has no coverage. Nothing asserts that a resolved chunk yields .others(index) while a cold-start / diarization-off chunk yields .others(nil) (rendered "Others"), and never .you. MeetingStateManagerTests.swift:103 ("MeetingSpeaker display names are correct") is the natural place to extend — this is the behavioural heart of the feature.

minWidth: showHistory
? Self.compactMinimumWidth + Self.historyWidthIncrement
: Self.compactMinimumWidth,
minHeight: 420

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Maintainability] This PR nicely shares width through compactMinimumWidth / historyWidthIncrement so the panel and the view can't drift apart. The min-height is the odd one out: 420 is a bare literal here while MeetingWindowPanel.minimumHeight is a named 420 — the same value declared in two spots. Fold it into a shared constant the same way you did the widths.

Suggested change
minHeight: 420
minHeight: Self.minimumHeight

(with a static let minimumHeight: CGFloat = 420 alongside the width constants, referenced from the panel too.)

@gbrunoo gbrunoo linked an issue Aug 31, 2026 that may be closed by this pull request
Addresses review feedback on sebsto#100.

- Gate labelling, diarizer warmup and echo suppression on transcript.mode
  (the canonical session mode, fixed at startMeeting) rather than the live
  header binding, so correctness no longer rests on the toggle being
  disabled while recording
- Share minimumHeight between MeetingTranscriptView and MeetingWindowPanel,
  matching how the widths are already shared
- Extract micSpeaker(mode:diarizedIndex:) so the label decision is testable
  without microphone hardware
- Add tests: mode back-compat (legacy JSON decodes .online) and round-trip;
  in-person labels .others(index)/.others(nil) and never .you; in-person
  keeps a would-be echo, with an online control proving the bypass is what
  changed the outcome
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Meeting mode: in-person recording with per-speaker separation

2 participants