feat(recording): capture meeting audio over a WebSocket - #5738
Conversation
Adds a recording package that owns an audio session: a state machine per session, a WAV writer, a transcript persister, and recovery for a session whose process died mid-recording. The dashboard exposes it at /api/ws/recording, and the Meetings app records what a session produced through its own recording store. The frontend captures both the microphone and the system output, mixes them through the PCM worklet's two inputs, and shows input level in a meter so a user can see the capture is live before the meeting starts.
|
🤖 Kiro Crew [operator: bolichen97#bb3ad1ca]: This PR has been inactive for 7+ days with failing CI. I reviewed the blockers but they require your input:
When you've addressed these, the pipeline will re-assess on its next cycle. |
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
The Description, the spec, the module docstring, and the Manual verification section all describe the recording socket emitting redacted transcript events, but nothing on the socket's request path can produce one.
1. The recording socket never emits the redacted transcript events the Description says it emits
The Description says —
Interim/final transcript events emitted back to the browser are redacted first (
recording/ws.pyis a registered redaction sink insecurity_posture.py).
and, under Manual verification:
Confirmed interim level/transcript events were emitted back over the socket and passed through redaction.
The code does — _emit_transcript is the only place a partial/final transcript event is built and the only place the redactor is called, and api_ws_recording never calls it. No transcription is wired into this socket at all, so the redactor call is dead and no transcript event can reach the browser: src/kiro_crew/recording/ws.py:199. The PR's own frontend documentation agrees with the code rather than with the prose — the wire-protocol header in website/src/apps/meetings/hooks/useMeetingRecording.ts ("Wire protocol — conforms to kiro_crew/recording/ws.py:api_ws_recording") lists only ready and level coming back, while the backend module docstring claims the socket "emits ready, partial, final, level, and error events".
Risk — Four separate surfaces tell a reader that live transcription with credential/exfil redaction happens on this socket: the Description bullet, the spec's Recording section in docs/system-specs/modules/meetings.md, the recording/ws.py module docstring, and the Security Posture row. The panel therefore counts an egress boundary that no request can reach, and the drift guard cannot catch it — test/test_security_posture.py's TestRedactionSinkRegistry only checks that the named module contains a redactor call, so a dead one satisfies it. The sink registration is itself a consequence of the dead call rather than an independent problem: the guard forces any module calling a redactor into either _REDACTION_SINKS or NON_EGRESS_REDACTION_MODULES, so the entry had to be added once the unreachable call existed. What blocks is that the Manual verification bullet reports an observed outcome the code cannot produce, which leaves the rest of that section unreliable for a reviewer trying to decide what was actually exercised.
Required change — Either wire transcription into api_ws_recording so _emit_transcript is reached on the live path, or drop the claim: remove _emit_transcript and the now-unreachable persister path, remove the security_posture.py sink row with it, and correct the Description bullet, the spec's Recording section, the recording/ws.py module docstring, the test module docstring, and the Manual verification bullet to state that the socket carries audio in and ready/level/error out.
Audit note — this PR and #7196 are one branch stack, not duplicatesA duplicate-detection sweep flagged these two as near-identical because they share a large block of added lines. They are not duplicates: one branch contains the other's commits, so the shared lines are inherited, and the review diff overstates what this PR actually authored. This is the textbook stacked-branch artifact, provable by SHA rather than by reading code: What is genuinely each PR's ownIf #5738 merges, #7196 still carries all of its own work and loses nothing: 6 files #5738 never touches ( Suggested actionThis is the base of the stack — it can go first on its own. #7196 should rebase after it lands. From a repository-wide duplicate/overlap audit of every pull request open against |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
@kaizawa97 We audited every open PR against Nothing on Four things are outstanding:
One smaller item: Audited at 4bb2f16. Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Problem / Motivation
The Meetings app transcribes a meeting but keeps no audio. Once the meeting ends, the transcript is the only record — a misheard sentence, a number the STT mangled, or a dispute about what was actually said cannot be checked against anything. And transcription only hears the local microphone: the other participants of a call (the audio the conferencing app plays back) never reach the app at all.
Why it matters
For any real meeting the far side is most of the content. Without capture of both sides and a durable recording, the notes are only as good as one microphone plus the recognizer's first guess, with no way to recover from either.
What changed (motivation → approach → change)
Goal: record the whole meeting (mic + far side) to a plain WAV in the user's own data directory, without a second audio capture and without coupling core to the app.
src/kiro_crew/recording/package — a per-session state machine, a WAV writer, a transcript persister, and crash-recovery scaffolding. Exposed at/api/ws/recording, a separate socket from/api/ws/stton purpose: that one caps a stream at 300 s (right for dictation, far too short for a meeting). The socket refuses non-loopback clients by default (recording.require_local_gateway, new core config key) — raw room audio is the most sensitive artifact this app produces. Interim/final transcript events emitted back to the browser are redacted first (recording/ws.pyis a registered redaction sink insecurity_posture.py).backend/recording_store.py(aMeetingStoreadapter) fromregister_routes;safe_meeting_id+containstay in the app, and an id core cannot place is refused rather than started.useMeetingTranscriptionowns the single audio pipeline and TEES every PCM chunk to the recording through anonPcmoption — transcription and recording are two readers of one stream, not two captures.audio/systemAudio.tscaptures the far side from the display surface (getDisplayMediawith a 1 fps video track that is immediately stopped and removed — audio-only capture is not expressible per spec). ARecordingMetershows input level so the user can see the capture is live./api/ws/recordingpermission + one highlight (mirrored into all 12 locale catalogs per the manifest-sync gate). Spec updated in the same commit (docs/system-specs/modules/meetings.md: Recording section, layout/data rows, security posture).Tests
test/test_recording.py— writer + session state machine (invalid transitions, pause/resume, failure marking).test/test_recording_storage.py— theMeetingStoreseam and the app's adapter (containment, refusal of an unplaceable id).test/test_recording_ws.py— socket protocol, the loopback guard, redaction of emitted events.test/test_recording_recovery.py— recovery scanning and status transitions.website/src/test/MeetingsRecording.test.ts— the recording hook's socket lifecycle and PCM tee.website/src/test/MeetingsSystemAudio.test.ts— thegetDisplayMediaconstraint guard (the shape that rejects must not come back) and track extraction.Local runs: full backend suite green (mypy 1095 files, flake8, black/brand/harness/subprocess-encoding/docs-lint gates); website
tsc, eslint,i18n:check(18 checks), i18n + meetings vitest suites green.Manual verification
Exercised end-to-end against a live harness gateway (fake ACP backend, loopback client):
/api/ws/recording, sent{"type":"start"}, receivedready, streamed 2 s of 16 kHz monos16lePCM in 4096-byte binary frames, then{"type":"stop"}.audio.wav(32000 frames, 16 kHz, mono) was written under the meeting's own data directory, with the app'scontain()check applied to the path.The screenshots below were captured against the same gateway, showing the live Recording state and the input-level meter. Still pending before merge sign-off: a pass against a real conferencing call's far-side audio — that path (
getDisplayMediasystem audio) is Electron-dependent and is covered by the stacked follow-up PR.Screenshots / video
Meeting view, recording controls (dark theme).
Idle meeting (before recording starts)
Related Issues
Part of the meetings feature stack split from the
feat/meetnotebranch. A follow-up PR (Electron system-audio capture) depends on this one and will be submitted once this merges.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)