-
-
Notifications
You must be signed in to change notification settings - Fork 166
feat(diarize): per-channel acoustic speaker diarization (Sortformer) #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f048ea5
b6b4e65
05b145a
3507342
33d7639
70674a9
8f3863a
e129c0a
2060434
cac5eaa
3336981
05679be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,7 @@ import { getLiveDraft, useLiveDraftStore } from '@/hooks/liveDraftStore'; | |
| import { ipc } from '@/lib/ipc'; | ||
| import { stripReasoning } from '@/lib/markdown'; | ||
|
|
||
| type ProcessingStage = 'transcribing' | 'summarizing' | 'finalizing' | 'error'; | ||
| type ProcessingStage = 'transcribing' | 'diarizing' | 'summarizing' | 'finalizing' | 'error'; | ||
|
|
||
| // Shared flag so the sibling ProcessingDock (the bottom "Processing" chip, | ||
| // rendered by App while on this route) can hide once the watchdog concludes | ||
|
|
@@ -33,6 +33,7 @@ const useProcessingWatchdogStore = create<{ | |
|
|
||
| const STAGE_LABEL: Record<ProcessingStage, string> = { | ||
| transcribing: 'Analyzing transcript', | ||
| diarizing: 'Identifying speakers', | ||
| summarizing: 'Generating notes', | ||
| finalizing: 'Almost done…', | ||
| error: 'Couldn’t process this recording.', | ||
|
|
@@ -59,6 +60,13 @@ const STAGE_LABEL: Record<ProcessingStage, string> = { | |
| const WATCHDOG_TICK_MS = 1500; | ||
| const WATCHDOG_IDLE_TICKS = 8; | ||
|
|
||
| function formatElapsedSeconds(totalSeconds: number): string { | ||
| if (totalSeconds < 60) return `${totalSeconds}s`; | ||
| const m = Math.floor(totalSeconds / 60); | ||
| const s = totalSeconds % 60; | ||
| return `${m}m ${s}s`; | ||
| } | ||
|
|
||
| export function Processing() { | ||
| const navigate = useNavigate(); | ||
| const recording = useRecording(); | ||
|
|
@@ -143,6 +151,21 @@ export function Processing() { | |
| setRetryError(null); | ||
| } | ||
|
|
||
| // Diarization's segmentation pass has no per-chunk checkpoint to report | ||
| // progress from -- unlike embedding extraction (not part of this branch's | ||
| // sidecar), so there is nothing to turn into a percentage here. A | ||
| // client-side elapsed-time ticker is the only way to show visible motion | ||
| // during that stretch without a backend change. | ||
| const diarizeTimerRef = React.useRef<ReturnType<typeof setInterval> | null>(null); | ||
| const diarizeStartedAtRef = React.useRef<number | null>(null); | ||
| const clearDiarizeTimer = React.useCallback(() => { | ||
| if (diarizeTimerRef.current) { | ||
| clearInterval(diarizeTimerRef.current); | ||
| diarizeTimerRef.current = null; | ||
| } | ||
| }, []); | ||
| React.useEffect(() => () => clearDiarizeTimer(), [clearDiarizeTimer]); | ||
|
|
||
| // Buffer streamed chunks and flush at most every 50ms (~20fps). At a | ||
| // typical token rate of 30-60 tokens/sec, this batches ~3 tokens per | ||
| // commit which keeps the UI smooth without re-parsing the entire markdown | ||
|
|
@@ -172,8 +195,9 @@ export function Processing() { | |
| // "saw activity" flag (which would hide a real no-job dead-end). | ||
| if (e.summaryFile) return; | ||
| if (activeSession && e.sessionName !== activeSession) return; | ||
| clearDiarizeTimer(); | ||
| pendingChunkRef.current += e.chunk; | ||
| setStage((s) => (s === 'transcribing' ? 'summarizing' : s)); | ||
| setStage((s) => (s === 'transcribing' || s === 'diarizing' ? 'summarizing' : s)); | ||
| if (!flushTimerRef.current) { | ||
| flushTimerRef.current = setTimeout(flushPending, 50); | ||
| } | ||
|
|
@@ -188,12 +212,16 @@ export function Processing() { | |
| // fresh-recording job. | ||
| if (e.summaryFile) return; | ||
| if (activeSession && e.sessionName !== activeSession) return; | ||
| clearDiarizeTimer(); | ||
| setChunkProgress(null); | ||
| setStage((s) => (s === 'error' ? s : 'finalizing')); | ||
| }), | ||
| ipc().on.processingComplete((e) => { | ||
| if (activeSession && e.sessionName !== activeSession) return; | ||
| if (!e.success) { | ||
| setRetryAudioFile(e.audioFile ?? null); | ||
| clearDiarizeTimer(); | ||
| setChunkProgress(null); | ||
| setStage('error'); | ||
| return; | ||
| } | ||
|
|
@@ -214,20 +242,41 @@ export function Processing() { | |
| // DIFFERENT meeting emits summaryFile-scoped progress — ignore those so | ||
| // another meeting's "Summarizing part N" can't hijack this screen's stage. | ||
| if (e.summaryFile) return; | ||
| const raw = e.line.replace(/^PROGRESS:summarize:/, ''); | ||
| if (raw === 'reducing') { | ||
| setChunkProgress('Merging summaries…'); | ||
| } else { | ||
| const [step, total] = raw.split('/').map(Number); | ||
| if (!Number.isNaN(step) && !Number.isNaN(total)) { | ||
| setChunkProgress(`Summarizing part ${step} of ${total}…`); | ||
| if (e.line.startsWith('PROGRESS:summarize:')) { | ||
| const raw = e.line.slice('PROGRESS:summarize:'.length); | ||
| if (raw === 'reducing') { | ||
| setChunkProgress('Merging summaries…'); | ||
| } else { | ||
| const [step, total] = raw.split('/').map(Number); | ||
| if (!Number.isNaN(step) && !Number.isNaN(total)) { | ||
| setChunkProgress(`Summarizing part ${step} of ${total}…`); | ||
| } | ||
| } | ||
| setStage((s) => (s === 'transcribing' || s === 'diarizing' ? 'summarizing' : s)); | ||
| } else if (e.line.startsWith('PROGRESS:diarize:')) { | ||
| const rest = e.line.slice('PROGRESS:diarize:'.length); | ||
| const [label, kind] = rest.split(':'); | ||
| if (kind === 'start') { | ||
| setStage((s) => (s === 'transcribing' ? 'diarizing' : s)); | ||
| clearDiarizeTimer(); | ||
| diarizeStartedAtRef.current = Date.now(); | ||
| setChunkProgress(`Diarizing ${label} channel…`); | ||
| diarizeTimerRef.current = setInterval(() => { | ||
| if (diarizeStartedAtRef.current === null) return; | ||
| const elapsedS = Math.floor((Date.now() - diarizeStartedAtRef.current) / 1000); | ||
| setChunkProgress(`Diarizing ${label} channel… (${formatElapsedSeconds(elapsedS)})`); | ||
| }, 1000); | ||
| } else if (kind === 'done') { | ||
| // Stop the elapsed ticker; no other UI action needed -- the | ||
| // next channel's :start, or the first summarize: chunk, | ||
| // supersedes this sub-label shortly after. | ||
| clearDiarizeTimer(); | ||
| } | ||
| } | ||
| setStage((s) => (s === 'transcribing' ? 'summarizing' : s)); | ||
| }), | ||
| ]; | ||
| return () => offs.forEach((fn) => fn()); | ||
| }, [activeSession, updateMeeting]); | ||
| }, [activeSession, updateMeeting, clearDiarizeTimer]); | ||
|
|
||
| // Watchdog. Any real processing activity — a streamed chunk, chunk progress, | ||
| // a stage move past 'transcribing' (summaryComplete/processingComplete) — | ||
|
|
@@ -523,10 +572,12 @@ function StageCard({ | |
| style={{ color: 'var(--fg-2)' }} | ||
| /> | ||
| <span | ||
| data-testid="processing-stage-label" | ||
| data-stage={stage} | ||
| className="text-[13px] transition-colors" | ||
| style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }} | ||
| > | ||
| {chunkProgress && stage === 'summarizing' ? chunkProgress : STAGE_LABEL[stage]} | ||
| {chunkProgress && stage !== 'finalizing' && stage !== 'error' ? chunkProgress : STAGE_LABEL[stage]} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The diarize elapsed ticker is not torn down when Prompt for AI agents |
||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| // swift-tools-version: 5.9 | ||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "diarize-sidecar", | ||
| platforms: [.macOS(.v14)], | ||
| dependencies: [ | ||
| .package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.15.2"), | ||
| ], | ||
| targets: [ | ||
| .executableTarget( | ||
| name: "diarize-sidecar", | ||
| dependencies: [ | ||
| .product(name: "FluidAudio", package: "FluidAudio"), | ||
| ], | ||
| path: "Sources" | ||
| ), | ||
| ] | ||
| ) |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The diarised-segment regex was widened from an explicit
(You|Others)to match any[^\]]+content, so any bracketed text that appears inside a segment body — not just a real[Speaker N]marker — is now treated as a new speaker boundary. For example[You] Call me at [5:00] tomorrowis parsed into a phantom5:00speaker segment (rendered as an 'Others' bubble), and text after a lone trailing bracket gets dropped. This regresses the previous behavior where bracketed content stayed with its segment. Recommend constraining the marker to the actual label set the pipeline emits (e.g.(?:You|Others|Speaker \d+)) in both the captured group and the lookahead, so only genuine markers split segments.Prompt for AI agents