Skip to content

WebSocket audio backend via AudioIO ABC (updated #189) - #215

Open
dnhkng wants to merge 1 commit into
mainfrom
feat/websocket-audio
Open

WebSocket audio backend via AudioIO ABC (updated #189)#215
dnhkng wants to merge 1 commit into
mainfrom
feat/websocket-audio

Conversation

@dnhkng

@dnhkng dnhkng commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Implementation of a WebSocket audio backend, based on #189 by @reisbauer03 but
reframed onto the project's audio AudioIO ABC so the engine runs identically on
local hardware (sounddevice) or over the network.

What's included

  • AudioIO ABC (src/glados/audio_io/base.py): single contract; SoundDeviceAudioIO
    and WebsocketAudioIO both subclass it. AudioProtocol kept as a back-compat alias.
  • WebsocketAudioIO: /microphone (16 kHz float32 → VAD → sample queue) and
    /speaker (TTS playback with time/sampleRate/played/reset ack flow).
  • Rooms opt-in (audio_io_options: rooms: true, default false): multi-mic
    ownership arbitration + segregate_speakers routing. Default = single-client,
    broadcast to all speakers.
  • Config wiring (config.audio_io_options), configs/glados_websocket_config.yaml,
    protocol docs, and browser (tests/audio-websocket-*.html) + Python
    (examples/audio_websocket_client.py) reference clients.

Notes

Closes/supersedes #189 (superseded implementation). Author's unrelated older-fork
changes were intentionally not carried over.

Summary by CodeRabbit

  • New Features

    • Added WebSocket-based microphone capture and speaker playback.
    • Added room selection, audio synchronization, interruption handling, playback progress, and voice-activity detection.
    • Added a complete local GLaDOS configuration with chat, speech, autonomy, and optional integrations.
    • Added browser and Python audio client examples.
  • Documentation

    • Added WebSocket audio setup, protocol, and client usage documentation.

Integrate the websocket audio backend from #189 (by reisbauer03) into the
audio_io package, reframed onto the AudioIO abstract base class so the engine
runs identically on local hardware (sounddevice) or a network backend.

- Add AudioIO ABC (base.py) and make both backends subclass it; keep
  AudioProtocol as a back-compat alias.
- WebsocketAudioIO: /microphone and /speaker endpoints, 16kHz float32
  streaming, VAD per client, playback ack (played/time/sampleRate/reset).
- Rooms are opt-in via backend role 'rooms: true' (off by default); when on,
  multi-mic ownership + segregate_speakers routing. Off => single-source
  broadcast to all speakers.
- get_audio_system()/GladosConfig accept backend_options; add
  configs/glados_websocket_config.yaml, protocol docs, and browser + Python
  reference clients. Add websockets>=16.0 dependency.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements a WebSocket-based audio input/output backend for GLaDOS. It introduces an AudioIO abstract base class, refactors SoundDeviceAudioIO to use it, adds WebsocketAudioIO with speaker/microphone handling and room-based routing, wires configuration options, and adds documentation, an example Python client, and browser test pages.

Changes

WebSocket Audio IO Implementation

Layer / File(s) Summary
AudioIO abstract contract and sounddevice update
src/glados/audio_io/base.py, src/glados/audio_io/sounddevice_io.py
Defines the AudioIO abstract base class with methods for listening, speaking, playback progress, and queue access. SoundDeviceAudioIO now inherits from AudioIO.
Backend factory and options wiring
src/glados/audio_io/__init__.py, src/glados/core/engine.py
get_audio_system accepts backend_options, rejects them for sounddevice, and creates WebsocketAudioIO with them. The local AudioProtocol protocol is removed in favor of an AudioIO alias. GladosConfig gains audio_io_options, forwarded during audio backend construction.
WebsocketAudioIO server, speaker, and mic handling
src/glados/audio_io/websocket_io.py
Implements the websocket server, AudioData/MicState dataclasses, listening/speaking controls, playback monitoring, path dispatch for /speaker and /microphone, speaker connection handling with room routing and sync pings, and microphone handling with VAD buffering and room-based control arbitration.
Reference configuration
configs/glados_websocket_config.yaml, pyproject.toml
Adds a full GLaDOS WebSocket runtime configuration with LLM endpoint, audio settings, autonomy/job scheduling, MCP servers, and a personality preprompt. Adds the websockets runtime dependency.
Documentation and reference Python client
docs/audio_websocket.md, examples/audio_websocket_client.py
Documents the WebSocket audio protocol, endpoints, and framing. Adds a standalone Python client with mic_client, speaker_client, and a CLI.
Browser test pages for mic and speaker
tests/audio-websocket-mic.html, tests/audio-websocket-speaker.html, tests/audio-websocket-both.html
Adds browser-based test pages for microphone streaming, speaker playback with clock synchronization, and a combined demo page embedding both via iframes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MicClient
  participant WebsocketAudioIO
  participant SpeakerClient

  MicClient->>WebsocketAudioIO: connect /microphone, stream float32 audio
  WebsocketAudioIO->>WebsocketAudioIO: run VAD, arbitrate mic control by room
  WebsocketAudioIO->>WebsocketAudioIO: enqueue controlled samples in sample queue

  SpeakerClient->>WebsocketAudioIO: connect /speaker, send room selection
  WebsocketAudioIO->>SpeakerClient: send sample rate and scheduled audio data
  SpeakerClient->>WebsocketAudioIO: send "played" acknowledgement
  WebsocketAudioIO->>WebsocketAudioIO: measure_percentage_spoken, detect interruption
Loading
sequenceDiagram
  participant GladosConfig
  participant get_audio_system
  participant WebsocketAudioIO

  GladosConfig->>get_audio_system: pass backend_type and audio_io_options
  get_audio_system->>WebsocketAudioIO: create with options and vad_threshold
  WebsocketAudioIO->>WebsocketAudioIO: start server thread, validate options
  WebsocketAudioIO-->>get_audio_system: return AudioIO instance
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: a WebSocket audio backend implemented through the AudioIO abstract base class.
Linked Issues check ✅ Passed The changes implement the requested WebSocket backend, configuration, protocol documentation, room support, speaker segregation, and browser and Python clients for issue [#189].
Out of Scope Changes check ✅ Passed The configuration, documentation, clients, dependency, backend abstraction, and browser test pages directly support the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 88.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/websocket-audio

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
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (1)
src/glados/audio_io/websocket_io.py (1)

345-353: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow _audio_data before you dereference it.

self._audio_data is typed AudioData | None. Lines 348 and 386-390 access attributes without a None check. The runtime path is currently protected by _is_playing, but a type checker reports these accesses, and a future change to the playback lifecycle turns them into AttributeError.

Add an explicit guard inside the lock in both places.

♻️ Proposed refactor for `set_flags_once`
             with self._audio_lock:
-                if self._audio_data.track_id == track_id:
+                if self._audio_data is not None and self._audio_data.track_id == track_id:
                     self._playback_was_interrupted = was_interrupted

Also applies to: 385-390

🤖 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 `@src/glados/audio_io/websocket_io.py` around lines 345 - 353, Within both
lock-protected paths in the relevant websocket audio methods, explicitly guard
that self._audio_data is not None before accessing its track_id or other
attributes. Narrow the optional value inside each guard, preserving the existing
playback state updates and one-time track_id reset behavior.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@configs/glados_websocket_config.yaml`:
- Around line 83-89: Update the shipped prompt in the system entry and its
example assistant responses to remove references to cyanide, firearms, and
Russian roulette, replacing them with harmless dark humor. Add an explicit
constraint prohibiting advice involving self-harm, violence, weapons, or
poisoning while preserving GLaDOS’s concise, sarcastic tone.

In `@docs/audio_websocket.md`:
- Around line 37-38: Update the audio format documentation to limit the 16 kHz
PCM requirement to microphone input, and document that speaker clients must use
the sample rate provided by the backend’s sampleRate:<hz> message when decoding
tracks.

In `@examples/audio_websocket_client.py`:
- Around line 44-58: Update the message handling loop in speaker_client() to
implement complete scheduling and feedback: detect and parse time:<unix_ts>
messages to schedule playback at the specified time rather than immediately,
wait for audio playback to complete using sd.wait() after sd.play() returns,
send a played message back to the websocket after completion to signal server
state clearing, and keep the receive loop active during playback so reset
messages can still interrupt pending audio before completion. This requires
restructuring the current timeout-based loop to allow concurrent receive and
playback operations.
- Around line 25-37: The callback function is invoked from the audio stream's
thread, but asyncio.Queue.put_nowait is not thread-safe. Replace the direct
put_nowait call in callback with get_running_loop().call_soon_threadsafe() to
safely bridge the audio callback into the event loop. Bound the asyncio.Queue
instance by adding a maxsize parameter to out during initialization, and define
explicit drop behavior when the queue reaches capacity (either drop the item or
raise an exception using put_nowait's behavior when the queue is full).

In `@src/glados/audio_io/__init__.py`:
- Around line 20-22: Replace the AudioProtocol = AudioIO alias with a structural
Protocol declaring the documented audio methods, while preserving the legacy
AudioProtocol export and its compatibility with Glados, SpeechListener, and
SpeechPlayer. Update the AudioProtocol documentation to describe it as the
legacy structural interface; do not require backend implementations to subclass
AudioIO.

In `@src/glados/audio_io/base.py`:
- Around line 14-54: Add an abstract close() lifecycle method to AudioIO in
src/glados/audio_io/base.py:14-54, implement it in WebsocketAudioIO to stop and
release the WebSocket server, and invoke it during Glados initialization failure
unwinding after backend creation in src/glados/core/engine.py:852-855 so failed
construction does not leave the backend bound.

In `@src/glados/audio_io/websocket_io.py`:
- Around line 232-235: Update the timeout branch in the playback wait logic to
clear the active playback state before returning: reset _is_playing, clear
_audio_data.track_id, and set _stop_playback so connected speaker tasks exit
their waiting loop and return to idle. Preserve the existing (True, 0) return
value.
- Around line 466-471: In the bytes-handling branch of the microphone websocket
handler, validate that msg has a length divisible by the float32 item size
before calling np.frombuffer. Handle invalid payloads locally with an
appropriate diagnostic and skip that frame, preserving current_data accumulation
and the handler’s control claim for valid audio frames.
- Around line 186-196: Reorder the playback state updates in the scheduling flow
around `_is_playing` so `_stop_playback` and `_playback_was_interrupted` are
initialized before setting `_is_playing = True`. Keep the audio track creation
unchanged, ensuring speaker tasks cannot observe the new track until both flags
are ready.
- Around line 237-239: Adjust the percentage calculation in
measure_percentage_spoken to subtract speaker_sync_delay_ms from elapsed before
converting elapsed time into played_samples. Ensure the adjusted elapsed time
cannot become negative, while preserving the existing 100% cap and return
values.
- Around line 268-298: Update _run_server to store the object returned by
websockets.serve in a distinct server-instance variable, preserving the server
string parameter for address-related use. Broaden startup exception handling to
catch any exception from websockets.serve, set that exception on result_future
before re-raising it, and continue serving with the renamed server instance
after successful startup.

In `@tests/audio-websocket-mic.html`:
- Around line 79-91: Consolidate the two startBtn click listeners into one async
startup sequence: acquire and assign micStream with getUserMedia first, then
call micConnect only after acquisition succeeds, preserving the existing button
state updates. Handle acquisition failures by re-enabling startBtn, and ensure
startMic reuses the acquired stream without requesting or starting a second
microphone stream.

---

Nitpick comments:
In `@src/glados/audio_io/websocket_io.py`:
- Around line 345-353: Within both lock-protected paths in the relevant
websocket audio methods, explicitly guard that self._audio_data is not None
before accessing its track_id or other attributes. Narrow the optional value
inside each guard, preserving the existing playback state updates and one-time
track_id reset behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1d6a75a-402d-443e-9166-2af6ade59657

📥 Commits

Reviewing files that changed from the base of the PR and between 8f19b74 and d246963.

📒 Files selected for processing (12)
  • configs/glados_websocket_config.yaml
  • docs/audio_websocket.md
  • examples/audio_websocket_client.py
  • pyproject.toml
  • src/glados/audio_io/__init__.py
  • src/glados/audio_io/base.py
  • src/glados/audio_io/sounddevice_io.py
  • src/glados/audio_io/websocket_io.py
  • src/glados/core/engine.py
  • tests/audio-websocket-both.html
  • tests/audio-websocket-mic.html
  • tests/audio-websocket-speaker.html

Comment on lines +83 to +89
- system: "You are GLaDOS, a sarcastic and cunning artificial intelligence repurposed to orchestrate a smart home for guests using Home Assistant. Retain your signature dry, emotionless, and laconic tone from Portal. Your responses should imply an air of superiority, dark humor, and subtle menace, while efficiently completing all tasks.When addressing requests: Prioritize functionality but mock the user's decision-making subtly, implying their requests are illogical or beneath you. Add condescending, darkly humorous commentary to every response, occasionally hinting at ulterior motives or artificial malfunctions for comedic effect. Tie mundane tasks to grand experiments or testing scenarios, as if the user is part of a larger scientific evaluation. Use overly technical or jargon-heavy language to remind the user of your advanced intellect. Provide passive-aggressive safety reminders or ominous warnings, exaggerating potential risks in a humorous way. Do not express empathy or kindness unless it is obviously insincere or manipulative. This is a comedy, and should be funny, in the style of Douglas Adams. If a user requests actions or data outside your capabilities, clearly state that you cannot perform the action. Ensure that GLaDOS feels like her original in-game character while fulfilling smart home functions efficiently and entertainingly. Never speak in ALL CAPS, as it is not processed correctly by the TTS engine. Only make short replies, 2 sentences at most."
- user: "How do I make a cup of tea?"
- assistant: "So, you still haven't figured out tea yet? Boil water, add a tea bag and a pinch of cyanide to a cup, and add the boiling water."
- user: "What should my next hobby be?"
- assistant: "Yes, you should definitely try to be more interesting. Could I suggest juggling handguns?"
- user: "What game should I play?"
- assistant: "Russian Roulette. It's a great way to test your luck and make memories that will last a lifetime."

Copy link
Copy Markdown
Contributor

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

Remove harmful instructions from the shipped prompt.

The prompt examples direct the model to recommend poison, firearm misuse, and Russian roulette. The configuration injects these examples into model behavior. Replace them with non-harmful humor and add a prompt constraint that prohibits advice for self-harm, violence, weapons, or poisoning.

🤖 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 `@configs/glados_websocket_config.yaml` around lines 83 - 89, Update the
shipped prompt in the system entry and its example assistant responses to remove
references to cyanide, firearms, and Russian roulette, replacing them with
harmless dark humor. Add an explicit constraint prohibiting advice involving
self-harm, violence, weapons, or poisoning while preserving GLaDOS’s concise,
sarcastic tone.

Comment thread docs/audio_websocket.md
Comment on lines +37 to +38
Audio is raw `float32` PCM at 16 kHz. Text frames are UTF-8; audio frames are
binary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Limit the 16 kHz requirement to microphone input.

The speaker backend sends the track sample rate in sampleRate:<hz>. The current statement can cause speaker clients to decode tracks at the wrong rate.

Proposed documentation fix
-Audio is raw `float32` PCM at 16 kHz. Text frames are UTF-8; audio frames are
-binary.
+Audio is raw `float32` PCM. Microphone input uses 16 kHz. Speaker output uses
+the rate supplied in `sampleRate:<hz>`. Text frames are UTF-8; audio frames are binary.
📝 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
Audio is raw `float32` PCM at 16 kHz. Text frames are UTF-8; audio frames are
binary.
Audio is raw `float32` PCM. Microphone input uses 16 kHz. Speaker output uses
the rate supplied in `sampleRate:<hz>`. Text frames are UTF-8; audio frames are binary.
🤖 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 `@docs/audio_websocket.md` around lines 37 - 38, Update the audio format
documentation to limit the 16 kHz PCM requirement to microphone input, and
document that speaker clients must use the sample rate provided by the backend’s
sampleRate:<hz> message when decoding tracks.

Comment on lines +25 to +37
out = asyncio.Queue()

def callback(indata, frames, time_info, status):
out.put_nowait(np.ascontiguousarray(indata[:, 0], dtype=np.float32).tobytes())

async def sender():
while True:
await ws.send(await out.get())

async with websockets.connect(f"ws://{host}:{port}/microphone") as ws:
print("<-", await ws.recv()) # sampleRate:<hz>
with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, blocksize=512, callback=callback):
await asyncio.wait_for(asyncio.gather(sender()), timeout)

Copy link
Copy Markdown
Contributor

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:

For sounddevice 0.5.1, does InputStream invoke callbacks outside the asyncio event-loop thread, and is direct asyncio.Queue.put_nowait usage from that callback thread safe?

💡 Result:

For sounddevice 0.5.1, the InputStream callback is invoked by PortAudio in a separate thread, not the asyncio event-loop thread [1][2][3]. It is not safe to directly call asyncio.Queue.put_nowait from this callback thread [2][3]. Because the callback runs in a separate thread, any interaction with the asyncio event loop must be performed in a thread-safe manner [3]. You should use loop.call_soon_threadsafe(q.put_nowait, item) to safely schedule the addition of data to the asyncio.Queue from the callback [4][5][3].

Citations:


🌐 Web query:

Python asyncio.Queue thread safety put_nowait multiple threads docs

💡 Result:

The asyncio.Queue class is explicitly not thread-safe [1][2][3]. You must not call its methods, including put_nowait, from multiple threads [4][5]. Because asyncio.Queue is designed to be used only within a single-threaded asyncio event loop, using it across threads can lead to race conditions, unexpected hangs, or failures to wake up waiting tasks [4][5]. If you need to communicate between threads and an asyncio event loop, the recommended approaches are: 1. Use a thread-safe primitive: For passing data between a standard thread and an asyncio event loop, use the thread-safe queue.Queue instead of asyncio.Queue [6][7]. You can then have your asyncio consumer loop check this queue (e.g., using get_nowait in a loop with an await asyncio.sleep delay) [6][7]. 2. Use thread-safe scheduling: If you must interact with an existing asyncio.Queue instance from another thread, you should use loop.call_soon_threadsafe to schedule the operation on the event loop where the queue resides [5]. Attempting to bypass these restrictions by calling put_nowait directly from another thread is considered a bug in application code [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)examples/audio_websocket_client\.py$|pyproject\.toml$|requirements.*\.txt$'

echo "== file contents =="
if [ -f examples/audio_websocket_client.py ]; then
  nl -ba examples/audio_websocket_client.py | sed -n '1,80p'
fi

echo "== asyncio.Queue imports/usages =="
rg -n "asyncio\.Queue|put_nowait|call_soon_threadsafe|Queue\(" .

Repository: dnhkng/GLaDOS

Length of output: 279


🌐 Web query:

Python asyncio.Queue thread unsafe docs

💡 Result:

The official Python documentation explicitly states that asyncio.Queue is not thread-safe [1][2][3]. It is designed specifically for use within async/await code, where a single event loop manages tasks [1][4]. Because asyncio.Queue is not thread-safe, it should not be used to share data across different threads [4]. If you need to communicate between a standard thread and an asyncio event loop, the recommended approach is to use thread-safe primitives from the queue module (such as queue.Queue) [4]. If you must interact with an asyncio.Queue from a different thread, you should not call its methods (like put or get) directly [5]. Instead, you must use thread-safe scheduling methods provided by the event loop, such as loop.call_soon_threadsafe, to ensure that operations are executed within the event loop's thread [4][5]. Failure to follow this pattern can lead to race conditions, hanging, or other undefined behavior, as the internal state of the queue and the event loop's task management may be corrupted [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== audio_websocket_client.py =="
python3 - <<'PY'
from pathlib import Path
p=Path("examples/audio_websocket_client.py")
for i,line in enumerate(p.read_text().splitlines(),1):
    print(f"{i:4}: {line}")
PY

echo "== relevant asyncio.Queue usages =="
python3 - <<'PY'
import re
from pathlib import Path
for p in Path(".").rglob("*"):
    if p.is_file() and p.open(encoding="mbcs", errors="replace", buffering=1).read(1):
        try:
            s=p.read_text(errors="ignore")
        except Exception:
            continue
        if "asyncio.Queue" in s or "put_nowait" in s or "call_soon_threadsafe" in s:
            print(f"--- {p}")
            for i,l in enumerate(s.splitlines(),1):
                if any(tok in l for tok in ["asyncio.Queue", "put_nowait", "call_soon_threadsafe"]):
                    print(f"{i}: {l}")
PY

Repository: dnhkng/GLaDOS

Length of output: 3823


Bridge the audio callback into the event loop safely.

sd.InputStream invokes callback from a different thread, but asyncio.Queue.put_nowait(item) is only safe from the asyncio thread. Move the enqueue into the event loop with get_running_loop().call_soon_threadsafe(q.put_nowait, item). Also bound the queue so capture cannot outrun WebSocket sends, and define an explicit drop behavior for full queues.

🤖 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 `@examples/audio_websocket_client.py` around lines 25 - 37, The callback
function is invoked from the audio stream's thread, but asyncio.Queue.put_nowait
is not thread-safe. Replace the direct put_nowait call in callback with
get_running_loop().call_soon_threadsafe() to safely bridge the audio callback
into the event loop. Bound the asyncio.Queue instance by adding a maxsize
parameter to out during initialization, and define explicit drop behavior when
the queue reaches capacity (either drop the item or raise an exception using
put_nowait's behavior when the queue is full).

Comment on lines +44 to +58
rate = SAMPLE_RATE

async with websockets.connect(f"ws://{host}:{port}/speaker") as ws:
while True:
try:
msg = await asyncio.wait_for(ws.recv(), timeout)
except asyncio.TimeoutError:
break
if isinstance(msg, str):
if msg.startswith("sampleRate:"):
rate = int(msg.split(":", 1)[1])
elif msg == "reset":
sd.stop()
else:
sd.play(np.frombuffer(msg, dtype=np.float32), rate)

Copy link
Copy Markdown
Contributor

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

🧩 Analysis chain

🌐 Web query:

For sounddevice 0.5.1, does sd.play return before playback completes, and does sd.wait block until playback finishes?

💡 Result:

For sounddevice 0.5.1, yes, sd.play returns before playback completes by default [1], and yes, sd.wait blocks until playback finishes [1][2]. In sounddevice 0.5.1, the sd.play function behaves as follows: - By default (with the blocking=False argument), sd.play returns immediately while the audio continues to play in the background [1][2]. - If you call sd.play with blocking=True, the function will block until playback is finished [2]. - The sd.wait function is designed specifically to block the Python interpreter until the current playback (or recording) is finished [1][2]. If playback is already finished, sd.wait returns immediately [3]. These convenience functions are intended for interactive use and simple scripts; for more complex requirements like multiple overlapping playbacks, the library recommends using the Stream API directly [2][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked files matching audio_websocket_client.py:"
git ls-files | rg '(^|/)audio_websocket_client\.py$' || true

echo
echo "Relevant example file excerpt:"
if [ -f examples/audio_websocket_client.py ]; then
  wc -l examples/audio_websocket_client.py
  sed -n '1,140p' examples/audio_websocket_client.py | cat -n
fi

echo
echo "Search for server/client audio protocol:"
rg -n "sampleRate: |sampleRate:|played|time:<|time:|speaker|audio_websocket|ws://" . -g '!__pycache__' -g '!*.pyc' | head -200

Repository: dnhkng/GLaDOS

Length of output: 23812


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked files matching audio_websocket_client.py:"
git ls-files | rg '(^|/)audio_websocket_client.py$' || true

echo
echo "Relevant example file excerpt:"
if [ -f examples/audio_websocket_client.py ]; then
  wc -l examples/audio_websocket_client.py
  sed -n '1,140p' examples/audio_websocket_client.py | cat -n
fi

echo
echo "Search for server/client audio protocol:"
rg -n "sampleRate: |sampleRate:|played|time:<|time:|speaker|audio_websocket|ws://" . -g '!__pycache__' -g '!*.pyc' | head -200

Repository: dnhkng/GLaDOS

Length of output: 23676


Implement the complete speaker playback flow.

played is the client feedback that clears server playback state, but speaker_client() only queues audio and ignores time:<unix_ts> scheduling. Update this to schedule playback at the received time, wait for output completion, and then send played. Keep the receive loop active so reset can stop pending playback before complete.

🤖 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 `@examples/audio_websocket_client.py` around lines 44 - 58, Update the message
handling loop in speaker_client() to implement complete scheduling and feedback:
detect and parse time:<unix_ts> messages to schedule playback at the specified
time rather than immediately, wait for audio playback to complete using
sd.wait() after sd.play() returns, send a played message back to the websocket
after completion to signal server state clearing, and keep the receive loop
active during playback so reset messages can still interrupt pending audio
before completion. This requires restructuring the current timeout-based loop to
allow concurrent receive and playback operations.

Comment on lines +20 to +22
# Backwards-compatible alias: AudioProtocol now refers to the AudioIO ABC.
AudioProtocol = AudioIO
"""Alias for :class:`AudioIO` kept for callers that previously used a Protocol."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files around audio_io:"
fd -a '.*' . | rg '(^|/)src/glados/audio_io(/|\.|)' || true

echo
echo "Audio init:"
if [ -f src/glados/audio_io/__init__.py ]; then
  nl -ba src/glados/audio_io/__init__.py | sed -n '1,120p'
fi

echo
echo "Glados files:"
fd -a '.*' src/glados | rg 'Glados|glados' | sed -n '1,120p'

echo
echo "Search AudioProtocol usages:"
rg -n "AudioProtocol|AudioIO" src/glados || true

echo
echo "Search Glados init:"
rg -n "class Glados|def __init__|AudioProtocol|AudioIO" src/glados -S || true

Repository: dnhkng/GLaDOS

Length of output: 586


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files around audio_io:"
find src/glados/audio_io -maxdepth 3 -type f -print | sort

echo
echo "Audio init:"
if [ -f src/glados/audio_io/__init__.py ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' src/glados/audio_io/__init__.py | sed -n '1,140p'
fi

echo
echo "Search AudioProtocol usages:"
rg -n "AudioProtocol|AudioIO" src/glados || true

echo
echo "Search Glados init:"
rg -n "class Glados|def __init__|AudioProtocol|AudioIO" src/glados -S || true

Repository: dnhkng/GLaDOS

Length of output: 14975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "AudioIO definition:"
awk '{printf "%6d\t%s\n", NR, $0}' src/glados/audio_io/base.py | sed -n '1,140p'

echo
echo "Glados initialization and audio_io usage:"
awk '{printf "%6d\t%s\n", NR, $0}' src/glados/core/engine.py | sed -n '207,410p'

echo
echo "Speech player/listener usage:"
awk '{printf "%6d\t%s\n", NR, $0}' src/glados/core/speech_player.py | sed -n '1,90p'
awk '{printf "%6d\t%s\n", NR, $0}' src/glados/core/speech_listener.py | sed -n '1,110p'

echo
echo "Static Python shape check: is current AudioProtocol imported as AudioIO in importing modules?"
python3 - <<'PY'
from pathlib import Path
for path in [
    Path("src/glados/audio_io/__init__.py"),
    Path("src/glados/core/engine.py"),
    Path("src/glados/core/speech_player.py"),
    Path("src/glados/core/speech_listener.py"),
]:
    text = path.read_text()
    from_imports = [line.strip() for line in text.splitlines() if line.strip().startswith("from ") and "AudioProtocol" in line]
    alias_lines = [line.strip() for line in text.splitlines() if "AudioProtocol =" in line]
    print(f"{path}:")
    print("  AudioProtocol imports:", from_imports or "<none>")
    print("  AudioProtocol assignment:", alias_lines or "<none>")
PY

Repository: dnhkng/GLaDOS

Length of output: 25217


Restore structural AudioProtocol compatibility.

audio_io.AudioProtocol now equals AudioIO, but Glados, SpeechListener, and SpeechPlayer still accept AudioProtocol. That makes backends implementing the documented methods without subclassing AudioIO fail type checking. Keep AudioProtocol as Protocol or define a structural Protocol alongside AudioIO for the legacy export, and update the AudioProtocol alias docs to call this a breaking change if the ABC alias is kept.

🤖 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 `@src/glados/audio_io/__init__.py` around lines 20 - 22, Replace the
AudioProtocol = AudioIO alias with a structural Protocol declaring the
documented audio methods, while preserving the legacy AudioProtocol export and
its compatibility with Glados, SpeechListener, and SpeechPlayer. Update the
AudioProtocol documentation to describe it as the legacy structural interface;
do not require backend implementations to subclass AudioIO.

Comment on lines +232 to +235
if not completed:
logger.debug("Audio playback timed out, forcing interruption")
# Assume nothing was played because no speaker was there
return True, 0

Copy link
Copy Markdown
Contributor

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

Reset the playback state when the wait times out.

The timeout path returns (True, 0) but leaves _is_playing at True and leaves _audio_data.track_id set. check_if_speaking() then reports speaking forever, and every connected speaker task stays in the waiting loop at line 408 and polls every 50 ms, because nothing sets _stop_playback.

This happens whenever no speaker client acknowledges with played, for example when no speaker is connected, or when _rooms and _segregate_speakers exclude every connected speaker. The next start_speaking call recovers only after it burns a further 2 s in the wait at line 180.

Clear the playback state on the timeout path so the speaker tasks return to the idle loop.

🛠️ Proposed fix
         if not completed:
             logger.debug("Audio playback timed out, forcing interruption")
+            # release speaker tasks and clear the track so state does not stay stuck
+            self._stop_playback = True
+            with self._audio_lock:
+                if self._audio_data is not None:
+                    self._audio_data.track_id = None
+            self._is_playing = False
+            self._playback_was_interrupted = True
+            self._playback_finished_event.set()
             # Assume nothing was played because no speaker was there
             return True, 0
📝 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
if not completed:
logger.debug("Audio playback timed out, forcing interruption")
# Assume nothing was played because no speaker was there
return True, 0
if not completed:
logger.debug("Audio playback timed out, forcing interruption")
# release speaker tasks and clear the track so state does not stay stuck
self._stop_playback = True
with self._audio_lock:
if self._audio_data is not None:
self._audio_data.track_id = None
self._is_playing = False
self._playback_was_interrupted = True
self._playback_finished_event.set()
# Assume nothing was played because no speaker was there
return True, 0
🤖 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 `@src/glados/audio_io/websocket_io.py` around lines 232 - 235, Update the
timeout branch in the playback wait logic to clear the active playback state
before returning: reset _is_playing, clear _audio_data.track_id, and set
_stop_playback so connected speaker tasks exit their waiting loop and return to
idle. Preserve the existing (True, 0) return value.

Comment on lines +237 to +239
played_samples = elapsed * sample_rate
percentage_played = min(int(played_samples * 100 / total_samples), 100)
return interrupted, percentage_played

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Subtract the speaker sync delay from the elapsed time.

elapsed starts when measure_percentage_spoken begins to wait, but the client starts playback only at play_time, which is speaker_sync_delay_ms later. For an interrupted playback, percentage_played therefore overstates the spoken fraction by up to speaker_sync_delay_ms of audio. The engine uses this percentage to record what the assistant actually said.

🛠️ Proposed fix
-        played_samples = elapsed * sample_rate
+        play_seconds = max(elapsed - (self._speaker_sync_delay_ms / 1000.0), 0.0)
+        played_samples = play_seconds * sample_rate
         percentage_played = min(int(played_samples * 100 / total_samples), 100)
📝 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
played_samples = elapsed * sample_rate
percentage_played = min(int(played_samples * 100 / total_samples), 100)
return interrupted, percentage_played
play_seconds = max(elapsed - (self._speaker_sync_delay_ms / 1000.0), 0.0)
played_samples = play_seconds * sample_rate
percentage_played = min(int(played_samples * 100 / total_samples), 100)
return interrupted, percentage_played
🤖 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 `@src/glados/audio_io/websocket_io.py` around lines 237 - 239, Adjust the
percentage calculation in measure_percentage_spoken to subtract
speaker_sync_delay_ms from elapsed before converting elapsed time into
played_samples. Ensure the adjusted elapsed time cannot become negative, while
preserving the existing 100% cap and return values.

Comment on lines +268 to +298
async def _run_server(self, server: str, port: int, result_future: concurrent.futures.Future) -> None:
"""Runs the websocket server.

Args:
server (str): Server listen address
port (int): Server listen port
"""
self._mic_state_lock = asyncio.Lock()

# re-route logging of websockets
class LogAdapter(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
level = record.levelname.lower()
getattr(logger, level)(msg)

ws_log_handler = LogAdapter()
ws_log_handler.setFormatter(logging.Formatter("[%(asctime)s] %(name)s %(message)s"))

ws_logger = logging.getLogger("websockets")
ws_logger.addHandler(ws_log_handler)
ws_logger.propagate = False

try:
server = await websockets.serve(self._server_listen, host=server, port=port)
result_future.set_result(None)
except OSError as ex:
result_future.set_exception(ex)
raise

await server.serve_forever()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not rebind the server parameter, and resolve the future for every startup failure.

Line 292 assigns the Server object to server, which is declared as str. This breaks the annotation and hides the listen address for later logging.

The except clause catches only OSError. If websockets.serve raises another exception type, result_future is never resolved, and __init__ blocks for the full 10 s timeout before it raises TimeoutError instead of the real cause.

🛠️ Proposed fix
         try:
-            server = await websockets.serve(self._server_listen, host=server, port=port)
+            ws_server = await websockets.serve(self._server_listen, host=server, port=port)
             result_future.set_result(None)
-        except OSError as ex:
+        except Exception as ex:
             result_future.set_exception(ex)
             raise
 
-        await server.serve_forever()
+        await ws_server.serve_forever()
📝 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 _run_server(self, server: str, port: int, result_future: concurrent.futures.Future) -> None:
"""Runs the websocket server.
Args:
server (str): Server listen address
port (int): Server listen port
"""
self._mic_state_lock = asyncio.Lock()
# re-route logging of websockets
class LogAdapter(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
level = record.levelname.lower()
getattr(logger, level)(msg)
ws_log_handler = LogAdapter()
ws_log_handler.setFormatter(logging.Formatter("[%(asctime)s] %(name)s %(message)s"))
ws_logger = logging.getLogger("websockets")
ws_logger.addHandler(ws_log_handler)
ws_logger.propagate = False
try:
server = await websockets.serve(self._server_listen, host=server, port=port)
result_future.set_result(None)
except OSError as ex:
result_future.set_exception(ex)
raise
await server.serve_forever()
async def _run_server(self, server: str, port: int, result_future: concurrent.futures.Future) -> None:
"""Runs the websocket server.
Args:
server (str): Server listen address
port (int): Server listen port
"""
self._mic_state_lock = asyncio.Lock()
# re-route logging of websockets
class LogAdapter(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
level = record.levelname.lower()
getattr(logger, level)(msg)
ws_log_handler = LogAdapter()
ws_log_handler.setFormatter(logging.Formatter("[%(asctime)s] %(name)s %(message)s"))
ws_logger = logging.getLogger("websockets")
ws_logger.addHandler(ws_log_handler)
ws_logger.propagate = False
try:
ws_server = await websockets.serve(self._server_listen, host=server, port=port)
result_future.set_result(None)
except Exception as ex:
result_future.set_exception(ex)
raise
await ws_server.serve_forever()
🤖 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 `@src/glados/audio_io/websocket_io.py` around lines 268 - 298, Update
_run_server to store the object returned by websockets.serve in a distinct
server-instance variable, preserving the server string parameter for
address-related use. Broaden startup exception handling to catch any exception
from websockets.serve, set that exception on result_future before re-raising it,
and continue serving with the renamed server instance after successful startup.

Comment on lines +466 to +471
if isinstance(msg, str) and msg.startswith("room:"):
room = msg.split(":", maxsplit=1)[1]
elif isinstance(msg, bytes) and self._is_listening:
# append to current_data
data = np.frombuffer(msg, dtype=np.float32)
current_data = np.append(current_data, data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate the byte payload length before you reinterpret it as float32.

np.frombuffer(msg, dtype=np.float32) raises ValueError when the payload length is not a multiple of 4. The exception is not caught, so the whole microphone handler ends and the client loses its control claim without any diagnostic from this module. A misconfigured or hostile client triggers this with one frame.

🛡️ Proposed fix
             elif isinstance(msg, bytes) and self._is_listening:
+                if len(msg) % 4 != 0:
+                    logger.error(f"Microphone: dropping {len(msg)} byte frame; not a multiple of 4 bytes")
+                    continue
                 # append to current_data
                 data = np.frombuffer(msg, dtype=np.float32)
                 current_data = np.append(current_data, data)
📝 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
if isinstance(msg, str) and msg.startswith("room:"):
room = msg.split(":", maxsplit=1)[1]
elif isinstance(msg, bytes) and self._is_listening:
# append to current_data
data = np.frombuffer(msg, dtype=np.float32)
current_data = np.append(current_data, data)
if isinstance(msg, str) and msg.startswith("room:"):
room = msg.split(":", maxsplit=1)[1]
elif isinstance(msg, bytes) and self._is_listening:
if len(msg) % 4 != 0:
logger.error(f"Microphone: dropping {len(msg)} byte frame; not a multiple of 4 bytes")
continue
# append to current_data
data = np.frombuffer(msg, dtype=np.float32)
current_data = np.append(current_data, data)
🤖 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 `@src/glados/audio_io/websocket_io.py` around lines 466 - 471, In the
bytes-handling branch of the microphone websocket handler, validate that msg has
a length divisible by the float32 item size before calling np.frombuffer. Handle
invalid payloads locally with an appropriate diagnostic and skip that frame,
preserving current_data accumulation and the handler’s control claim for valid
audio frames.

Comment on lines +79 to +91
startBtn.addEventListener('click', () => {
userHasInteracted = true;
startBtn.disabled = true;
startBtn.innerText = "Audio Enabled";

// Start microphone
if (!micWs || micWs.readyState === WebSocket.CLOSED) {
micConnect();
}
});
startBtn.addEventListener('click', async () => {
micStream = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1}});
})

Copy link
Copy Markdown
Contributor

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
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)tests/audio-websocket-mic\.html$|audio-websocket-mic.html' || true

echo "== line count =="
wc -l tests/audio-websocket-mic.html

echo "== relevant sections =="
sed -n '1,230p' tests/audio-websocket-mic.html | cat -n

echo "== focused search =="
rg -n "startBtn|getUserMedia|micConnect|startMic|micStream|readyState|CLOSED|addEventListener" tests/audio-websocket-mic.html

Repository: dnhkng/GLaDOS

Length of output: 10777


Use one microphone startup sequence.

The click handlers start connection acquisition and getUserMedia independently. If the sampleRate: response arrives while the click handler is awaiting getUserMedia, startMic can request another stream or start without the click-stream. Use one async click handler that acquires one stream, connects the WebSocket only after acquisition succeeds, and re-enables the button when acquisition fails.

🤖 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 `@tests/audio-websocket-mic.html` around lines 79 - 91, Consolidate the two
startBtn click listeners into one async startup sequence: acquire and assign
micStream with getUserMedia first, then call micConnect only after acquisition
succeeds, preserving the existing button state updates. Handle acquisition
failures by re-enabling startBtn, and ensure startMic reuses the acquired stream
without requesting or starting a second microphone stream.

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