|
9 | 9 | from __future__ import annotations |
10 | 10 |
|
11 | 11 | import dataclasses |
| 12 | +import wave |
| 13 | +from pathlib import Path |
12 | 14 |
|
13 | 15 | import pytest |
14 | 16 |
|
15 | 17 | from aai_cli.app.context import AppState |
16 | 18 | from aai_cli.commands.stream import DEFAULT_SPEECH_MODEL |
17 | 19 | from aai_cli.commands.stream import _exec as stream_exec |
18 | 20 | from aai_cli.core import config, llm |
19 | | -from aai_cli.core.errors import UsageError |
| 21 | +from aai_cli.core.errors import CLIError, UsageError |
20 | 22 | from aai_cli.streaming.turn_presets import TurnDetectionPreset |
21 | 23 |
|
22 | 24 | # The CLI's flag defaults, as data. Tests override per-case with dataclasses.replace. |
|
60 | 62 | config_file=None, |
61 | 63 | output_field=None, |
62 | 64 | show_code=False, |
| 65 | + save_audio=None, |
63 | 66 | ) |
64 | 67 |
|
65 | 68 |
|
@@ -170,6 +173,7 @@ def test_stream_options_are_immutable(): |
170 | 173 | {"from_stdin": True, "device": 2}, # mic-only capture flags |
171 | 174 | {"from_stdin": True, "sample_rate": 44100}, |
172 | 175 | {"from_stdin": True, "show_code": True}, # renders one source |
| 176 | + {"from_stdin": True, "save_audio": Path("out.wav")}, # tees one stream |
173 | 177 | ], |
174 | 178 | ) |
175 | 179 | def test_from_stdin_rejects_incompatible_flags(overrides): |
@@ -222,3 +226,84 @@ def fake_stream_batch(sources, *, make_session, open_source, renderer, json_mode |
222 | 226 | dataclasses.replace(DEFAULTS, from_stdin=True), AppState(), json_mode=True |
223 | 227 | ) |
224 | 228 | assert seen["sources"] == ["a.wav", "b.wav"] |
| 229 | + |
| 230 | + |
| 231 | +# --- --save-audio (tee the streamed PCM to a WAV) -------------------------- |
| 232 | +class RecordingMic(FakeMic): |
| 233 | + """A mic that yields known PCM so the tee'd WAV's contents can be asserted.""" |
| 234 | + |
| 235 | + PCM = b"\x01\x02\x03\x04\x05\x06\x07\x08" |
| 236 | + |
| 237 | + def __iter__(self): |
| 238 | + return iter([self.PCM]) |
| 239 | + |
| 240 | + |
| 241 | +def test_save_audio_tees_streamed_pcm_to_a_wav(monkeypatch, tmp_path): |
| 242 | + # The bytes the streaming API receives are also written to --save-audio, verbatim, |
| 243 | + # as a 16-bit mono WAV at the source's sample rate. |
| 244 | + config.set_api_key("default", "sk_live") |
| 245 | + out = tmp_path / "rec.wav" |
| 246 | + |
| 247 | + def fake_stream_audio(api_key, source, *, params, **_kwargs): |
| 248 | + # Draining the iterable is what drives the tee — mirror the real SDK consuming it. |
| 249 | + sent = b"".join(source) |
| 250 | + assert sent == RecordingMic.PCM # the API still sees the unaltered audio |
| 251 | + |
| 252 | + monkeypatch.setattr(stream_exec.client, "stream_audio", fake_stream_audio) |
| 253 | + monkeypatch.setattr(stream_exec, "MicrophoneSource", RecordingMic) |
| 254 | + |
| 255 | + stream_exec.run_stream( |
| 256 | + dataclasses.replace(DEFAULTS, save_audio=out), AppState(), json_mode=True |
| 257 | + ) |
| 258 | + |
| 259 | + assert out.is_file() |
| 260 | + with wave.open(str(out), "rb") as w: |
| 261 | + assert w.getnchannels() == 1 |
| 262 | + assert w.getsampwidth() == 2 |
| 263 | + assert w.getframerate() == 16000 # FakeMic's reported rate |
| 264 | + assert w.readframes(w.getnframes()) == RecordingMic.PCM |
| 265 | + |
| 266 | + |
| 267 | +def test_save_audio_not_written_when_flag_unset(monkeypatch, tmp_path): |
| 268 | + # Without --save-audio, the default run leaves no stray WAV behind (kills a mutant |
| 269 | + # that tees unconditionally). |
| 270 | + config.set_api_key("default", "sk_live") |
| 271 | + monkeypatch.setattr(stream_exec.client, "stream_audio", lambda *a, **k: b"".join(a[1])) |
| 272 | + monkeypatch.setattr(stream_exec, "MicrophoneSource", RecordingMic) |
| 273 | + |
| 274 | + stream_exec.run_stream(DEFAULTS, AppState(), json_mode=True) |
| 275 | + |
| 276 | + assert list(tmp_path.glob("*.wav")) == [] |
| 277 | + |
| 278 | + |
| 279 | +def test_save_audio_rejects_system_audio(): |
| 280 | + # The mic + system streams can't share one file, so the combo is a usage error |
| 281 | + # (raised before credentials). |
| 282 | + with pytest.raises(UsageError): |
| 283 | + stream_exec.run_stream( |
| 284 | + dataclasses.replace(DEFAULTS, save_audio=Path("rec.wav"), system_audio=True), |
| 285 | + AppState(), |
| 286 | + json_mode=False, |
| 287 | + ) |
| 288 | + |
| 289 | + |
| 290 | +def test_save_audio_rejects_show_code(): |
| 291 | + # --show-code emits SDK code that doesn't tee audio, so the combo is rejected. |
| 292 | + with pytest.raises(UsageError): |
| 293 | + stream_exec.run_stream( |
| 294 | + dataclasses.replace(DEFAULTS, save_audio=Path("rec.wav"), show_code=True), |
| 295 | + AppState(), |
| 296 | + json_mode=False, |
| 297 | + ) |
| 298 | + |
| 299 | + |
| 300 | +def test_save_audio_rejects_missing_parent_dir(tmp_path): |
| 301 | + # A path under a directory that doesn't exist is a clean path error, before auth. |
| 302 | + config.set_api_key("default", "sk_live") |
| 303 | + with pytest.raises(CLIError) as excinfo: |
| 304 | + stream_exec.run_stream( |
| 305 | + dataclasses.replace(DEFAULTS, save_audio=tmp_path / "nope" / "rec.wav"), |
| 306 | + AppState(), |
| 307 | + json_mode=False, |
| 308 | + ) |
| 309 | + assert excinfo.value.error_type == "save_audio_path" |
0 commit comments