Skip to content

Add LiveKit and ElevenLabs streaming voice output - #1

Merged
muhammad-hammad-sarwar merged 1 commit into
mainfrom
vorflux/livekit-elevenlabs-streaming
Aug 13, 2026
Merged

Add LiveKit and ElevenLabs streaming voice output#1
muhammad-hammad-sarwar merged 1 commit into
mainfrom
vorflux/livekit-elevenlabs-streaming

Conversation

@muhammad-hammad-sarwar

@muhammad-hammad-sarwar muhammad-hammad-sarwar commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Adds a hybrid real-time voice pipeline that keeps browser microphone/Deepgram input on the existing WebSocket while publishing ElevenLabs-generated assistant speech as a native LiveKit audio track.

Changes

  • Split the FastAPI backend into validated configuration, typed protocol, provider adapters, audio processing, admission control, and session/generation services.
  • Stream Groq output through sentence-safe buffering into ElevenLabs realtime TTS, frame PCM for LiveKit, and support bounded queues, interruption hard cuts, stale-generation suppression, and deterministic cleanup.
  • Create isolated LiveKit rooms with short-lived least-privilege tokens, explicit room deletion, local Docker configuration, and safe lifecycle errors.
  • Add frontend hooks for typed WebSocket control, native remote-track playback, autoplay recovery, late-track subscription, microphone constraints, and coordinated Start/End behavior.
  • Add backend and frontend unit/integration coverage plus setup documentation and environment templates.

Testing

  • cd backend && python3 -m pytest tests/unit -q — passed, 29 tests.
  • cd backend && python3 -m pytest tests/integration -m 'not external' -q — passed, 10 tests.
  • cd frontend && npm run lint — passed.
  • cd frontend && npm run test -- --run — passed, 14 tests.
  • cd frontend && npx tsc --noEmit — passed.
  • cd frontend && npm run build — passed.
  • Local LiveKit Docker plus Chromium fake microphone — passed Start → subscriber readiness → client.ready → Listening → End → Ready.
  • Real ElevenLabs TTS/playback remains blocked: the configured key was confirmed by an idempotent /v1/voices check to return HTTP 401; a replacement key has been requested.

Evidence:


Attached Images and Videos

final-review-listening.png

final-review-ended.png

🎥 View recording: final-review-fake-mic-lifecycle.webm


Session Details

  • Session: View Session
  • Requested by: Unknown
  • Address comments on this PR. Add (aside) to your comment to have me ignore it.

Summary by CodeRabbit

  • New Features

    • Added real-time voice conversations with microphone input, streamed assistant text, and LiveKit audio playback.
    • Added session readiness, listening, speaking, interruption, sound-permission, and error states.
    • Added configurable WebSocket deployment support and browser-origin controls.
    • Added safeguards for overloaded sessions, invalid media, provider failures, and interrupted playback.
  • Documentation

    • Expanded setup, deployment, configuration, operational, and testing guidance.
  • Tests

    • Added comprehensive backend and frontend coverage for voice sessions, streaming, playback, validation, limits, and failure handling.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
realtime-voice-agent Ready Ready Preview Aug 12, 2026 1:16pm

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a complete browser voice-session pipeline. The backend handles WebSocket admission, speech input, streamed text, LiveKit audio, interruptions, limits, errors, and cleanup. The frontend manages microphone capture, validated events, LiveKit playback, session state, and controls.

Changes

Backend voice pipeline

Layer / File(s) Summary
Contracts and runtime controls
backend/app/core/*, backend/app/models/events.py, backend/app/services/admission.py, backend/app/audio/pcm.py, backend/app/audio/text_buffer.py
Adds validated settings, structured logging, admission leases, strict event models, turn debouncing, PCM framing, sentence buffering, and audio sink contracts.
Providers and spoken generation
backend/app/providers/*, backend/app/services/generation.py, backend/app/audio/livekit_publisher.py
Adds Deepgram, Groq, ElevenLabs, and LiveKit streaming. Generation now separates browser text from spoken audio and handles stale generations, bounded queues, cancellation, and TTS failure.
Application and WebSocket orchestration
backend/app/main.py, backend/app/api/audio_ws.py, backend/docker-compose.livekit.yml, backend/livekit.dev.yaml
Adds lifespan-managed startup and shutdown. The WebSocket route coordinates readiness, media forwarding, turn events, generation, playback interruption, worker failures, and cleanup.
Backend validation and setup
backend/tests/*, backend/.env.example, backend/README.md, backend/requirements*.txt
Adds unit and integration coverage for the pipeline, provider failures, cleanup, configuration, admission, and event handling. Documents setup and operational limits.

Frontend voice session

Layer / File(s) Summary
Protocol, connection, and audio hooks
frontend/app/lib/*, frontend/app/hooks/useSocket.ts, frontend/app/hooks/useLiveKitAudio.ts, frontend/app/hooks/useVoiceSession.ts
Adds validated protocol events, configurable WebSocket URLs, explicit socket lifecycle methods, LiveKit track handling, microphone streaming, readiness gating, assistant output, interruptions, and teardown.
UI and frontend validation
frontend/app/page.tsx, frontend/tests/*, frontend/package.json, frontend/vitest.config.mts, frontend/.env.example, frontend/.gitignore, frontend/README.md
Replaces direct recording logic with session-state UI. Adds sound-permission and retry controls, Vitest setup, hook tests, LiveKit tests, and frontend configuration documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant VoiceSession
  participant WebSocket
  participant Backend
  participant LiveKit
  Browser->>VoiceSession: Start voice session
  VoiceSession->>LiveKit: Unlock and connect audio
  VoiceSession->>WebSocket: Connect and send client.ready
  Browser->>WebSocket: Send WebM/Opus microphone chunks
  WebSocket->>Backend: Forward media and control events
  Backend-->>WebSocket: Send transcript, text, state, and error events
  Backend->>LiveKit: Publish streamed assistant PCM audio
  LiveKit-->>Browser: Deliver assistant audio
  Browser->>WebSocket: Send playback.hard_cut
  Backend->>LiveKit: Stop current generation audio
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding LiveKit and ElevenLabs streaming voice output.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vorflux/livekit-elevenlabs-streaming

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (16)
backend/tests/unit/test_turn_manager.py-12-12 (1)

12-12: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Await the scheduled task instead of a fixed delay.

The 20 ms delay can expire before the event loop runs the debounce task under CI load. Capture manager.pending_task after the updates and await it before the assertions.

Proposed fix
-    await asyncio.sleep(.02)
+    task = manager.pending_task
+    assert task is not None
+    await task
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/unit/test_turn_manager.py` at line 12, Replace the fixed
asyncio.sleep delay in the test with awaiting the scheduled debounce task: after
applying the updates, capture manager.pending_task and await it before
performing assertions, preserving the existing test flow.
frontend/app/hooks/useLiveKitAudio.ts-187-187 (1)

187-187: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter TrackUnsubscribed before detaching audio.

An unrelated unsubscribe currently detaches the assistant audio and sets subscriberReady to false. Match the event participant, publication, and track against the expected and attached references before detaching. Add a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/hooks/useLiveKitAudio.ts` at line 187, Update
onTrackUnsubscribed in useLiveKitAudio to ignore unrelated TrackUnsubscribed
events by validating the event participant, publication, and track against the
expected and currently attached references before calling
detachAudioRef.current(). Preserve subscriberReady changes only for the matching
assistant audio, and add a regression test covering an unrelated unsubscribe.
backend/app/providers/deepgram.py-27-27 (1)

27-27: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Split the compound statements.

Ruff reports E701 on both lines. Put the raise and cancellation statements on separate lines.

Proposed fix
-            if error is not None: raise RuntimeError("Deepgram listening failed") from error
+            if error is not None:
+                raise RuntimeError("Deepgram listening failed") from error
...
-        if not self.listening_task.done(): self.listening_task.cancel()
+        if not self.listening_task.done():
+            self.listening_task.cancel()

Also applies to: 44-44

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/providers/deepgram.py` at line 27, Split the compound statements
in the Deepgram listening error handling and cancellation branches into separate
lines, keeping the existing RuntimeError chaining and cancellation behavior
unchanged. Update both affected branches in the relevant Deepgram provider
function.

Source: Linters/SAST tools

backend/app/audio/text_buffer.py-41-46 (1)

41-46: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Emit a sentence that ends at the current buffer boundary.

Line 41 rejects a terminator when it is the last buffered character. A value such as "Hello." then waits until a later delta or flush(). Accept an end-of-buffer terminator unless the abbreviation check rejects it.

Proposed fix
-            if index + 1 >= len(self._buffer) or not self._buffer[index + 1].isspace():
+            if index + 1 < len(self._buffer) and not self._buffer[index + 1].isspace():
                 continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/text_buffer.py` around lines 41 - 46, Update the
sentence-boundary logic around the buffer scan to accept a terminator at the
current buffer boundary, rather than continuing solely because index + 1 is
outside _buffer. Preserve the abbreviation and numeric-decimal exclusions in the
existing prefix check, so a final character such as “Hello.” emits immediately
while rejected abbreviations still wait.
backend/app/providers/groq.py-21-24 (1)

21-24: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Call response.close() in the finally block. groq==1.0.0 returns AsyncStream for stream=True, and AsyncStream exposes async close(), not aclose(). The current code skips cleanup after early cancellation or return.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/providers/groq.py` around lines 21 - 24, Update the cleanup in
the finally block to invoke the async close() method exposed by the response
returned from the streaming Groq request, rather than looking up aclose.
Preserve cleanup for early cancellation and return, and await the close
operation when available.
backend/app/api/audio_ws.py-275-296 (1)

275-296: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ruff reports E701/E702 errors on these lines.

Ruff flags multiple statements on one line at lines 275, 279, 281, 285, 287, 290, 291, 293, 296, 331, 333, 335, 341, 343, 344, and 345, and also at line 218. If Ruff runs in CI with these rules enabled, the lint job fails. Split the compound statements, or confirm that E701/E702 are excluded in the Ruff configuration.

Also applies to: 330-345

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/audio_ws.py` around lines 275 - 296, Resolve Ruff E701/E702
violations in the websocket handling flow around the message-processing block
and the related lines near 218 and 330-345 by splitting compound statements and
same-line control-flow statements into separate lines. Alternatively, update the
Ruff configuration to explicitly exclude E701/E702 if that is the intended
project policy, ensuring CI linting passes.

Source: Linters/SAST tools

backend/tests/integration/test_audio_websocket.py-118-131 (1)

118-131: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

The media assertion depends on task scheduling and can be flaky.

send_bytes only enqueues the chunk into media_queue. media_sender forwards it in a separate task. Leaving the websocket_connect block sends a disconnect, and the endpoint's finally block cancels media_task immediately. If media_sender has not been scheduled yet, connection.media stays empty and the assertion at line 129 fails.

Make the test wait for observable progress before closing the socket. For example, have FakeDeepgramConnection.send_media push into a queue that the endpoint acknowledges, or read one more server message after send_bytes so the server loop advances.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_audio_websocket.py` around lines 118 - 131,
Update the websocket integration test around send_bytes and the connection.media
assertion to wait for observable server-side progress before leaving the
websocket_connect block. Use an existing server acknowledgment/message path or
add a test-only synchronization mechanism through
FakeDeepgramConnection.send_media, ensuring the media chunk has been forwarded
before the socket closes and the assertions run.
backend/app/api/audio_ws.py-250-259 (1)

250-259: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep a reference to the close task created in observe.

asyncio.create_task(websocket.close(...)) at line 256 is not stored. The event loop keeps only a weak reference, so the task can be garbage-collected before the close completes. The receive loop then stays blocked on websocket.receive() until the browser acts, which defeats the purpose of the fast close.

Store the task in the existing observer_tasks list, which the finally block already cancels and gathers.

🛡️ Proposed fix
         def observe(task: asyncio.Task[object]) -> None:
             if task.cancelled() or worker_failure.done():
                 return
             error = task.exception()
             if error is not None:
                 worker_failure.set_result(error)
-                asyncio.create_task(websocket.close(code=1011, reason="session_worker_failed"))
+                observer_tasks.append(asyncio.create_task(websocket.close(code=1011, reason="session_worker_failed")))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/audio_ws.py` around lines 250 - 259, Store the task created
by websocket.close in the existing observer_tasks list within observe, rather
than discarding the create_task result. Preserve the existing worker_failure
handling and ensure the finally block can cancel and gather this close task.

Source: Linters/SAST tools

backend/livekit.dev.yaml-3-11 (1)

3-11: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Bind the development server to the loopback address.

The file combines bind_addresses: 0.0.0.0 with a well-known key pair (devkey/secret). With network_mode: host in docker-compose.livekit.yml, any host on the same network can mint tokens and create rooms on this server. For a local-only server, bind to 127.0.0.1.

🛡️ Proposed change
 bind_addresses:
-  - 0.0.0.0
+  - 127.0.0.1

If a phone or a second machine must reach the server during testing, keep 0.0.0.0 and document that the key pair must be changed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/livekit.dev.yaml` around lines 3 - 11, Update the bind_addresses
setting in the development LiveKit configuration to use 127.0.0.1 instead of
0.0.0.0, keeping the local-only default while leaving the RTC and keys settings
unchanged.
backend/README.md-7-20 (1)

7-20: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The setup block cannot run as one sequence.

docker compose -f docker-compose.livekit.yml up runs in the foreground and blocks. The uvicorn command on line 14 and the frontend commands never run in that shell. Add -d, or state that each server needs its own terminal.

📝 Proposed change
 # Start a local LiveKit server (Linux host networking):
-docker compose -f docker-compose.livekit.yml up
-uvicorn app.main:app --host 0.0.0.0 --port 3300
+docker compose -f docker-compose.livekit.yml up -d
+# Run the backend in this terminal, then use a new terminal for the frontend:
+uvicorn app.main:app --host 0.0.0.0 --port 3300
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/README.md` around lines 7 - 20, Update the README setup sequence so
the LiveKit Docker Compose command runs detached with the existing
configuration, allowing the subsequent uvicorn and frontend commands to execute
in the same shell.
backend/app/providers/elevenlabs.py-36-40 (1)

36-40: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close the socket if the initial handshake message fails.

__aenter__ connects and then sends the voice-settings message. If that send raises (ConnectionClosed, timeout, cancellation), __aenter__ propagates the error and __aexit__ never runs, because the context was never entered. self._socket stays open until garbage collection, which consumes an ElevenLabs concurrent stream slot.

🛡️ Proposed fix
     async def __aenter__(self) -> "ElevenLabsStream":
         self._socket = await connect(self.url, additional_headers={"xi-api-key": self.options.api_key},
                                      max_queue=self.options.max_queue, max_size=self.options.max_size)
-        await self._send({"text": " ", "voice_settings": {"stability": .5, "similarity_boost": .75}})
-        return self
+        try:
+            await self._send({"text": " ", "voice_settings": {"stability": .5, "similarity_boost": .75}})
+        except BaseException:
+            await self.close()
+            raise
+        return self
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/providers/elevenlabs.py` around lines 36 - 40, Update
ElevenLabsStream.__aenter__ to close self._socket when the initial _send
handshake fails, then re-raise the original exception. Ensure cleanup covers
connection, timeout, and cancellation failures while preserving normal entry
behavior and returning self after a successful handshake.
backend/app/main.py-52-55 (1)

52-55: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Disable voice_agent propagation.

app.core.logging sets the voice_agent logger to INFO before create_app() runs, so INFO events are not dropped. However, create_app() adds a handler and leaves propagation enabled, which can duplicate records when the root logger has a handler. Set voice_logger.propagate = False.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/main.py` around lines 52 - 55, Update create_app so the
voice_agent logger sets propagate to False after configuring its handler,
preventing records from being duplicated through the root logger.
backend/app/models/events.py-61-64 (1)

61-64: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Build _CLIENT_ADAPTER from the ClientEvent alias.

Line 63 restates the union ClientReady | PlaybackHardCut instead of reusing ClientEvent from line 62. The two definitions can diverge. If a third client event is added to ClientEvent, parse_client_event continues to reject it, and the failure appears as a protocol validation error far from this file.

♻️ Proposed fix to remove the duplicated union
 ServerEvent: TypeAlias = SessionReady | TurnStarted | AssistantTextDelta | AssistantState | AssistantInterrupted | ErrorEvent
 ClientEvent: TypeAlias = ClientReady | PlaybackHardCut
-_CLIENT_ADAPTER = TypeAdapter(ClientReady | PlaybackHardCut)
+_CLIENT_ADAPTER: TypeAdapter[ClientEvent] = TypeAdapter(ClientEvent)
 _SERVER_ADAPTER = TypeAdapter(ServerEvent)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/models/events.py` around lines 61 - 64, Update _CLIENT_ADAPTER to
construct its TypeAdapter from the ClientEvent alias instead of repeating
ClientReady | PlaybackHardCut, ensuring parse_client_event automatically
supports every event included in ClientEvent.
backend/app/audio/livekit_publisher.py-103-104 (1)

103-104: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Derive the error message from FRAME_BYTES.

The check uses FRAME_BYTES, but the message hardcodes 480 bytes. If FRAME_BYTES changes, the message reports the wrong size to whoever reads the traceback.

🐛 Proposed fix
         if len(frame) != FRAME_BYTES:
-            raise ValueError("PCM frame must be exactly 480 bytes")
+            raise ValueError(f"PCM frame must be exactly {FRAME_BYTES} bytes, got {len(frame)}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 103 - 104, Update the
validation error in the frame-length check within the publisher method to derive
the reported byte count from FRAME_BYTES instead of hardcoding 480, keeping the
existing ValueError behavior and condition unchanged.
backend/tests/integration/test_livekit_publisher.py-82-87 (1)

82-87: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace the scheduling yield with a deterministic wait.

Line 84 relies on one await asyncio.sleep(0) to let several steps complete: the worker resumes from get(), calls capture_frame, raises, the task finishes, and the done-callback records _worker_error. A single yield does not guarantee all of them.

The test likely passes today because write itself starts with await asyncio.sleep(0), which adds another yield. The outcome still depends on scheduling order and can flake under a different event loop policy. A flaky test on the error-propagation path is worse than no test, because it trains readers to re-run CI.

Await the worker task instead.

💚 Proposed fix
     await publisher.start_generation(1)
+    worker=publisher._worker
     await publisher.write(1, b'x' * FRAME_BYTES)
-    await asyncio.sleep(0)
+    await asyncio.wait({worker})
     with pytest.raises(RuntimeError, match='LiveKit audio publishing failed'):
         await publisher.write(1, b'x' * FRAME_BYTES)

asyncio.wait does not re-raise the worker's exception, so the assertion below still exercises the deferred surfacing path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_livekit_publisher.py` around lines 82 - 87,
Replace the scheduling-only asyncio.sleep(0) in the publisher error-path test
with a deterministic wait for the worker task, such as awaiting it through
asyncio.wait without propagating its exception. Ensure the worker has completed
and its done-callback has recorded _worker_error before the subsequent
publisher.write assertion, while preserving the deferred RuntimeError behavior
being tested.
backend/app/services/admission.py-82-84 (1)

82-84: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not rely on the pre-accept close code

With Uvicorn 0.46.0, websocket.close() before accept() returns an HTTP 403 handshake rejection. The browser does not receive 4403 or 4429, and the frontend does not inspect close codes. Accept the socket before closing when the frontend must distinguish errors; otherwise document all handshake failures as generic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/admission.py` around lines 82 - 84, Update
reject_before_accept so it accepts the WebSocket before closing when the
frontend must distinguish AdmissionRejected errors, preserving error.code and
error.error_code in the close call; otherwise document that pre-accept handshake
failures are generic and do not expose the configured close code.
🧹 Nitpick comments (23)
backend/tests/unit/test_admission.py (1)

9-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Resolve the reported Ruff E701 and E702 violations.

Split each executable statement onto its own physical line.

  • backend/tests/unit/test_admission.py#L9-L10: Split the conditional statements from the assignments.
  • backend/tests/unit/test_admission.py#L20-L20: Split the context manager from the awaited call.
  • backend/tests/unit/test_admission.py#L23-L23: Split the context manager from the awaited call.
  • backend/tests/unit/test_audio_ws_buffers.py#L23-L23: Split the queue retrieval from the completion call.
  • backend/tests/unit/test_events_config_logging.py#L11-L11: Split base.update(more) from return base.
  • backend/tests/unit/test_events_config_logging.py#L22-L22: Put the Settings call on a separate line from the context manager.
  • backend/tests/unit/test_events_config_logging.py#L24-L24: Put the parser call on a separate line from the context manager.
  • backend/tests/unit/test_generation_runner.py#L11-L11: Expand the double initializer.
  • backend/tests/unit/test_generation_runner.py#L19-L19: Expand the double initializer.
  • backend/tests/unit/test_generation_runner.py#L23-L23: Split the append operation from the event set.
  • backend/tests/unit/test_generation_runner.py#L31-L31: Expand the test setup assignments.
  • backend/tests/unit/test_generation_runner.py#L42-L42: Expand the test setup assignments.
  • backend/tests/unit/test_generation_runner.py#L60-L60: Expand the test setup assignments.
  • backend/tests/unit/test_generation_runner.py#L82-L82: Expand the test setup assignments.
  • backend/tests/unit/test_turn_manager.py#L21-L22: Put each cancellation and close call on its own line.
  • backend/tests/unit/test_voice_session.py#L31-L31: Split the sink and clock initialization.
  • backend/tests/unit/test_voice_session.py#L35-L35: Split the turn signal from the clock update.
  • backend/tests/unit/test_voice_session.py#L37-L37: Put each close call on its own line.
  • backend/tests/unit/test_voice_session.py#L42-L42: Expand the test setup assignments.
  • backend/tests/unit/test_voice_session.py#L44-L44: Split generation start from the turn signal.
  • backend/tests/unit/test_voice_session.py#L59-L59: Expand the test setup assignments.
  • backend/tests/unit/test_voice_session.py#L62-L62: Split the turn signal from the clock update.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/unit/test_admission.py` around lines 9 - 10, Resolve Ruff
E701/E702 across the listed test files by placing every executable statement on
its own physical line: in backend/tests/unit/test_admission.py (9-10, 20, 23),
split conditional assignments and context managers from awaited calls;
backend/tests/unit/test_audio_ws_buffers.py (23), separate queue retrieval from
completion; backend/tests/unit/test_events_config_logging.py (11, 22, 24),
separate update/return, Settings/context-manager, and parser/context-manager
calls; backend/tests/unit/test_generation_runner.py (11, 19, 23, 31, 42, 60,
82), expand double initializers and setup assignments, and separate
append/event-set operations; backend/tests/unit/test_turn_manager.py (21-22),
put cancellation and close calls on separate lines;
backend/tests/unit/test_voice_session.py (31, 35, 37, 42, 44, 59, 62), split
initialization, signal/clock, close, setup, and generation/signal statements.
Preserve the existing test behavior.

Source: Linters/SAST tools

frontend/app/hooks/useVoiceSession.ts (1)

140-140: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the dead start guard and the device log.

Two small items:

  • Line 279 and line 287 set and clear startingRef with no await between them. The guard at line 271 can never observe true. Remove the ref or drop the guard.
  • Line 140 logs the full microphone track settings at info level on every session start. Use console.debug, or remove the log.
♻️ Proposed refactor
       const track = stream.getAudioTracks()[0];
       if (track) {
-        console.info("Voice microphone processing", track.getSettings());
+        console.debug("Voice microphone processing", track.getSettings());
       }

Also applies to: 279-287

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/hooks/useVoiceSession.ts` at line 140, Remove the ineffective
startingRef guard and its associated set/clear operations in the voice-session
start flow, since no await allows it to prevent concurrent starts. Change the
microphone track settings log near the session-start handling from console.info
to console.debug, or remove it.
frontend/tests/config.test.ts (1)

20-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cover the remaining validation branches.

getAudioWebSocketUrl throws for three distinct reasons. The suite covers only the path check. Add cases for an unparsable value and for a non-ws scheme.

♻️ Proposed additional tests
   it("rejects a configured URL outside the audio route", () => {
     process.env.NEXT_PUBLIC_WS_URL = "ws://127.0.0.1:3300/other";
     expect(() => getAudioWebSocketUrl(location)).toThrow(
       "NEXT_PUBLIC_WS_URL must target /ws/audio",
     );
   });
+
+  it("rejects a value that is not a URL", () => {
+    process.env.NEXT_PUBLIC_WS_URL = "not a url";
+    expect(() => getAudioWebSocketUrl(location)).toThrow(
+      "NEXT_PUBLIC_WS_URL must be a valid ws(s) URL",
+    );
+  });
+
+  it("rejects a non-websocket scheme", () => {
+    process.env.NEXT_PUBLIC_WS_URL = "http://127.0.0.1:3300/ws/audio";
+    expect(() => getAudioWebSocketUrl(location)).toThrow(
+      "NEXT_PUBLIC_WS_URL must use ws or wss",
+    );
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/tests/config.test.ts` around lines 20 - 37, Extend the
getAudioWebSocketUrl test suite with cases for an unparsable NEXT_PUBLIC_WS_URL
value and a URL using a non-ws scheme. Assert that each invocation throws the
corresponding validation error, while preserving the existing path-validation
coverage.
frontend/app/hooks/useSocket.ts (1)

59-71: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Log dropped server events.

parseServerEvent returns null for a payload that has valid JSON but an unknown type or a failed field check. The handler then drops the payload with no signal. If the backend event schema in backend/app/models/events.py changes ahead of frontend/app/lib/protocol.ts, the session goes silent with no diagnostic trace.

Add a debug-level log for the dropped payload type.

♻️ Proposed refactor
         try {
           const event = parseServerEvent(JSON.parse(message.data));
           if (event) {
             handlersRef.current.onEvent(event, sendControl);
+          } else {
+            console.debug("Ignored unrecognized voice-session event.");
           }
         } catch {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/hooks/useSocket.ts` around lines 59 - 71, In the socket
onmessage handler, update the branch where parseServerEvent returns null to emit
a debug-level log containing the dropped payload’s type. Preserve the existing
event dispatch and invalid-JSON error handling, and use the project’s
established logging mechanism.
frontend/tests/useVoiceSession.test.ts (2)

171-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a failed assistant generation.

No test sends assistant.state with state: "failed". That branch in frontend/app/hooks/useVoiceSession.ts lines 227-230 currently sets the error state without running cleanup, so the microphone and the socket stay active. A test that asserts teardown on that event would catch the leak and lock in the corrected behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/tests/useVoiceSession.test.ts` around lines 171 - 180, Add a test
alongside the existing useVoiceSession lifecycle tests that sends an
assistant.state event with state "failed" after starting and reaching session
readiness, then assert recorder stop, socket disconnect, and LiveKit disconnect
are called. Use the existing socketOptions.onEvent, sessionReady, socket, stop,
liveKit, and renderHook setup to verify failed assistant generation performs the
same cleanup as end.

16-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the doubles match the real contracts.

Two fidelity gaps:

  • Line 20: unlockAudio: vi.fn() returns undefined. useVoiceSession calls unlockAudio().then(...) in enableSound, so any test that calls enableSound without mockResolvedValueOnce throws a TypeError. Give the mock a default async implementation.
  • MockRecorder discards the constructor options. The suite therefore cannot assert the requested mimeType, which is the value that fails on browsers without WebM recording support.
♻️ Proposed refactor
-  unlockAudio: vi.fn(),
+  unlockAudio: vi.fn(async () => true),
 class MockRecorder {
   static instances: MockRecorder[] = [];
+  static isTypeSupported = vi.fn(() => true);
   state = "inactive";
+  mimeType: string;
   ondataavailable: ((event: BlobEvent) => void) | null = null;
   start = vi.fn(() => { this.state = "recording"; });
   stop = vi.fn(() => { this.state = "inactive"; });
 
-  constructor() {
+  constructor(_stream: MediaStream, options?: MediaRecorderOptions) {
+    this.mimeType = options?.mimeType ?? "";
     MockRecorder.instances.push(this);
   }
 }

Also applies to: 50-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/tests/useVoiceSession.test.ts` around lines 16 - 25, Update the
liveKit test double’s unlockAudio mock to resolve by default so enableSound can
safely call its promise methods without per-test setup. Update MockRecorder to
retain the constructor options, including mimeType, so tests can inspect and
assert the requested recording configuration.
backend/app/api/audio_ws.py (1)

316-327: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Compare against the WebSocketState enum instead of the literal 1.

websocket.application_state.value == 1 depends on the numeric order of Starlette's WebSocketState members. Use the enum member so the intent is explicit and the check survives an upstream reordering.

♻️ Proposed refactor
+from starlette.websockets import WebSocketState
+
-            if websocket.application_state.value == 1:
+            if websocket.application_state is WebSocketState.CONNECTED:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/audio_ws.py` around lines 316 - 327, Update the
application-state check in the websocket error-sending block to compare
websocket.application_state against the appropriate WebSocketState enum member
instead of the literal value 1, preserving the existing send and suppression
behavior.
backend/app/core/config.py (1)

27-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Ignore environment variables for explicit Settings construction.

_env_file=None disables dotenv loading, but process environment variables still populate omitted fields. Use an init-only settings source when explicit construction must be deterministic.

split_origins also raises TypeError for non-iterable non-string values. Pydantic v2 does not convert this exception to ValidationError, so it bypasses the sanitized error mapping. Raise ValueError for unsupported values if all startup errors must use the same format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/config.py` around lines 27 - 37, Update Settings.__init__ to
use an init-only settings source when explicit values are supplied, preventing
process environment variables from filling omitted fields while preserving
normal environment loading otherwise. Update split_origins to raise ValueError
for non-iterable, non-string inputs so Settings.__init__ consistently sanitizes
startup failures through its existing error mapping.
backend/app/core/logging.py (1)

19-20: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that the value is monotonic, not epoch-based.

monotonic_ms returns an arbitrary origin. The value is valid for durations only. The protocol sends a related monotonic value to the browser in AssistantInterrupted.qualified_interrupt_at_ms. A future reader can compare that field to Date.now() and get a meaningless result. Add a one-line docstring to state the constraint.

📝 Proposed docstring
 def monotonic_ms() -> int:
+    """Return monotonic milliseconds. The origin is arbitrary; use for durations only."""
     return int(time.monotonic() * 1000)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/logging.py` around lines 19 - 20, Update the monotonic_ms
function with a one-line docstring stating that its result uses an arbitrary
monotonic origin and is suitable only for measuring durations, not comparing
with epoch-based timestamps such as Date.now().
backend/app/audio/livekit_publisher.py (3)

97-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Explain the asyncio.sleep(0) yield.

Line 98 yields control so a pending done-callback from _observe_worker can record _worker_error before line 99 reads it. The integration test at backend/tests/integration/test_livekit_publisher.py lines 83-86 depends on this ordering.

The call looks removable to a future reader. Add a comment to protect it.

📝 Proposed comment
     async def write(self, generation_id: int, frame: bytes) -> None:
-        await asyncio.sleep(0)
+        # Yield so a completed worker's done-callback records _worker_error before the check below.
+        await asyncio.sleep(0)
         self._raise_worker_error()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 97 - 99, Add a concise
comment immediately above asyncio.sleep(0) in the write method explaining that
the yield lets _observe_worker’s pending done-callback record _worker_error
before _raise_worker_error() checks it; preserve the existing ordering and
behavior.

69-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Log the worker error message, not only its type.

log_event("livekit.publisher_failed", ...) records error_type only. A RuntimeError from a capture failure and a RuntimeError from a different cause produce identical log lines. The stored exception is also re-raised later from a different call site, so this callback is the only place the original context exists.

Add a truncated message to keep the record diagnosable.

📝 Proposed fix
-            log_event("livekit.publisher_failed", generation_id=generation_id, error_type=type(error).__name__)
+            log_event("livekit.publisher_failed", level=logging.ERROR, generation_id=generation_id,
+                      error_type=type(error).__name__, error=str(error)[:200])

Add import logging at the top of the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 69 - 78, Update
_observe_worker to include a truncated representation of the captured error
message in the livekit.publisher_failed log event alongside error_type. Add the
necessary logging import if needed to apply the project’s established truncation
or formatting approach, while preserving the existing exception storage and
hard_cut behavior.

20-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Construct LiveKitMetadata with keyword arguments.

The dataclass has six fields and five are str. Lines 50-51 construct it positionally. A field reorder or an inserted field silently mis-assigns values with no type error.

The consequence is concrete. build_session_ready in backend/app/api/audio_ws.py maps metadata.assistant_identity to participant_identity, which the browser uses to find the track to subscribe to. If browser_identity and assistant_identity were ever swapped, the browser would subscribe to the wrong identity and audio playback would fail with no error anywhere in the pipeline.

♻️ Proposed fix
-        self.metadata = LiveKitMetadata(room_name, browser_identity, assistant_identity, ASSISTANT_TRACK_NAME,
-                                        browser_token, self.settings.LIVEKIT_URL)
+        self.metadata = LiveKitMetadata(room_name=room_name, browser_identity=browser_identity,
+                                        assistant_identity=assistant_identity, track_name=ASSISTANT_TRACK_NAME,
+                                        browser_token=browser_token, livekit_url=self.settings.LIVEKIT_URL)

To enforce this, add kw_only=True to the decorator:

-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
 class LiveKitMetadata:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 20 - 27, Update the
LiveKitMetadata dataclass decorator to use kw_only=True, and change its
construction near the affected lines to pass all fields by keyword rather than
position. Preserve the existing field values and names so build_session_ready
continues mapping assistant_identity correctly.
backend/tests/integration/test_livekit_publisher.py (3)

50-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Close the publisher at the end of this test.

The sibling tests call await publisher.close(). This test does not. start_generation(8) created a _consume worker, and hard_cut(8) cancelled it without awaiting it. The cancelled task is still pending when the event loop tears down, which can produce a pending-task warning that appears to come from an unrelated test.

💚 Proposed fix
     assert publisher._generation_id is None
     assert publisher._queue.empty() and source.clears == 1
+    await publisher.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_livekit_publisher.py` around lines 50 - 60,
Add cleanup to test_hard_cut_is_synchronous_invalidates_and_clears by awaiting
publisher.close() after the existing assertions. Ensure the cancelled _consume
worker created by start_generation is awaited before the test finishes, matching
the cleanup used by sibling publisher tests.

9-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the create_and_publish failure path.

The fakes never fail. FakeRoom.connect and FakeParticipant.publish_track always succeed, so no test reaches the except BaseException: await self.close(); raise branch on lines 58-60 of backend/app/audio/livekit_publisher.py.

That branch carries the room-deletion guarantee. A test that makes connect raise, and then asserts deleted == [room_name], would pin the behavior. A second test that cancels the task during connect would cover the cancellation case discussed on that file.

Regarding Ruff S106 on line 28: the credentials are dummy test values, so the rule is a false positive here. If Ruff runs the S rules over tests in CI, add a per-file ignore for the test directory rather than changing the fixture.

💚 Proposed test for the failure path
`@pytest.mark.asyncio`
async def test_failed_connect_deletes_the_room(monkeypatch):
    import app.audio.livekit_publisher as module

    class BrokenRoom(FakeRoom):
        async def connect(self, url, token):
            raise RuntimeError('connect failed')

    api_client = FakeAPI()
    monkeypatch.setattr(module.rtc, 'AudioSource', lambda *a, **kw: FakeSource())
    monkeypatch.setattr(module.rtc, 'Room', BrokenRoom)
    publisher = LiveKitPublisher(settings(), api_client)
    with pytest.raises(RuntimeError, match='connect failed'):
        await publisher.create_and_publish()
    assert api_client.room.deleted == api_client.room.created

Do you want me to add this test and the cancellation variant?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_livekit_publisher.py` around lines 9 - 28, Add
tests for the create_and_publish failure path using a BrokenRoom whose connect
method raises, then assert the exception propagates and FakeRoomService.deleted
matches the created room. Add a cancellation-during-connect test to cover
cleanup on task cancellation, and configure Ruff with a per-file S-rule ignore
for the test directory rather than changing settings() credentials.

Source: Linters/SAST tools


62-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the replaced worker has terminated.

This test confirms the new generation id and a single clear_queue call. It does not confirm that the previous _consume worker finished. The test therefore passes while the old worker is still running, which is the race described on lines 84-95 of backend/app/audio/livekit_publisher.py.

Capture the first worker and assert it is done after the replacement. That assertion fails today and passes once start_generation awaits the cancelled worker.

💚 Proposed fix
     await publisher.start_generation(1)
+    first_worker=publisher._worker
     await publisher.start_generation(2)
     assert publisher._generation_id == 2
     assert source.clears == 1
+    assert first_worker is not publisher._worker
+    assert first_worker.done()
     await publisher.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_livekit_publisher.py` around lines 62 - 72,
Update test_starting_replacement_generation_hard_cuts_the_old_generation to
capture the first generation’s _consume worker before starting generation 2,
then assert that captured worker has terminated after the replacement. Ensure
start_generation awaits the cancelled previous worker so this assertion verifies
the race is fixed.
backend/app/core/turn_manager.py (2)

45-47: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider emitting sooner on an explicit end-of-turn signal.

on_end_of_turn starts a full debounce cycle. The provider's end-of-turn signal is stronger evidence that the user stopped speaking than the silence timer is. The current path adds about debounce_seconds of latency, which defaults to 2 seconds, before the assistant starts generating.

If the signal is trustworthy, use a shorter debounce for this path so the first token arrives sooner. Keep the debounce for the timer path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/turn_manager.py` around lines 45 - 47, Update on_end_of_turn
to schedule with a shorter explicit-end debounce instead of the full
debounce_seconds delay, while preserving the existing full debounce behavior for
the silence-timer path and the current closed/transcript guards.

78-82: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Await the cancelled task in close for deterministic shutdown.

close is async but performs no await. It cancels the pending task and returns before that task finishes unwinding. A caller that awaits close can therefore still have an in-flight callback running afterward.

This matters during shutdown. The lifespan handler gathers session.close() and then tears down providers and the LiveKit API client. A still-unwinding callback can touch a provider that is already closing. Await the task so close is a real barrier.

♻️ Proposed fix to make `close` a barrier
     async def close(self) -> None:
         if self._closed:
             return
         self._closed = True
-        self.cancel_pending()
+        task, self._pending_task = self._pending_task, None
+        if task and not task.done():
+            task.cancel()
+            with contextlib.suppress(asyncio.CancelledError):
+                await task

Add import contextlib at the top of the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/turn_manager.py` around lines 78 - 82, Update
TurnManager.close to await the task cancelled by cancel_pending, making the
method a shutdown barrier before returning. Use contextlib to suppress the
expected cancellation exception while preserving the existing idempotent _closed
guard and cancellation flow.
backend/app/services/voice_session.py (3)

40-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Expand the compressed statements that Ruff flags.

Ruff reports E701 on lines 48, 61, 64, 89, 94, 101, 106, and 107, and E702 on lines 82, 90, and 104. If these rules run in CI, the build fails.

The density also hurts the code that matters most. Line 82 packs task creation, set insertion, and callback registration onto one line, and lines 104 and 107 compress shutdown steps. This is the barge-in and teardown path, where ordering is the correctness argument.

Run ruff format on the file, or expand the flagged lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/voice_session.py` around lines 40 - 45, Expand all Ruff
E701 and E702 violations in the voice-session implementation, including the
generation_id, active_generation_id, and closed properties and the
barge-in/teardown paths. Replace semicolon- and inline-statement chains with
separate indented statements, preserving task creation, set insertion, callback
registration, and shutdown ordering; run Ruff formatting on the file to verify
the result.

Source: Linters/SAST tools


33-38: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the per-generation dictionaries.

_turn_ids grows on every start_generation and is never pruned. _interrupt_at is pruned only in playback_hard_cut, which runs only when the browser sends playback.hard_cut. If the browser disconnects after an interruption, or a generation completes without an interruption, entries remain.

Both dicts are per-session, so growth is bounded by the session lifetime rather than by process lifetime. A long session still accumulates one entry per turn indefinitely.

Note that turn_id raises KeyError for a missing generation, so entries cannot be dropped without care. A bounded mapping is the safer fix.

♻️ Proposed direction using a bounded mapping
+from collections import OrderedDict
-        self._turn_ids: dict[int, str] = {}
+        self._turn_ids: OrderedDict[int, str] = OrderedDict()
     def start_generation(self, turn_id: str | None = None) -> int:
         if self._closed: raise RuntimeError("session is closed")
         self._generation_id += 1
         self._active_generation = self._generation_id
         self._turn_ids[self._generation_id] = turn_id or str(uuid.uuid4())
+        while len(self._turn_ids) > _MAX_TRACKED_GENERATIONS:
+            self._turn_ids.popitem(last=False)
         return self._generation_id

Apply the same trimming to _interrupt_at, and define _MAX_TRACKED_GENERATIONS as a module constant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/voice_session.py` around lines 33 - 38, Introduce a
module-level _MAX_TRACKED_GENERATIONS limit and use bounded mappings for both
_turn_ids and _interrupt_at. Preserve the existing turn_id missing-generation
behavior while trimming the oldest entries when new generations exceed the
limit, and apply the same bounded cleanup regardless of whether
playback_hard_cut occurs.

100-109: 🩺 Stability & Availability | 🔵 Trivial

Consider a timeout around session shutdown.

The ordering here is correct, and the sink-ownership comment matches how lifespan in backend/app/main.py gathers session.close() before livekit_api.aclose().

One operational risk remains. Neither close nor the lifespan gather applies a timeout. audio_sink.close() calls delete_room against the LiveKit server. If that server is unreachable or slow, close blocks, and the lifespan gather blocks with it. Process shutdown then hangs until an external supervisor kills it, which turns a deploy into an outage.

Apply a bounded timeout at the shutdown boundary and log the sessions that did not finish.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/voice_session.py` around lines 100 - 109, Add a bounded
timeout around the lifespan shutdown gather that awaits each session.close(),
and log the sessions that remain unfinished when the timeout expires. Preserve
the existing shutdown ordering so session cleanup completes before
livekit_api.aclose(), while ensuring a slow audio_sink.close() cannot block
process shutdown indefinitely.
backend/app/audio/protocols.py (1)

1-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Give the sentinel a distinct type so the queue union can discriminate.

PCM_COMPLETED = object() types as object. In backend/app/audio/livekit_publisher.py the queue is annotated asyncio.Queue[tuple[int, bytes | object]], and bytes | object collapses to object. The type checker then loses the frame type, which is why line 154 of that file needs # type: ignore[arg-type] when it passes item to rtc.AudioFrame.

A dedicated singleton type fixes both. After the change, a item is PCM_COMPLETED check narrows the union to bytes, and the ignore comment can be removed.

Add a module docstring as well, to match the sibling modules in this package.

♻️ Proposed typed sentinel
+"""Audio output contracts shared by the generation runner and the LiveKit publisher."""
 from __future__ import annotations
-from typing import Protocol, runtime_checkable
+from enum import Enum
+from typing import Protocol, runtime_checkable
 
-PCM_COMPLETED = object()
+
+class PcmControl(Enum):
+    """Non-audio control markers carried on the PCM queue."""
+
+    COMPLETED = "completed"
+
+
+PCM_COMPLETED = PcmControl.COMPLETED

Then in backend/app/audio/livekit_publisher.py:

-        self._queue: asyncio.Queue[tuple[int, bytes | object]] = asyncio.Queue(maxsize=settings.PCM_QUEUE_FRAMES)
+        self._queue: asyncio.Queue[tuple[int, bytes | PcmControl]] = asyncio.Queue(maxsize=settings.PCM_QUEUE_FRAMES)
-                frame = rtc.AudioFrame(item, 24_000, 1, SAMPLES_PER_FRAME)  # type: ignore[arg-type]
+                frame = rtc.AudioFrame(item, 24_000, 1, SAMPLES_PER_FRAME)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/protocols.py` around lines 1 - 4, Replace the untyped
PCM_COMPLETED object in the protocols module with a dedicated singleton sentinel
type and annotate the sentinel accordingly, adding the requested module
docstring. Update the queue and item handling in livekit_publisher around the
PCM_COMPLETED identity check so the union narrows to bytes, then remove the
unnecessary type: ignore[arg-type] on the rtc.AudioFrame call.
backend/app/services/admission.py (1)

45-51: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Document the proxy-trust requirement for TRUST_PROXY_HEADERS.

The leftmost x-forwarded-for entry is the correct choice when the header is trusted, and the fallback to the socket peer is the right default. One deployment hazard is worth recording next to the code: if TRUST_PROXY_HEADERS is enabled without a proxy that overwrites the header, a client sets its own x-forwarded-for and bypasses MAX_SESSIONS_PER_IP completely.

Add a comment so the coupling stays visible to operators.

📝 Proposed comment
     def client_ip(self, websocket: WebSocket) -> str:
         headers: Mapping[str, str] = websocket.headers
+        # Enable TRUST_PROXY_HEADERS only behind a proxy that overwrites x-forwarded-for.
+        # Otherwise a client sets the header itself and bypasses MAX_SESSIONS_PER_IP.
         if self.settings.TRUST_PROXY_HEADERS:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/admission.py` around lines 45 - 51, Add an inline
comment in client_ip next to the TRUST_PROXY_HEADERS check documenting that it
must only be enabled behind a proxy that overwrites x-forwarded-for; otherwise
clients can spoof the header and bypass MAX_SESSIONS_PER_IP. Preserve the
existing leftmost-entry parsing and socket-peer fallback.
backend/app/models/events.py (1)

61-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use type as the discriminator for both event unions.

Each member has a unique Literal value, so Field(discriminator="type") enables direct model selection and focused validation errors. parse_client_event converts validation errors to a generic WebSocket close reason, so the error-detail benefit applies only to server-side diagnostics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/models/events.py` around lines 61 - 67, Define the client and
server event unions with `type` as their discriminator by applying
`Field(discriminator="type")` to both `ClientEvent` and `ServerEvent`. Update
the corresponding `TypeAdapter` definitions, including `_CLIENT_ADAPTER` and
`_SERVER_ADAPTER`, to use the discriminated unions while preserving
`parse_client_event` behavior.

Comment on lines +54 to +61
try:
await self._room.connect(self.settings.LIVEKIT_URL, assistant_token)
track = rtc.LocalAudioTrack.create_audio_track(ASSISTANT_TRACK_NAME, self._source)
await self._room.local_participant.publish_track(track)
except BaseException:
await self.close()
raise
return self.metadata

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cleanup can be interrupted, and the LiveKit room leaks.

except BaseException catches asyncio.CancelledError, then awaits self.close() inside the handler. close awaits room.disconnect() and delete_room. When the original exception is a CancelledError, an await inside the handler can raise a second CancelledError and abort close partway. The room is then never deleted.

This path is routine, not exceptional. The WebSocket task is cancelled whenever the browser disconnects. If the client disconnects while _room.connect or publish_track is in flight, cleanup runs under cancellation and can leave an orphaned room on the LiveKit server for every such session.

Shield the cleanup so it completes.

🛡️ Proposed fix to protect cleanup from cancellation
         try:
             await self._room.connect(self.settings.LIVEKIT_URL, assistant_token)
             track = rtc.LocalAudioTrack.create_audio_track(ASSISTANT_TRACK_NAME, self._source)
             await self._room.local_participant.publish_track(track)
         except BaseException:
-            await self.close()
+            # Shield so a cancellation of this task cannot abort room deletion.
+            with contextlib.suppress(Exception):
+                await asyncio.shield(asyncio.ensure_future(self.close()))
             raise
         return self.metadata

Consider adding a timeout around the shielded cleanup so a hung LiveKit API call cannot delay the unwind indefinitely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 54 - 61, Update the
exception handler in the publisher method containing _room.connect and
publish_track so self.close() runs under cancellation protection and completes
even when the original error is asyncio.CancelledError. Wrap the shielded
cleanup in a bounded timeout so a hung LiveKit disconnect or delete operation
cannot block unwinding indefinitely, then re-raise the original exception.

Comment on lines +84 to +95
async def start_generation(self, generation_id: int) -> None:
if self._closed or self._source is None:
raise RuntimeError("LiveKit publisher is unavailable")
self._raise_worker_error()
old_generation = self._generation_id
if old_generation is not None:
# Never clear a new generation when replacing an old one.
self.hard_cut(old_generation)
self._generation_id = generation_id
self._worker_error = None
self._worker = asyncio.create_task(self._consume(generation_id))
self._worker.add_done_callback(self._observe_worker)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Await the cancelled worker before starting the replacement.

hard_cut(old_generation) on line 91 calls self._worker.cancel() but does not await the task. Line 94 then overwrites self._worker with a new _consume task. Cancellation is delivered on a later loop iteration, so the old worker and the new worker can both run for a short window, and both loop on the same shared self._queue.

The old worker captured the old id in its generation parameter. If it wins a get() race during that window, it takes a frame belonging to the new generation and drops it on line 146 as worker_generation_invalid. The frame is consumed and never published.

The user-visible result is clipped assistant audio at the start of a replacement turn. Replacement is the barge-in path, so this window is reached whenever a user interrupts.

🐛 Proposed fix to serialize worker replacement
         old_generation = self._generation_id
         if old_generation is not None:
             # Never clear a new generation when replacing an old one.
             self.hard_cut(old_generation)
+        # Await the cancelled worker so it cannot consume frames of the new generation.
+        previous = self._worker
+        if previous and not previous.done():
+            with contextlib.suppress(asyncio.CancelledError, Exception):
+                await previous
         self._generation_id = generation_id
         self._worker_error = None
         self._worker = asyncio.create_task(self._consume(generation_id))
         self._worker.add_done_callback(self._observe_worker)

Note that _observe_worker runs for the cancelled task first. It returns early on task.cancelled(), so it will not overwrite _worker_error after line 93 clears it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def start_generation(self, generation_id: int) -> None:
if self._closed or self._source is None:
raise RuntimeError("LiveKit publisher is unavailable")
self._raise_worker_error()
old_generation = self._generation_id
if old_generation is not None:
# Never clear a new generation when replacing an old one.
self.hard_cut(old_generation)
self._generation_id = generation_id
self._worker_error = None
self._worker = asyncio.create_task(self._consume(generation_id))
self._worker.add_done_callback(self._observe_worker)
async def start_generation(self, generation_id: int) -> None:
if self._closed or self._source is None:
raise RuntimeError("LiveKit publisher is unavailable")
self._raise_worker_error()
old_generation = self._generation_id
if old_generation is not None:
# Never clear a new generation when replacing an old one.
self.hard_cut(old_generation)
# Await the cancelled worker so it cannot consume frames of the new generation.
previous = self._worker
if previous and not previous.done():
with contextlib.suppress(asyncio.CancelledError, Exception):
await previous
self._generation_id = generation_id
self._worker_error = None
self._worker = asyncio.create_task(self._consume(generation_id))
self._worker.add_done_callback(self._observe_worker)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 84 - 95, Make
start_generation asynchronous replacement serialize the old worker before
creating the new one: after hard_cut(old_generation), await the cancelled
existing worker task and handle its expected cancellation, then assign the new
generation and create _consume. Preserve _observe_worker behavior and ensure
_worker is not overwritten until the prior task has fully stopped.

Comment on lines +115 to +124
worker = self._worker
if worker:
try:
await worker
except asyncio.CancelledError:
raise
except Exception:
# The callback recorded the original failure and hard-cut synchronously.
pass
self._raise_worker_error()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

A concurrent hard cut turns finish into a spurious cancellation.

hard_cut cancels self._worker. If a barge-in occurs while finish awaits that worker on line 118, await worker raises CancelledError, and lines 119-120 re-raise it.

The caller was not cancelled. backend/app/services/generation.py line 194 awaits audio_sink.finish(request.generation_id), so the runner task receives a CancelledError that did not originate from its own cancellation. That unwinds the runner as though the request were cancelled and corrupts asyncio's cancellation bookkeeping for that task.

Distinguish worker cancellation from caller cancellation. A cancelled worker after a hard cut is a normal barge-in outcome and should return quietly.

Regarding the Ruff S110 and BLE001 hints on lines 121-123: the suppression is intentional, because _observe_worker records the failure and line 124 re-raises it. Keep the behavior and silence the rules explicitly.

🐛 Proposed fix
         worker = self._worker
         if worker:
-            try:
-                await worker
-            except asyncio.CancelledError:
-                raise
-            except Exception:
-                # The callback recorded the original failure and hard-cut synchronously.
-                pass
+            # Wait without absorbing the worker's own cancellation into this task.
+            await asyncio.wait({worker})
+            if worker.cancelled():
+                # A concurrent hard cut cancelled the worker. This is a normal barge-in.
+                return
+            if worker.exception() is not None:
+                # _observe_worker recorded the failure; _raise_worker_error below reports it.
+                pass
         self._raise_worker_error()

asyncio.wait does not propagate the awaited task's exception or cancellation, so a genuine cancellation of the caller still interrupts the await correctly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
worker = self._worker
if worker:
try:
await worker
except asyncio.CancelledError:
raise
except Exception:
# The callback recorded the original failure and hard-cut synchronously.
pass
self._raise_worker_error()
worker = self._worker
if worker:
# Wait without absorbing the worker's own cancellation into this task.
await asyncio.wait({worker})
if worker.cancelled():
# A concurrent hard cut cancelled the worker. This is a normal barge-in.
return
if worker.exception() is not None:
# _observe_worker recorded the failure; _raise_worker_error below reports it.
pass
self._raise_worker_error()
🧰 Tools
🪛 Ruff (0.16.1)

[error] 121-123: try-except-pass detected, consider logging the exception

(S110)


[warning] 121-121: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 115 - 124, Update finish
around self._worker to wait via asyncio.wait so worker cancellation from
hard_cut is observed without re-raising it as caller cancellation, while genuine
cancellation of the caller still interrupts finish. Preserve _observe_worker’s
recorded-error handling and _raise_worker_error propagation, and explicitly
suppress the intentional Ruff S110 and BLE001 diagnostics around the
worker-await exception handling.

Comment on lines +171 to +178
if self._created_room and self.metadata:
try:
await self.api_client.room.delete_room(room_proto.DeleteRoomRequest(room=self.metadata.room_name))
except Exception as exc:
if "not found" not in str(exc).lower():
raise
finally:
self._created_room = False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

livekit-api Python TwirpError code attribute delete_room room not found error type

💡 Result:

A TwirpError with code "not_found" when calling delete_room in LiveKit indicates that the LiveKit Server does not recognize the specified room name or ID as an active room [1][2][3]. ### Understanding the Error When you call delete_room, you are instructing the LiveKit SFU (Selective Forwarding Unit) to terminate a specific room session. A "not_found" error is the authoritative signal from the server that the room does not exist in its current, active state [4]. This can occur for several reasons: 1. Room Already Deleted: The room has already been closed, either by a previous delete_room call or because it naturally expired due to inactivity (e.g., all participants left and the empty timeout was reached) [4][3]. 2. Stale Observability Data: You may be attempting to delete a room based on information from an observability dashboard or historical session record [4]. These dashboards sometimes show sessions as "Active" due to delays in synchronization or long-running tasks (like Egress jobs) even after the actual room SFU process has terminated [4][5]. 3. Race Conditions: In rare cases, intermittent 404 errors can occur due to server-side timing issues or propagation delays, especially under high load or in clustered environments [1][6]. ### Recommended Actions * Verify Active Status: Use the LiveKit CLI command lk room list to check if the room is truly active on the server [4][5]. If the room does not appear in this list, it is not active, and a 404 on delete_room is expected behavior [4]. * Check for Stuck Egress/Ingress Jobs: If you believe a room should be gone but it remains visible in a dashboard, check if there are lingering Egress or Ingress jobs associated with that session that are keeping the record "Active" [4]. * Handle the Exception: Your application code should be prepared to catch and handle this error gracefully. Because delete_room is intended to ensure a room is gone, receiving a 404 effectively confirms the desired state (that the room no longer exists) [4][3]. Example error handling in Python: from livekit import api try: await lkapi.room.delete_room(api.DeleteRoomRequest(room="myroom")) except api.ServerError as e: if e.code == api.TwirpErrorCode.NOT_FOUND: print("Room already closed or does not exist.") else: print(f"Failed to delete room: {e.code} - {e.message}") [7][8][9]

Citations:


🏁 Script executed:

sed -n '1,230p' backend/app/audio/livekit_publisher.py
printf '\n--- dependency references ---\n'
rg -n "livekit-api|livekit|TwirpError|DeleteRoomRequest|delete_room" -S pyproject.toml poetry.lock requirements*.txt backend 2>/dev/null | head -200

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 11751


🌐 Web query:

livekit-api 1.2.0 Python source TwirpError ServerError code not_found

💡 Result:

In the LiveKit Python SDK (including version 1.2.0 and later), a TwirpError with the code not_found and status 404 indicates that the requested resource—such as a room or participant—could not be located on the LiveKit server [1][2]. Key details regarding this error: 1. Nature of the Error: It is a server-side error, specifically a standard Twirp (RPC) error code representing an HTTP 404 condition [3][2]. 2. Common Causes: - Timing or Race Conditions: It often occurs when an API call (e.g., attempting to start Egress or modify a participant) is made just as a room is being created or closed, leading to a temporary "not found" state [1]. - Invalid Resource Name: The specific room, participant, or identity provided in the request does not exist in the current server state [1]. 3. Recommended Handling: - Error Class: The SDK uses ServerError as the primary base class for these API exceptions, with TwirpError typically kept as an alias for backward compatibility [4][5]. - Implementation: In application logic, you should catch ServerError and inspect the code attribute [4]. - Mitigation: If the error is due to a race condition (e.g., checking for a room that is in the process of being initialized), implementing a retry strategy with backoff is often effective [1]. Example: try: await lkapi.egress.start_room_composite_egress(req) except api.ServerError as e: if e.code == "not_found": # Handle or retry the request print("Resource not found:", e.message)

Citations:


🏁 Script executed:

python3 - <<'PY'
import json
import urllib.request

urls = [
    "https://raw.githubusercontent.com/livekit/python-sdks/v1.2.0/livekit-api/livekit/api/twirp_client.py",
    "https://raw.githubusercontent.com/livekit/python-sdks/v1.2.0/livekit-api/livekit/api/__init__.py",
    "https://raw.githubusercontent.com/livekit/python-sdks/main/livekit-api/livekit/api/twirp_client.py",
]
for url in urls:
    print(f"\n--- {url} ---")
    try:
        with urllib.request.urlopen(url, timeout=10) as response:
            text = response.read().decode()
        for i, line in enumerate(text.splitlines(), 1):
            if "class ServerError" in line or "class TwirpError" in line or "code" in line:
                print(f"{i}: {line}")
    except Exception as exc:
        print(type(exc).__name__, exc)
PY

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 937


🏁 Script executed:

for url in \
  'https://raw.githubusercontent.com/livekit/python-sdks/v1.2.0/livekit-api/livekit/api/twirp_client.py' \
  'https://raw.githubusercontent.com/livekit/python-sdks/v1.2.0/livekit-api/livekit/api/__init__.py' \
  'https://raw.githubusercontent.com/livekit/python-sdks/main/livekit-api/livekit/api/twirp_client.py'
do
  echo "--- $url ---"
  curl -ksSfL "$url" | nl -ba | grep -E 'class (ServerError|TwirpError)|code|__all__' | head -80 || true
done

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 910


🏁 Script executed:

python3 - <<'PY'
import json
import ssl
import tarfile
import urllib.request
from io import BytesIO

ctx = ssl._create_unverified_context()
meta_url = "https://pypi.org/pypi/livekit-api/1.2.0/json"
with urllib.request.urlopen(meta_url, context=ctx, timeout=15) as response:
    meta = json.load(response)

for item in meta["urls"]:
    if item["packagetype"] == "sdist":
        source_url = item["url"]
        break
else:
    raise SystemExit("No source distribution found")

print("source:", source_url)
with urllib.request.urlopen(source_url, context=ctx, timeout=15) as response:
    data = response.read()

with tarfile.open(fileobj=BytesIO(data), mode="r:*") as archive:
    for member in archive.getmembers():
        if member.name.endswith(("twirp_client.py", "api/__init__.py")):
            print(f"\n--- {member.name} ---")
            text = archive.extractfile(member).read().decode()
            for number, line in enumerate(text.splitlines(), 1):
                if any(token in line for token in ("class ServerError", "class TwirpError", "code", "ServerError")):
                    print(f"{number}: {line}")
PY

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 2135


🏁 Script executed:

python3 - <<'PY'
import ssl, tarfile, urllib.request
from io import BytesIO

url = "https://files.pythonhosted.org/packages/f3/19/36ff6712ec638a4b7dad4d8f03795952e401dc31db0b04cddec7892650da/livekit_api-1.2.0.tar.gz"
with urllib.request.urlopen(url, context=ssl._create_unverified_context(), timeout=15) as response:
    data = response.read()

with tarfile.open(fileobj=BytesIO(data), mode="r:*") as archive:
    for name in (
        "livekit_api-1.2.0/livekit/api/twirp_client.py",
        "livekit_api-1.2.0/livekit/api/__init__.py",
    ):
        text = archive.extractfile(name).read().decode().splitlines()
        print(f"\n--- {name} ---")
        for start, end in ((40, 160), (270, 290)):
            if start <= len(text):
                for number in range(start, min(end, len(text)) + 1):
                    print(f"{number}: {text[number - 1]}")
PY

printf '\n--- teardown call sites ---\n'
sed -n '130,190p' backend/app/api/audio_ws.py
sed -n '1,120p' backend/tests/integration/test_livekit_publisher.py

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 14005


🏁 Script executed:

rg -n -C 5 "publisher\.close|\.close\(\)|create_and_publish|except BaseException|lifespan|sessions" backend/app backend/tests -g '*.py' | head -240

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 18717


Handle room-deletion errors by code, not message text.

Catch api.ServerError and compare exc.code with api.ServerErrorCode.NOT_FOUND. Treat only NOT_FOUND as idempotent. Log other deletion failures and continue teardown so cleanup cannot mask the original failure or abort lifespan shutdown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/audio/livekit_publisher.py` around lines 171 - 178, Update the
room cleanup block around the publisher teardown to catch api.ServerError and
compare exc.code with api.ServerErrorCode.NOT_FOUND instead of inspecting
exception text. Treat only NOT_FOUND as idempotent; log other deletion failures
and continue teardown without re-raising, while preserving the finally reset of
_created_room.

Comment on lines +13 to +16
def log_event(event: str, *, level: int = logging.INFO, **fields: Any) -> None:
"""Write a one-line JSON record with a stable event name and safe fields."""
safe = {key: value for key, value in fields.items() if "key" not in key.lower() and "secret" not in key.lower()}
LOGGER.log(level, json.dumps({"event": event, **safe}, default=str, separators=(",", ":")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Extend the redaction deny list beyond key and secret.

The filter matches only field names that contain key or secret. Sensitive names used in this cohort do not match. token is one example: SessionReady in backend/app/models/events.py carries a LiveKit JWT in a field named token, and LiveKitPublisher._token mints both browser and assistant tokens. Any future log_event(..., token=...) call writes the JWT to logs. User speech is a second example: transcript fields flow through TurnManager and VoiceSession, and logging one records speech content.

Add the common sensitive substrings, and prefer redaction markers over silent removal so the record still shows that a field was present.

🔒 Proposed fix to broaden redaction
+_REDACTED_SUBSTRINGS = ("key", "secret", "token", "password", "authorization", "credential")
+
+
 def log_event(event: str, *, level: int = logging.INFO, **fields: Any) -> None:
     """Write a one-line JSON record with a stable event name and safe fields."""
-    safe = {key: value for key, value in fields.items() if "key" not in key.lower() and "secret" not in key.lower()}
+    safe = {
+        name: ("[redacted]" if any(part in name.lower() for part in _REDACTED_SUBSTRINGS) else value)
+        for name, value in fields.items()
+    }
     LOGGER.log(level, json.dumps({"event": event, **safe}, default=str, separators=(",", ":")))
🧰 Tools
🪛 ast-grep (0.45.1)

[info] 15-15: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"event": event, **safe}, default=str, separators=(",", ":"))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/logging.py` around lines 13 - 16, Update log_event’s
safe-field filtering to redact fields whose names contain token or transcript in
addition to key or secret. Replace matched values with a clear redaction marker
rather than removing those fields, while preserving the existing JSON event
structure and serialization behavior.

Comment on lines +76 to +80
try:
emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms, interrupt.turn_id)
except TypeError:
# Compatibility for internal two-argument callbacks; route callbacks receive turn_id.
emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms) # type: ignore[call-arg]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The TypeError fallback can call the callback twice and hide real errors.

except TypeError wraps the call to emit_interrupt, so it catches every TypeError raised anywhere inside the callback body, not only an arity mismatch. When a three-argument callback raises TypeError internally, this handler treats it as a signature mismatch and invokes the callback a second time with two arguments. The result is either a duplicate interrupt sent to the browser or a second confusing TypeError that masks the original fault.

The fallback also contradicts the declared contract. InterruptEmitter on line 15 is Callable[[int, int, str], Awaitable[None] | None], and the two-argument path needs # type: ignore[call-arg] to type-check.

The only two-argument caller is a test. backend/tests/unit/test_voice_session.py line 21 passes lambda gid, _:, while line 63 already uses the three-argument form. Update the test to the declared signature and remove the fallback.

🐛 Proposed fix
         interrupt = Interrupt(generation_id, self.turn_id(generation_id), int(qualified_at * 1000))
-        try:
-            emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms, interrupt.turn_id)
-        except TypeError:
-            # Compatibility for internal two-argument callbacks; route callbacks receive turn_id.
-            emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms)  # type: ignore[call-arg]
+        emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms, interrupt.turn_id)

In backend/tests/unit/test_voice_session.py, change the two-argument lambda to accept turn_id:

-            session.on_update("interrupt", teardown=lambda gid: calls.append(("teardown", gid)),
-                              emit_interrupt=lambda gid, _: calls.append(("priority", gid)))
+            session.on_update("interrupt", teardown=lambda gid: calls.append(("teardown", gid)),
+                              emit_interrupt=lambda gid, _at, _turn: calls.append(("priority", gid)))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms, interrupt.turn_id)
except TypeError:
# Compatibility for internal two-argument callbacks; route callbacks receive turn_id.
emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms) # type: ignore[call-arg]
interrupt = Interrupt(generation_id, self.turn_id(generation_id), int(qualified_at * 1000))
emitted = emit_interrupt(interrupt.generation_id, interrupt.qualified_at_ms, interrupt.turn_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/voice_session.py` around lines 76 - 80, Remove the
TypeError compatibility fallback around emit_interrupt in the voice-session
interrupt flow and invoke it only with the declared three-argument
InterruptEmitter signature. Update the two-argument lambda in
test_voice_session.py to accept turn_id, preserving the existing three-argument
test behavior and allowing callback TypeErrors to propagate without retrying.

Comment on lines +81 to +90
if inspect.isawaitable(emitted):
task = asyncio.create_task(emitted); self._interrupt_tasks.add(task); task.add_done_callback(self._interrupt_tasks.discard)
self._schedule_teardown(generation_id, teardown)
return interrupt

def _schedule_teardown(self, generation_id: int, teardown: Teardown) -> None:
async def run() -> None:
result = teardown(generation_id)
if inspect.isawaitable(result): await result
task = asyncio.create_task(run()); self._teardown_tasks.add(task); task.add_done_callback(self._teardown_tasks.discard)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fire-and-forget tasks drop their exceptions across the pipeline. Three call sites create an asyncio.Task and then discard the reference without ever calling task.exception() or wrapping the body in a handler. When the coroutine raises, asyncio reports "Task exception was never retrieved" at garbage-collection time, and the originating turn or barge-in fails with no log and no ErrorEvent to the browser.

  • backend/app/services/voice_session.py#L81-L90: replace add_done_callback(self._interrupt_tasks.discard) and add_done_callback(self._teardown_tasks.discard) with a callback that discards the task and logs task.exception(). The teardown path matters most, because audio_ws.py line 221 passes teardown=lambda _: cancel_generation(); a silent failure there leaves the assistant speaking after the user interrupted.
  • backend/app/core/turn_manager.py#L59-L71: wrap the self._callback(transcript) invocation in _wait_and_emit with except asyncio.CancelledError: raise followed by except Exception that logs the failure. Line 62 already cleared _transcript, so an unlogged failure discards the user's turn with no trace.

Add one shared helper for the done-callback pattern so the third site cannot regress.

🧰 Tools
🪛 Ruff (0.16.1)

[error] 82-82: Multiple statements on one line (semicolon)

(E702)


[error] 82-82: Multiple statements on one line (semicolon)

(E702)


[error] 89-89: Multiple statements on one line (colon)

(E701)


[error] 90-90: Multiple statements on one line (semicolon)

(E702)


[error] 90-90: Multiple statements on one line (semicolon)

(E702)

📍 Affects 2 files
  • backend/app/services/voice_session.py#L81-L90 (this comment)
  • backend/app/core/turn_manager.py#L59-L71
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/voice_session.py` around lines 81 - 90, The
fire-and-forget task paths lose exceptions without logging or propagating
failure. In backend/app/services/voice_session.py:81-90, add one shared
done-callback helper that removes completed tasks from their tracking set and
retrieves/logs task.exception(), then use it for both interrupt and teardown
tasks; ensure cancellation is handled without treating it as an error. In
backend/app/core/turn_manager.py:59-71, update _wait_and_emit to re-raise
asyncio.CancelledError and log other exceptions from self._callback(transcript)
after _transcript is cleared.

Comment on lines +33 to +41
source=FakeSource(24000, 1, queue_size_ms=100); room=FakeRoom()
monkeypatch.setattr(module.rtc, 'AudioSource', lambda *a,**kw: source)
monkeypatch.setattr(module.rtc, 'Room', lambda: room)
monkeypatch.setattr(module.rtc.LocalAudioTrack, 'create_audio_track', lambda name, _: (name, 'track'))
publisher=LiveKitPublisher(settings(), FakeAPI())
metadata=await publisher.create_and_publish()
assert metadata.room_name.startswith('voice-') and metadata.browser_identity != metadata.assistant_identity
assert source.args == (24000, 1) and source.kwargs['queue_size_ms'] == 100
assert publisher._queue.maxsize == 2500

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The bounded-source assertion is vacuous.

Line 34 patches AudioSource with lambda *a, **kw: source, which discards the arguments the publisher passes. The source object was built by the test itself on line 33 with FakeSource(24000, 1, queue_size_ms=100).

Line 40 therefore asserts the values the test hardcoded, not the values create_and_publish supplied. The publisher could pass a 48000 Hz rate, or omit queue_size_ms entirely, and this test would still pass. The bounded-source guarantee named in the test title is not verified.

Capture the real arguments in the patch.

🐛 Proposed fix
-    source=FakeSource(24000, 1, queue_size_ms=100); room=FakeRoom()
-    monkeypatch.setattr(module.rtc, 'AudioSource', lambda *a,**kw: source)
+    room=FakeRoom()
+    created=[]
+    def make_source(*a, **kw):
+        created.append(FakeSource(*a, **kw))
+        return created[-1]
+    monkeypatch.setattr(module.rtc, 'AudioSource', make_source)
     monkeypatch.setattr(module.rtc, 'Room', lambda: room)
     monkeypatch.setattr(module.rtc.LocalAudioTrack, 'create_audio_track', lambda name, _: (name, 'track'))
     publisher=LiveKitPublisher(settings(), FakeAPI())
     metadata=await publisher.create_and_publish()
+    source=created[0]
     assert metadata.room_name.startswith('voice-') and metadata.browser_identity != metadata.assistant_identity
     assert source.args == (24000, 1) and source.kwargs['queue_size_ms'] == 100
-    assert publisher._queue.maxsize == 2500
+    assert publisher._queue.maxsize == settings().PCM_QUEUE_FRAMES

Deriving maxsize from the settings value also removes the magic 2500, so a changed default does not fail this test without explanation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
source=FakeSource(24000, 1, queue_size_ms=100); room=FakeRoom()
monkeypatch.setattr(module.rtc, 'AudioSource', lambda *a,**kw: source)
monkeypatch.setattr(module.rtc, 'Room', lambda: room)
monkeypatch.setattr(module.rtc.LocalAudioTrack, 'create_audio_track', lambda name, _: (name, 'track'))
publisher=LiveKitPublisher(settings(), FakeAPI())
metadata=await publisher.create_and_publish()
assert metadata.room_name.startswith('voice-') and metadata.browser_identity != metadata.assistant_identity
assert source.args == (24000, 1) and source.kwargs['queue_size_ms'] == 100
assert publisher._queue.maxsize == 2500
room=FakeRoom()
created=[]
def make_source(*a, **kw):
created.append(FakeSource(*a, **kw))
return created[-1]
monkeypatch.setattr(module.rtc, 'AudioSource', make_source)
monkeypatch.setattr(module.rtc, 'Room', lambda: room)
monkeypatch.setattr(module.rtc.LocalAudioTrack, 'create_audio_track', lambda name, _: (name, 'track'))
publisher=LiveKitPublisher(settings(), FakeAPI())
metadata=await publisher.create_and_publish()
source=created[0]
assert metadata.room_name.startswith('voice-') and metadata.browser_identity != metadata.assistant_identity
assert source.args == (24000, 1) and source.kwargs['queue_size_ms'] == 100
assert publisher._queue.maxsize == settings().PCM_QUEUE_FRAMES
🧰 Tools
🪛 Ruff (0.16.1)

[error] 33-33: Multiple statements on one line (semicolon)

(E702)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/integration/test_livekit_publisher.py` around lines 33 - 41,
Update the AudioSource monkeypatch in the test around
LiveKitPublisher.create_and_publish to capture the actual positional and keyword
arguments passed by the publisher, then construct or configure the FakeSource
from those captured values. Assert against the captured arguments rather than
the preconfigured source, and derive the expected publisher._queue.maxsize from
the relevant settings value instead of hardcoding 2500.

if (track) {
console.info("Voice microphone processing", track.getSettings());
}
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find the encoding/container the backend expects for microphone frames.
fd -t f -e py . backend | xargs rg -n -C4 'encoding|webm|opus|mimetype|mime_type|container|sample_rate'

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 1079


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- backend Python files ---'
git ls-files 'backend/**/*.py' | sed -n '1,200p'

echo '--- audio websocket implementation ---'
rg -n -C8 'send_bytes|receive_bytes|Deepgram|deepgram|audio|encoding|webm|opus|mp4|sample_rate|mime' backend -g '*.py' | sed -n '1,260p'

echo '--- integration test context ---'
sed -n '1,180p' backend/tests/integration/test_audio_websocket.py

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 30508


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- Deepgram provider ---'
sed -n '1,260p' backend/app/providers/deepgram.py

echo '--- WebSocket route ---'
sed -n '1,300p' backend/app/api/audio_ws.py

echo '--- frontend recorder and socket code ---'
sed -n '115,165p' frontend/app/hooks/useVoiceSession.ts
sed -n '1,110p' frontend/app/hooks/useSocket.ts 2>/dev/null || true

echo '--- repository references to recorder MIME types and Deepgram options ---'
rg -n -C5 'MediaRecorder|audio/webm|audio/mp4|LiveKit|DeepgramOptions|encoding|sample_rate|channels' frontend backend -g '*.{ts,tsx,py}'

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 50399


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- Deepgram-related configuration and provider call graph ---'
rg -n -C10 'Deepgram|deepgram|send_media|encoding|sample_rate|channels|AudioOptions|ListenOptions' backend/app backend/tests -g '*.py'

echo '--- frontend hook imports and error path ---'
sed -n '1,190p' frontend/app/hooks/useVoiceSession.ts
sed -n '190,330p' frontend/app/hooks/useVoiceSession.ts

Repository: muhammad-hammad-sarwar/realtime-voice-agent

Length of output: 42836


🌐 Web query:

Deepgram Flux v2 live streaming WebSocket supported audio containers WebM Opus audio/mp4 encoding

💡 Result:

Deepgram's Flux v2 live streaming via WebSocket supports specific containerized and raw audio formats [1][2]. For containerized audio, Deepgram Flux v2 supports: - WebM containers with Opus encoding [1][3][2] - Ogg containers with Opus encoding [4][1][2] - WAV containers with linear16 encoding [4][1][2] When sending containerized audio, you must omit the encoding and sample_rate parameters in your WebSocket URL, as the Flux model automatically detects these from the container metadata [4][1][3]. For raw audio (non-containerized), Flux v2 supports: - linear16, linear32, mulaw, alaw, opus, and ogg-opus [5][1][2] - When sending raw audio, both the encoding and sample_rate parameters are required [1][2]. Regarding audio/mp4: Deepgram Flux does not natively support MP4 containers for live streaming [1][2]. If your audio source is in an MP4 container, it must be demuxed or transcoded into a supported format (such as raw linear16 or a supported containerized format like WebM/Opus) before being sent to the WebSocket endpoint [1][2]. For all live streaming, ensure you are connecting to the /v2/listen endpoint (e.g., wss://api.deepgram.com/v2/listen) [1][2]. Using an 80ms audio chunk size is strongly recommended for optimal performance and latency [1][2][6].

Citations:


Handle browsers without WebM recording support

MediaRecorder throws NotSupportedError when audio/webm;codecs=opus is unavailable. The Deepgram Flux connection accepts WebM/Opus and Ogg/Opus containers, but not audio/mp4.

Use MediaRecorder.isTypeSupported to select a supported WebM/Opus or Ogg/Opus type. If none is supported, show an unsupported-browser error instead of passing audio/mp4 to Deepgram.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/hooks/useVoiceSession.ts` at line 142, Update the MediaRecorder
initialization in the voice-session recording flow to select a supported
WebM/Opus or Ogg/Opus MIME type via MediaRecorder.isTypeSupported. If neither
format is supported, show the existing unsupported-browser error and do not
create or connect a recorder; never fall back to audio/mp4.

Comment on lines +227 to +230
} else {
setError("The assistant audio could not be completed.");
setSessionState("error");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Release resources when a generation fails.

This branch sets the error state but skips cleanup(). The MediaRecorder, the microphone tracks, the WebSocket, and the LiveKit room all stay active. frontend/app/page.tsx disables End for the error state, so the user cannot stop the microphone. Pressing Start then calls connect(), which returns early in frontend/app/hooks/useSocket.ts line 50 because the socket is still OPEN; no new session.ready arrives and the UI stays in connecting.

Call failSession so teardown and state change stay consistent with every other failure path.

🐛 Proposed fix
       } else if (event.state === "completed") {
         setSessionState("listening");
       } else {
-        setError("The assistant audio could not be completed.");
-        setSessionState("error");
+        await failSession("The assistant audio could not be completed.");
       }

The backend reports assistant output failures as recoverable (assistant_output_failed). If a failed generation must keep the session alive, keep the socket open and return to listening instead of entering error. Confirm the intended behavior.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
setError("The assistant audio could not be completed.");
setSessionState("error");
}
} else {
await failSession("The assistant audio could not be completed.");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/hooks/useVoiceSession.ts` around lines 227 - 230, Update the
assistant-output failure branch in useVoiceSession to call failSession instead
of directly setting the error state, ensuring cleanup and consistent failure
teardown for MediaRecorder, microphone tracks, WebSocket, and LiveKit resources.
Preserve the existing user-facing error message unless the intended
recoverable-session behavior is confirmed; if recovery is required, keep the
socket open and transition back to listening instead.

@muhammad-hammad-sarwar
muhammad-hammad-sarwar merged commit 2b8fcf7 into main Aug 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant