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, and gap repair. 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,
"missing_sequences": [1801, 1802],
"audio_object_prefix": "meetings/meeting-uuid/chunks/",
"degraded_reasons": ["ml_unavailable"],
"max_duration_seconds": 14400,
"ml_session_id": "ml-session-uuid"
}Valid statuses: active, stopping, composing, 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 the chunk 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",
"missing_sequences": [1801, 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_chunks_stored": 36000,
"highest_contiguous_sequence": 35998,
"gaps": [35999, 36000],
"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 sha256, de-duplicates by sequence, stores each chunk at meetings/{meeting_id}/chunks/{sequence}.webm, and returns the remaining gap set. 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_sequences": [1802],
"last_contiguous_sequence": 1842
}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",
"last_stored_sequence": 17500,
"missing_sequences": [17501, 17502],
"ml_session_id": "ml-session-uuid"
}
}GapUploadCompleteEvent — New
Confirms that all gap chunks have been received and stored. The app can clear OPFS and resume normal operation.
{
"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",
"last_stored_sequence": 18000
}
}RecordingStoppedEvent
Confirms recording has stopped. The client calls GET /meetings/{id}/recording/missing-chunks to determine which OPFS gap chunks to upload before audio composition can proceed.
{
"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,
"post_processing_started": true
}
}Valid reasons: user_requested, duration_limit, connection_closed, server_shutdown, storage_failure.
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"
}
}RecordingErrorEvent
Reports degraded or failed recording conditions. Recoverable conditions include a paired recovery code (e.g., ml_unavailable → ml_recovered).
{
"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": "ml_unavailable",
"severity": "degraded",
"message": "Live insights paused. Audio is still recording.",
"retry_after_ms": 1000
}
}Valid codes: ml_unavailable, ml_recovered, storage_unavailable, storage_recovered, insight_warning, transcoder_error, no_audio_detected, session_conflict, audio_checksum_mismatch, backpressure.
AudioChunkStoredEvent — New
Periodically reports the highest contiguous sequence number durably stored in GCS. 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 chunks, whichever comes first) during an active recording.
{
"specversion": "1.0",
"id": "event-uuid",
"source": "wordloop-core/ws",
"type": "com.wordloop.recording.audio_chunk_stored.v1",
"time": "2026-05-01T09:05:00Z",
"traceparent": "00-...",
"data": {
"meeting_id": "meeting-uuid",
"highest_contiguous_sequence": 5000,
"total_chunks_stored": 5000
}
}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,
"task_extraction": "live"
}
}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_composed_path": "gs://wordloop-audio/meetings/meeting-uuid/audio.webm"
}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_composed_path": "gs://wordloop-audio/meetings/meeting-uuid/audio.webm"
}
}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/chunks/",
"audio_composed_path": "gs://wordloop-audio/meetings/meeting-uuid/audio.webm"
}Valid reasons: user_requested, duration_limit, connection_closed, server_shutdown, storage_failure.