Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 3 additions & 91 deletions aai_cli/agent/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,96 +22,8 @@ def _output_default_rate(device: int | None = None) -> int:
return _default_rate("output", device)


def _default_output_stream(rate: int) -> Any:
"""Open a sounddevice PCM16 mono output stream (imported lazily to keep startup fast)."""
try:
import sounddevice as sd
except ImportError as exc:
raise audio_missing_error() from exc
try:
stream = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16")
stream.start()
except Exception as exc:
raise CLIError(
f"Could not open the audio output device: {exc}",
error_type="audio_output_error",
exit_code=1,
suggestion="Check your speaker/output device, then run 'aai doctor'.",
) from exc
return stream


class Player:
"""Plays queued PCM16 audio chunks through a speaker output stream."""

def __init__(
self,
*,
sample_rate: int = SAMPLE_RATE,
stream_factory: Callable[[int], object] | None = None,
output_rate: int | None = None,
rate_query: Callable[[int | None], int] | None = None,
) -> None:
self._source_rate = sample_rate # rate of enqueued audio (agent = 24 kHz)
self._factory = stream_factory or _default_output_stream
query = rate_query or _output_default_rate
# Open the speaker at its native rate; resample agent audio to it.
self._device_rate = output_rate if output_rate is not None else query(None)
self._queue: queue.Queue[bytes | None] = queue.Queue()
# sounddevice stream (or a test double); typed Any since sounddevice ships no stubs.
self._stream: Any = None
self._thread: threading.Thread | None = None

def start(self) -> None:
self._stream = self._factory(self._device_rate)
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()

def _run(self) -> None:
state: Any = None
while True:
chunk = self._queue.get()
if chunk is None:
return
if self._device_rate != self._source_rate:
chunk, state = _resample(
chunk, state, src_rate=self._source_rate, dst_rate=self._device_rate
)
try:
self._stream.write(chunk)
except Exception: # noqa: BLE001 - stream may be torn down mid-write
return

def enqueue(self, pcm: bytes) -> None:
self._queue.put(pcm)

def flush(self) -> None:
"""Discard pending audio (barge-in / interruption)."""
try:
while True:
self._queue.get_nowait()
except queue.Empty:
pass

def pending(self) -> int:
return self._queue.qsize()

def close(self) -> None:
self._queue.put(None)
# Stop the stream first so any in-flight write() raises and the worker
# thread returns promptly, avoiding a teardown race with the join below.
if self._stream is not None:
with contextlib.suppress(Exception):
self._stream.stop()
if self._thread is not None:
self._thread.join(timeout=2)
if self._stream is not None:
with contextlib.suppress(Exception):
self._stream.close()


class NullPlayer:
"""A Player look-alike that discards audio instead of opening a speaker.
"""A player look-alike that discards audio instead of opening a speaker.

Used by file-driven agent runs (`aai agent <file>`), which only need the
transcript events: there is no human listening, and headless/CI hosts have
Expand Down Expand Up @@ -168,7 +80,7 @@ class DuplexAudio:
directions through one `sd.RawStream` callback avoids that. Audio is captured
at the device's native rate and resampled to `target_rate` (the agent's 24 kHz)
for the mic side; playback is resampled back to the device rate. Exposes a
`Player`-compatible `player` and an iterable `mic` so `run_session` is unchanged.
player-compatible `player` and an iterable `mic` so `run_session` is unchanged.
"""

def __init__(
Expand Down Expand Up @@ -262,7 +174,7 @@ def close(self) -> None:


class _DuplexPlayer:
"""Player-compatible facade over a DuplexAudio's playback side."""
"""A player-compatible facade over a DuplexAudio's playback side."""

def __init__(self, duplex: DuplexAudio) -> None:
self._duplex = duplex
Expand Down
7 changes: 0 additions & 7 deletions aai_cli/auth/ams.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,6 @@ def exchange(intermediate_session_token: str, organization_id: str) -> dict[str,
return cast(dict[str, Any], _json_or_raise(resp))


def get_auth(session_jwt: str) -> dict[str, Any]:
"""GET /v1/auth (session cookie) -> account incl. `id`."""
with _client(session_jwt) as client:
resp = client.get("/v1/auth")
return cast(dict[str, Any], _json_or_raise(resp))


def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]:
"""GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}]."""
with _client(session_jwt) as client:
Expand Down
8 changes: 0 additions & 8 deletions aai_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,6 @@ def get_active_profile() -> str:
return str(_load().get("active_profile", DEFAULT_PROFILE))


def set_active_profile(name: str) -> None:
_validate_profile(name)
data = _load()
data["active_profile"] = name
data.setdefault("profiles", {}).setdefault(name, {})
_dump(data)


def set_api_key(profile: str, api_key: str) -> None:
_validate_profile(profile)
keyring.set_password(KEYRING_SERVICE, profile, api_key)
Expand Down
16 changes: 0 additions & 16 deletions aai_cli/config_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,14 +272,6 @@ def construct_transcription_config(merged: dict[str, typing.Any]) -> aai.Transcr
raise UsageError(f"Invalid transcription config: {exc}") from exc


def build_transcription_config(
*, flags: dict[str, object], overrides: list[str], config_file: str | None
) -> aai.TranscriptionConfig:
return construct_transcription_config(
merge_transcribe_config(flags=flags, overrides=overrides, config_file=config_file)
)


def merge_streaming_params(
*, flags: dict[str, object], overrides: list[str], config_file: str | None
) -> dict[str, object]:
Expand Down Expand Up @@ -307,14 +299,6 @@ def construct_streaming_params(merged: dict[str, typing.Any]) -> StreamingParame
raise UsageError(f"Invalid streaming config: {exc}") from exc


def build_streaming_params(
*, flags: dict[str, object], overrides: list[str], config_file: str | None
) -> StreamingParameters:
return construct_streaming_params(
merge_streaming_params(flags=flags, overrides=overrides, config_file=config_file)
)


def split_csv(value: str | None) -> list[str] | None:
"""Split a comma-separated flag value into a list, or None if empty."""
if not value:
Expand Down
4 changes: 0 additions & 4 deletions aai_cli/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@ class Environment:
_active: Environment | None = None


def known() -> tuple[str, ...]:
return tuple(ENVIRONMENTS)


def get(name: str) -> Environment:
"""The named environment, or a clean CLIError if it's unknown."""
env = ENVIRONMENTS.get(name)
Expand Down
101 changes: 0 additions & 101 deletions tests/test_agent_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import pytest

from aai_cli.agent.audio import Player, _default_output_stream
from aai_cli.errors import CLIError


Expand All @@ -24,106 +23,6 @@ def close(self):
self.closed = True


def test_player_writes_enqueued_audio():
fake = FakeStream()
p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: fake)
p.start()
p.enqueue(b"\x01\x02")
p.enqueue(b"\x03\x04")
p.close() # drains the queue, then tears down
assert b"\x01\x02" in fake.writes
assert b"\x03\x04" in fake.writes
assert fake.stopped
assert fake.closed


def test_player_flush_discards_pending_audio():
fake = FakeStream()
p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: fake)
# Do NOT start the worker; queue items directly so flush is deterministic.
p.enqueue(b"stale-1")
p.enqueue(b"stale-2")
p.flush()
assert p.pending() == 0


def test_player_worker_survives_write_error():
class BoomStream(FakeStream):
def write(self, data):
raise RuntimeError("device gone")

p = Player(sample_rate=24000, output_rate=24000, stream_factory=lambda rate: BoomStream())
p.start()
p.enqueue(b"\x01\x02")
p.close() # must return (join has a timeout); thread must not be alive
assert p._thread is not None and not p._thread.is_alive()


def test_default_output_stream_opens_started_sounddevice_stream(monkeypatch):
created = {}

class FakeOut:
def __init__(self, **kwargs):
created.update(kwargs)
self.started = False

def start(self):
self.started = True

fake_sd: Any = types.ModuleType("sounddevice")
fake_sd.RawOutputStream = lambda **kw: FakeOut(**kw)
monkeypatch.setitem(sys.modules, "sounddevice", fake_sd)

stream = _default_output_stream(24000)
assert stream.started
assert created["samplerate"] == 24000
assert created["channels"] == 1


def test_default_output_stream_missing_sounddevice_raises_mic_missing(monkeypatch):
monkeypatch.setitem(sys.modules, "sounddevice", None) # import -> ImportError
with pytest.raises(CLIError) as exc:
_default_output_stream(24000)
assert exc.value.error_type == "mic_missing"


def test_default_output_stream_open_failure_raises_audio_output_error(monkeypatch):
def boom(**kw):
raise OSError("no output device")

fake_sd: Any = types.ModuleType("sounddevice")
fake_sd.RawOutputStream = boom
monkeypatch.setitem(sys.modules, "sounddevice", fake_sd)
with pytest.raises(CLIError) as exc:
_default_output_stream(24000)
assert exc.value.error_type == "audio_output_error"
assert exc.value.exit_code == 1


def test_player_opens_stream_at_device_rate():
seen = {}

def factory(rate):
seen["rate"] = rate
return FakeStream()

p = Player(sample_rate=24000, output_rate=48000, stream_factory=factory)
p.start()
p.close()
assert seen["rate"] == 48000 # speaker opened at its native rate, not forced to 24 kHz


def test_player_resamples_source_to_device_rate():
# Agent audio is 24 kHz; when the speaker opens at 48 kHz the worker upsamples.
fake = FakeStream()
p = Player(sample_rate=24000, output_rate=48000, stream_factory=lambda rate: fake)
p.start()
p.enqueue(b"\x00\x00" * 240) # 10 ms of 24 kHz silence
p.close()
written = b"".join(fake.writes)
assert len(written) > 240 * 2 # upsampled to ~48 kHz -> more bytes than the 24 kHz input


from aai_cli.agent.audio import DuplexAudio # noqa: E402


Expand Down
13 changes: 0 additions & 13 deletions tests/test_auth_ams.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,6 @@ def handler(request: httpx.Request) -> httpx.Response:
assert "tok_abc" in seen["body"]


def test_get_auth_sends_session_cookie(monkeypatch):
seen = {}

def handler(request: httpx.Request) -> httpx.Response:
seen["cookie"] = request.headers.get("cookie", "")
return httpx.Response(200, json={"id": 42, "email": "a@b.com"})

_patch_transport(monkeypatch, handler)
out = ams.get_auth("sess_jwt_xyz")
assert out["id"] == 42
assert "stytch_session_jwt=sess_jwt_xyz" in seen["cookie"]


@pytest.mark.parametrize("status", [401, 403])
def test_auth_4xx_raises_not_authenticated(monkeypatch, status):
def handler(request: httpx.Request) -> httpx.Response:
Expand Down
8 changes: 3 additions & 5 deletions tests/test_auth_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ def test_run_login_flow_happy_path(monkeypatch):
"exchange",
lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt", "session_token": "t"},
)
monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 9})
monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final")

assert flow.run_login_flow() == "sk_final"
Expand Down Expand Up @@ -112,7 +111,9 @@ def test_find_or_create_creates_when_existing_token_has_no_api_key(monkeypatch):
assert flow.find_or_create_cli_key(1, "jwt") == "sk_new"


def test_run_login_flow_uses_exchange_account_without_get_auth(monkeypatch):
def test_run_login_flow_uses_exchange_account(monkeypatch):
# The signed-in account comes from exchange()'s response; the flow must not make a
# second round-trip to fetch it.
monkeypatch.setattr(flow, "_open_browser", lambda url: None)
monkeypatch.setattr(
flow,
Expand All @@ -132,9 +133,6 @@ def test_run_login_flow_uses_exchange_account_without_get_auth(monkeypatch):
"exchange",
lambda ist, org: {"account": {"id": 42}, "session_jwt": "jwt"},
)
monkeypatch.setattr(
flow.ams, "get_auth", lambda jwt: pytest.fail("get_auth is a redundant round-trip")
)
captured = {}

def fake_find(acct, jwt):
Expand Down
12 changes: 3 additions & 9 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ def test_active_profile_defaults_to_default():
assert config.get_active_profile() == "default"


def test_set_active_profile_persists():
config.set_active_profile("staging")
assert config.get_active_profile() == "staging"


def test_logout_clears_key():
config.set_api_key("default", "sk_abc")
config.clear_api_key("default")
Expand Down Expand Up @@ -72,7 +67,7 @@ def test_empty_api_key_flag_rejected():

def test_invalid_profile_name_has_suggestion():
with pytest.raises(CLIError) as exc:
config.set_active_profile("bad name!")
config.set_api_key("bad name!", "sk_x")
assert exc.value.message.startswith("Invalid profile name")
assert exc.value.suggestion == "Use only letters, digits, '-' or '_'."

Expand All @@ -86,7 +81,6 @@ def test_malformed_config_raises_clean_error(tmp_config):


def test_config_roundtrips_after_special_value(tmp_path, monkeypatch):
# active profile name is validated; this checks tomli_w writes valid TOML for normal data
config.set_api_key("default", "sk_x")
config.set_active_profile("staging")
# profile names are validated; this checks tomli_w writes valid TOML for normal data
config.set_api_key("staging", "sk_x")
assert config.get_active_profile() == "staging"
Loading
Loading