meeting-pipeline
The meeting state machine, loopback capture window and transcription worker in Struq Voice. Use whenever a task involves meetings, meeting state (idle/starting/recording/paused/finalizing/error), Ctrl
Files1
View on GitHubSKILL.md.claude/skills/meeting-pipeline/SKILL.md
GitHubMeeting pipeline
The single authority
src/main/meeting/meeting-session.ts owns meeting state. Tray and main
window render from broadcasts of it (meeting:state-changed IPC). Nothing
else mutates or owns meeting state.
idle ──start──▶ starting ──lanes live──▶ recording ──stop──▶ finalizing ──▶ idle
▲ │ │ ▲ │
│ └──fail──────────────────┴──┘pause/resume──────────────┘
└────────── error ◀────────────────── worker failure / 8s lane timeout ──┘
- Refused up front when the database, the support assets or the engine are unavailable. The renderer turns each refusal into its own copy, never an error toast.
- The support assets ship with the installer (
extraResources), so in a packaged build they are always present and nothing is downloaded. The downloader is the repair path for a dev checkout or missing files, and it runs on its own without naming a single model to the user. - The hidden meeting window is created on start, destroyed on stop; the
struq-meetingutilityProcess is forked on start, drained on stop, killed.
Lifecycle rules (do not relitigate)
start() awaits four times: storage, worker start, window load, capture
gesture. Every one of those is a point where the stop hotkey can land.
- Each start carries a generation token.
stop()anddispose()invalidate it before tearing anything down, andstart()re-checks after every await. A start that has been overtaken unwinds only what it created and returns; it must never rebuild shared state. Without this the losing start rebuilt the window and worker that stop had just destroyed, leaving a recorder nobody owned holding the microphone and the loopback for the life of the app. - A start already in flight refuses a second one. Two concurrent starts would run two full sequences against one set of handles.
- One finalize per meeting row.
stop()claims the id when it cancels a start mid-flight so the unwind skips it; otherwise both write and race to decidecompleteversusinterrupted. - A meeting that never recorded is
interrupted. Cancelled mid-start, a failed archive, a dead capture renderer: allinterrupted, nevercomplete. - Main watches
render-process-goneon the meeting window. The lane-live timer is cleared the moment a lane goes live, so after that nothing else notices a dead renderer and the session would sit inrecordingforever with a frozen archive.
Why ASR runs out of process
sherpa-onnx-node's recognizer.decode() is a synchronous blocking native
call. For dictation that is fine; for continuous meeting decoding it would
stall main (tray, IPC, hotkeys, every window pump) for most of the meeting.
The worker (src/main/meeting/worker/index.ts) is a utilityProcess with its
own event loop, crash isolation and memory that is genuinely released on
kill. The queue is hard-capped at 600 seconds of backlog; over the cap emits
a gap marker instead of growing.
Speaker clustering
worker/speaker-clusterer.ts. A voice is a bounded ring of its recent
embeddings, scored by the mean of the top few similarities. A single running
mean was tried and does not work: one voice split into two clusters whose
means were 0.494 apart while two genuinely different voices sat at 0.391, so
no threshold separates them.
Three rules keep one person from becoming several, which is what the 1.2.x releases did:
- Duration is the dominant factor. CAM++ scores about 0.05 against its own
voice at 300ms, 0.15 at one second and 0.89 at eight. Anything under
minSpeakerAudioMs(3s) is provisional: it takes the nearest speaker's label but may never found or define one. Backchannels were minting speakers. - Embed the speech, not the utterance. The VAD hands back padding; embed
trimSilence'd audio or the fingerprint is partly the room. - Two thresholds and a merge. At or above
thresholdthe embedding is kept, belowcreateThresholdit is somebody new, and in between it joins the nearest speaker without defining them. Converged speakers merge, which emitsspeakers-mergedso main can rewrite stored segments.
speakerMergeThreshold is not on the same scale as speakerThreshold: it
averages every pair across two rings, where assignment takes the best few
against one. Scoring a merge with top-k instead of the mean fused a male and a
female voice.
Measure changes, do not guess them: node scripts/make-two-speaker-fixture.mjs --speakers 1 then node scripts/diarization-bench.mjs. The one-speaker
fixture must yield exactly one speaker and the two-speaker fixture exactly two.
The lane split (do not relitigate)
- The microphone lane is you by construction: it gets the speaker key
meand no clustering. - The system lane (Windows loopback) is everyone else: your voice is not in it because conferencing apps do not render your own mic back to you. Only this lane is clustered.
- Lanes are mixed only into the archive (
recording.webm), never into the transcript pipeline. SpeechSegment.startfrom oneVadper lane per meeting is the meeting timeline directly; no separate clock.
Dictation always wins
The capture session's state is mirrored into
meetings.setDictationActive(state.phase !== "idle" && state.phase !== "error").
The worker finishes the utterance it is on, then holds until released. Its
queue grows for at most one dictation, then drains.
Loopback acquisition
getDisplayMedia needs transient user activation and a hotkey-started
meeting has none. Main sends meeting-audio:begin with the options, the
renderer stores them and exposes window.__struqBeginMeetingAudio(), and
main calls it via webContents.executeJavaScript(code, true) (the only
supported path). The handler in src/main/meeting/loopback.ts grants
audio: "loopback" (never loopbackWithMute) to the meeting window only.
Never use loopbackWithMute: it silences the meeting for the user.
Worker protocol
The wire format is src/main/meeting/worker/protocol.ts, not ipc.ts: these
are private messages over parentPort, not renderer channels. Audio frames
are structured-cloned copies (32 KB/s per lane), never SharedArrayBuffer.
Archive
src/main/meeting/archive-writer.ts appends opus chunks to
userData/meetings/<id>/recording.webm with an internal queue so the IPC
handler never blocks. Main is the only SQLite writer; the worker never
touches the database.
Export
src/main/meeting/export.ts is pure over records, segments and speakers:
Markdown, plain text and SRT. Speaker labels resolve from the speakers map,
falling back to defaultSpeakerLabel; gap rows render as "[not
transcribed]" and are skipped in SRT.