Recording
Live recording lifecycle — WebSocket commands and events, ML session management, OPFS gap repair, and Pub/Sub drain.
Recording
Recording state is a sub-resource of Meeting. This page covers the full lifecycle: starting, stopping, resuming, ML session orchestration, gap repair, audio composition, and post-processing handoff. For binary audio frame formats and transport, see Audio. For shared connection semantics, see Infrastructure.
Recording creation: The recording resource is created as a side effect of StartRecordingCommand (see WebSocket commands below). There is no POST /meetings/{id}/recording endpoint — the recording lifecycle is entirely driven by WebSocket commands. The REST surface provides read-only access to recording state and chunk management for gap recovery.
Resource Shape
{
"meeting_id": "meeting-uuid",
"status": "active",
"started_at": "2026-05-01T09:00:00Z",
"stopped_at": null,
"stop_reason": null,
"last_received_sequence": 1842,
"highest_contiguous_sequence": 1800,
"missing_ranges": [{ "start": 1801, "end": 1802 }],
"audio_object_prefix": "meetings/meeting-uuid/segments/",
"audio_version": 1,
"gap_upload_deadline_at": "2026-05-01T10:30:00Z",
"degraded_reasons": ["ml_unavailable"],
"max_duration_seconds": 14400,
"ml_session_id": "ml-session-uuid"
}Valid statuses: active, stopping, draining_ml, awaiting_gap_upload, composing_audio, post_processing, completed, failed.
REST API
GET /meetings/{id}/recording
Returns the recording state for a meeting, including audio-chunk continuity and degradation state. Returns 404 if the meeting has never been recorded.
| Auth | bearerAuth |
| Response | 200 MeetingRecording |
| Cache-Control | private, no-store |
| Errors | 404 meeting not found or never recorded; 403 belongs to another user |
GET /meetings/{id}/recording/missing-chunks
Returns a range-based gap plan for audio frame sequences Core has not durably stored in GCS. The app calls this after reconnect or stop to determine which OPFS chunks to upload.
| Auth | bearerAuth |
| Response | 200 MissingChunkList |
| Cache-Control | private, no-store |
{
"meeting_id": "meeting-uuid",
"highest_contiguous_sequence": 1800,
"missing_ranges": [{ "start": 1801, "end": 1802 }],
"accepted_mime_types": ["audio/webm"],
"max_chunk_bytes": 1048576
}GET /meetings/{id}/recording/chunk-inventory — New (Diagnostic)
Returns the full chunk inventory for a recording. Admin/diagnostic use only — not called by the app during normal operation.
| Auth | service auth |
| Response | 200 ChunkInventory |
| Cache-Control | private, no-store |
{
"meeting_id": "meeting-uuid",
"total_frames_received": 36000,
"total_segments_stored": 1800,
"highest_contiguous_sequence": 35998,
"gaps": [{ "start": 35999, "end": 36000 }],
"audio_version": 2,
"total_bytes": 172800000,
"composition_status": "pending",
"first_chunk_at": "2026-05-01T09:00:00Z",
"last_chunk_at": "2026-05-01T10:00:00Z"
}POST /meetings/{id}/recording/chunks
Uploads OPFS gap chunks. Core verifies SHA-256 for every uploaded part, uses CRC32C metadata for continuity with the live path, de-duplicates by (meeting_id, sequence, checksum), stores or merges chunks into GCS segment objects, and returns the remaining gap ranges. Only multipart/form-data is accepted — no base64-encoded JSON.
| Auth | bearerAuth |
| Idempotency | Required |
| Request Content-Type | multipart/form-data |
| Response | 200 GapUploadResult + Location: /meetings/{id}/recording |
| Errors | 409 audio already composed; 413 chunk too large; 422 checksum mismatch |
Each part includes:
| Field | Type | Description |
|---|---|---|
sequence | integer | Monotonic chunk sequence number |
started_at_ms | integer | Chunk start offset in milliseconds |
duration_ms | integer | Chunk duration in milliseconds |
mime_type | string | audio/webm |
sha256 | string | Hex-encoded SHA-256 of the audio bytes |
audio | binary | Raw audio chunk bytes |
{
"meeting_id": "meeting-uuid",
"accepted_sequences": [1801],
"remaining_missing_ranges": [{ "start": 1802, "end": 1802 }],
"last_contiguous_sequence": 1842,
"audio_version": 1
}Real-Time Events
Browser → Core
StartRecordingCommand
Starts a live recording for a meeting. If the user already has an active recording, Core returns RecordingErrorEvent with code: "session_conflict".
{
"specversion": "1.0",
"id": "command-uuid",
"source": "wordloop-app/ws",
"type": "com.wordloop.recording.start.v1",
"time": "2026-05-01T09:00:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"client_recording_id": "browser-generated-uuid",
"audio_config": {
"encoding": "webm",
"sample_rate": 48000,
"channels": 1,
"chunk_duration_ms": 100
},
"max_duration_seconds": 14400
}
}StopRecordingCommand
Stops the recording. The app includes the last sequence written to OPFS so Core can report gaps precisely.
{
"specversion": "1.0",
"id": "command-uuid",
"source": "wordloop-app/ws",
"type": "com.wordloop.recording.stop.v1",
"time": "2026-05-01T10:00:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"last_client_sequence": 36000,
"opfs_manifest_sha256": "hex-encoded-sha256"
}
}ResumeRecordingCommand — New
Sent by the app after a WebSocket reconnect during an active recording. Carries the client's last known sequence so Core can report the GCS gap.
{
"specversion": "1.0",
"id": "command-uuid",
"source": "wordloop-app/ws",
"type": "com.wordloop.recording.resume.v1",
"time": "2026-05-01T09:30:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"last_client_sequence": 18000
}
}Core → Browser
RecordingStartedEvent
Confirms that Core, ML, storage, and transcription pre-warm are ready. The banner transitions from Connecting to Recording.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.started.v1",
"time": "2026-05-01T09:00:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"ml_session_id": "ml-session-uuid",
"started_at": "2026-05-01T09:00:00Z",
"max_duration_seconds": 14400
}
}RecordingResumedEvent — New
Sent in response to ResumeRecordingCommand. Tells the app where GCS stands so the app knows which OPFS chunks to upload.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.resumed.v1",
"time": "2026-05-01T09:30:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"highest_contiguous_sequence": 17500,
"missing_ranges": [{ "start": 17501, "end": 18000 }],
"gap_upload_deadline_at": "2026-05-01T10:00:00Z",
"ml_session_id": "ml-session-uuid"
}
}GapUploadCompleteEvent — New
Reports progress for gap upload. The app can trim OPFS up to highest_contiguous_sequence; it should keep any chunks still covered by remaining_missing_ranges until Core confirms there are no remaining ranges.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.gap_upload_complete.v1",
"time": "2026-05-01T09:31:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"highest_contiguous_sequence": 18000,
"remaining_missing_ranges": []
}
}RecordingStoppedEvent
Confirms capture has stopped. This does not mean audio has been composed or post-processing has started. The event carries the initial gap plan so the client can start OPFS upload without an extra request; the client may still call GET /meetings/{id}/recording/missing-chunks to refresh the plan.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.stopped.v1",
"time": "2026-05-01T10:00:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"reason": "user_requested",
"last_received_sequence": 35998,
"last_client_sequence": 36000,
"missing_ranges": [{ "start": 35999, "end": 36000 }],
"gap_upload_deadline_at": "2026-05-01T10:30:00Z",
"next_status": "awaiting_gap_upload"
}
}Valid reasons: user_requested, duration_limit, connection_closed, server_shutdown, storage_failure.
connection_closed fires after a grace period, not immediately on socket close. When a user's last WebSocket connection closes without a StopRecordingCommand, Core arms a grace timer (RECORDING_DISCONNECT_GRACE_SECONDS, default 120s) rather than stopping the recording right away — a second open tab, a reconnect, or a ResumeRecordingCommand inside the window cancels the timer and the recording stays active. Only if the grace period elapses with no reconnect does Core run the normal stop path with reason: "connection_closed". Setting RECORDING_DISCONNECT_GRACE_SECONDS=0 stops the recording as soon as the last connection drops.
Recording Lifecycle State Machine
| State | Meaning | Exit condition |
|---|---|---|
active | Browser is capturing and Core accepts audio frames. | Stop command, duration limit, inactivity timeout, or server shutdown. |
stopping | Stop intent is persisted and no new recording session can start for the user. | Core starts ML drain. |
draining_ml | Core asks ML to close upstream transcription and flush final live segments. | StreamDrainedEvent or drain timeout. |
awaiting_gap_upload | Capture has stopped and Core is waiting for OPFS gap chunks until gap_upload_deadline_at. | No gaps remain or deadline expires. |
composing_audio | Core composes GCS segment objects into final audio.webm and writes the composition manifest. | Compose succeeds or fails. |
post_processing | Core has published TranscriptionJob for final transcript, synthesis, and task reconciliation. | ML write-back completes, is skipped because the meeting is soft-deleted, or fails. |
completed | Recording artefacts are final for the current audio_version. | Terminal. |
failed | Recording cannot produce a usable final audio artefact. | Terminal unless an operator replays from diagnostics. |
Stop coordination is an in-process pipeline, not an outbox-durable one: StopSession persists the stopping transition synchronously, then hands off to runStopPipeline as a tracked goroutine that walks stopping → draining_ml → awaiting_gap_upload | composing_audio → post_processing in memory. A Core process crash mid-pipeline loses that goroutine — there is no outbox event a restarted process replays to resume it. The awaiting_gap_upload deadline is enforced independently of the pipeline goroutine: a periodic sweeper (RECORDING_GAP_SWEEP_INTERVAL_SECONDS, default 30s) scans for recordings past gap_upload_deadline_at (RECORDING_GAP_UPLOAD_DEADLINE_SECONDS, default 1800s) and advances them to composing_audio even if the recording that started them is gone, which is what makes that one state transition durable across a restart. Crash recovery for a recording stuck in stopping/draining_ml/composing_audio/post_processing (outside the gap-upload deadline sweep) is not implemented — see Configuration for the settings governing this sequence.
RecordingDurationWarningEvent
Warns the client before server-side auto-stop.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.duration_warning.v1",
"time": "2026-05-01T12:50:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"remaining_seconds": 600,
"auto_stop_at": "2026-05-01T13:00:00Z"
}
}RecordingHealthEvent — New
Reports recoverable health transitions for backend or browser-local durability domains. Health events are state transitions, not fatal errors. The client renders them as banners and clears degraded warnings when the corresponding domain returns to healthy or recovered.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.health.v1",
"time": "2026-05-01T09:10:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"status": "degraded",
"domains": ["ml"],
"code": "ml_unavailable",
"message": "Live insights paused. Audio is still recording.",
"recoverable": true,
"retry_after_ms": 1000
}
}Valid statuses: healthy, degraded, recovered.
Valid domains: core_ws, storage, ml, transcription_provider, insight_provider, local_buffer, auth.
Valid health codes: ml_unavailable, storage_unavailable, insight_warning, transcoder_error, no_audio_detected, backpressure, server_draining, auth_expiring, local_buffer_unavailable, local_buffer_full, local_buffer_corrupt_chunk.
RecordingErrorEvent
Reports fatal or user-actionable recording failures. Recoverable backend degradation belongs in RecordingHealthEvent.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.error.v1",
"time": "2026-05-01T09:10:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"code": "session_conflict",
"severity": "error",
"message": "You already have an active recording session.",
"retry_after_ms": 1000
}
}Valid error codes: session_conflict, audio_checksum_mismatch, gap_upload_deadline_expired, recording_failed, unauthorized_recording, unsupported_browser.
AudioStoredProgressEvent — New
Periodically reports the highest contiguous audio frame sequence durably covered by GCS segment storage. The client uses this to trim the OPFS shadow buffer during normal operation — without it, OPFS grows unboundedly during long sessions. Core emits this event every 10 seconds (or every 100 audio frames, whichever comes first) during an active recording.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.audio_stored_progress.v1",
"time": "2026-05-01T09:05:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"highest_contiguous_sequence": 5000,
"total_frames_stored": 5000,
"total_segments_stored": 250
}
}ML Integration
Core → ML
REST: POST /meetings/{id}/live-session
Creates and pre-warms the ML side of a live recording. ML opens the upstream AssemblyAI session, loads speaker states (pushed by Core in the request body), prepares the insight pipeline, and returns the WebSocket URL.
| Auth | service auth |
| Idempotency | Required; key maps to (meeting_id, transcription_id) |
| Response | 201 Created with MLLiveSession + Location: /meetings/{id}/live-session |
| Errors | 409 active session already exists for meeting; 503 transcription provider unavailable |
Request:
{
"meeting_id": "meeting-uuid",
"transcription_id": "transcription-uuid",
"user_id": "user-uuid",
"audio_config": {
"encoding": "webm",
"sample_rate": 48000,
"channels": 1,
"chunk_duration_ms": 100
},
"speaker_states": [
{
"speaker_label": "speaker_1",
"state": "manual",
"person_id": "person-uuid",
"attempt_count": 0
}
],
"voice_profiles": [
{
"person_id": "person-uuid",
"embedding_model": "ecapa-tdnn-v1",
"embedding": [0.12, -0.34]
}
],
"insight_policy": {
"talking_point_cadence_seconds": 30,
"talking_point_cadence_segments": 5,
"action_item_fast_path": true,
"task_extraction": "live_and_reconcile"
}
}Response:
{
"id": "ml-session-uuid",
"meeting_id": "meeting-uuid",
"status": "ready",
"websocket_url": "wss://ml.internal/meetings/meeting-uuid/live-session/stream",
"expires_at": "2026-05-01T13:00:00Z",
"max_duration_seconds": 14400
}REST: GET /meetings/{id}/live-session
Returns ML's authoritative view of a live session. Core uses this for diagnostics and recovery decisions.
| Auth | service auth |
| Response | 200 MLLiveSessionStatus |
| Errors | 404 no active session for meeting |
{
"id": "ml-session-uuid",
"meeting_id": "meeting-uuid",
"status": "streaming",
"last_audio_sequence_received": 1842,
"last_audio_sequence_processed": 1841,
"last_output_event_id": "event-uuid",
"speaker_state_count": 3,
"context_buffer_segment_count": 45,
"degraded_reasons": []
}Valid statuses: created, ready, streaming, draining, completed, failed, expired.
REST: POST /meetings/{id}/live-session/drain
Requests a graceful drain of the live session. Core normally sends DrainCommand over the ML WebSocket first; this REST endpoint is the idempotent fallback for control-plane cleanup. Uses POST (not DELETE) to carry the request body and express intent clearly.
| Auth | service auth |
| Idempotency | Required |
| Response | 202 Accepted while draining; 204 No Content if already drained |
| Errors | 404 no active session for meeting |
{
"reason": "user_requested",
"last_received_sequence": 35998,
"audio_storage_prefix": "gs://wordloop-audio/meetings/meeting-uuid/segments/"
}REST: POST /meetings/{id}/live-session/speaker-states
Pushes a speaker-state update to ML outside the live WebSocket path. The normal path is SpeakerStateUpdatedEvent over WebSocket; REST is the fallback when Core recovers a session and needs to reconcile state. For the full speaker identification pipeline, see Person & Speaker Identity.
| Auth | service auth |
| Idempotency | Required |
| Response | 204 No Content |
| Errors | 404 no active session; 409 session already completed |
{
"speaker_label": "speaker_1",
"state": "manual",
"person_id": "person-uuid",
"updated_at": "2026-05-01T09:15:00Z"
}WebSocket: StreamStartEvent
Sent immediately after WebSocket open. Confirms the session and gives ML the replay point for reconnects. Includes current speaker states and voice profiles so ML can reconstruct its in-memory state on every connection — initial or reconnect.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ml-ws",
"type": "com.wordloop.ml.stream.start.v1",
"time": "2026-05-01T09:00:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"ml_session_id": "ml-session-uuid",
"transcription_id": "transcription-uuid",
"last_audio_sequence": 0,
"last_ml_event_id": null,
"speaker_states": [
{
"speaker_label": "speaker_1",
"state": "manual",
"person_id": "person-uuid",
"attempt_count": 0
}
],
"voice_profiles": [
{
"person_id": "person-uuid",
"embedding_model": "ecapa-tdnn-v1",
"embedding": [0.12, -0.34]
}
]
}
}WebSocket: DrainCommand
Requests a graceful drain. ML closes upstream transcription, flushes final segments, emits StreamDrainedEvent, then closes the WebSocket.
{
"specversion": "1.0",
"id": "command-uuid",
"source": "wordloop-core/ml-ws",
"type": "com.wordloop.ml.recording.drain.v1",
"time": "2026-05-01T10:00:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"reason": "user_requested",
"last_audio_sequence": 35998,
"audio_storage_prefix": "gs://wordloop-audio/meetings/meeting-uuid/segments/"
}
}ML → Core
WebSocket: StreamReadyEvent
Confirms ML has opened the upstream transcription stream and is ready to receive audio.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-ml/ws",
"type": "com.wordloop.ml.stream.ready.v1",
"time": "2026-05-01T09:00:01Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"ml_session_id": "ml-session-uuid"
}
}WebSocket: StreamDrainedEvent
Final ML event for a live session. Core can now close the socket, verify final audio, and publish post-meeting work.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-ml/ws",
"type": "com.wordloop.ml.stream.drained.v1",
"time": "2026-05-01T10:00:10Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"ml_session_id": "ml-session-uuid",
"last_audio_sequence_processed": 35998,
"final_segment_count": 812,
"closed_provider_sessions": ["assemblyai"]
}
}Pub/Sub
meeting.session.terminated.v1
Signals ML to drain and finalise a live streaming session. Core publishes this when the user stops, the duration limit is reached, or the client disappears.
| Producer | Core |
| Consumer | ML streaming coordinator |
| CloudEvents type | com.wordloop.meeting.session.terminated.v1 |
| Ordering key | meeting_id |
| Dead-letter | meeting-session-terminated-dlq |
{
"ml_session_id": "ml-session-uuid",
"meeting_id": "meeting-uuid",
"user_id": "user-uuid",
"reason": "user_requested",
"last_received_sequence": 35998,
"audio_storage_prefix": "gs://wordloop-audio/meetings/meeting-uuid/segments/",
"audio_version": 2
}Valid reasons: user_requested, duration_limit, connection_closed, server_shutdown, storage_failure.