WordloopWordloop
WorkMeeting RecordingTechnical Design DocContracts

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.

AuthbearerAuth
Response200 MeetingRecording
Cache-Controlprivate, no-store
Errors404 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.

AuthbearerAuth
Response200 MissingChunkList
Cache-Controlprivate, no-store
{
  "meeting_id": "meeting-uuid",
  "missing_sequences": [1801, 1802],
  "accepted_mime_types": ["audio/webm"],
  "max_chunk_bytes": 1048576
}

GET /meetings/{id}/recording/chunk-inventoryNew (Diagnostic)

Returns the full chunk inventory for a recording. Admin/diagnostic use only — not called by the app during normal operation.

Authservice auth
Response200 ChunkInventory
Cache-Controlprivate, 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.

AuthbearerAuth
IdempotencyRequired
Request Content-Typemultipart/form-data
Response200 GapUploadResult + Location: /meetings/{id}/recording
Errors409 audio already composed; 413 chunk too large; 422 checksum mismatch

Each part includes:

FieldTypeDescription
sequenceintegerMonotonic chunk sequence number
started_at_msintegerChunk start offset in milliseconds
duration_msintegerChunk duration in milliseconds
mime_typestringaudio/webm
sha256stringHex-encoded SHA-256 of the audio bytes
audiobinaryRaw 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"
  }
}

ResumeRecordingCommandNew

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
  }
}

RecordingResumedEventNew

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"
  }
}

GapUploadCompleteEventNew

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_unavailableml_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.

AudioChunkStoredEventNew

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.

Authservice auth
IdempotencyRequired; key maps to (meeting_id, transcription_id)
Response201 Created with MLLiveSession + Location: /meetings/{id}/live-session
Errors409 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.

Authservice auth
Response200 MLLiveSessionStatus
Errors404 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.

Authservice auth
IdempotencyRequired
Response202 Accepted while draining; 204 No Content if already drained
Errors404 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.

Authservice auth
IdempotencyRequired
Response204 No Content
Errors404 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.

ProducerCore
ConsumerML streaming coordinator
CloudEvents typecom.wordloop.meeting.session.terminated.v1
Ordering keymeeting_id
Dead-lettermeeting-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.

On this page