agent-cli 0.70.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_cli/__init__.py +5 -0
- agent_cli/__main__.py +6 -0
- agent_cli/_extras.json +14 -0
- agent_cli/_requirements/.gitkeep +0 -0
- agent_cli/_requirements/audio.txt +79 -0
- agent_cli/_requirements/faster-whisper.txt +215 -0
- agent_cli/_requirements/kokoro.txt +425 -0
- agent_cli/_requirements/llm.txt +183 -0
- agent_cli/_requirements/memory.txt +355 -0
- agent_cli/_requirements/mlx-whisper.txt +222 -0
- agent_cli/_requirements/piper.txt +176 -0
- agent_cli/_requirements/rag.txt +402 -0
- agent_cli/_requirements/server.txt +154 -0
- agent_cli/_requirements/speed.txt +77 -0
- agent_cli/_requirements/vad.txt +155 -0
- agent_cli/_requirements/wyoming.txt +71 -0
- agent_cli/_tools.py +368 -0
- agent_cli/agents/__init__.py +23 -0
- agent_cli/agents/_voice_agent_common.py +136 -0
- agent_cli/agents/assistant.py +383 -0
- agent_cli/agents/autocorrect.py +284 -0
- agent_cli/agents/chat.py +496 -0
- agent_cli/agents/memory/__init__.py +31 -0
- agent_cli/agents/memory/add.py +190 -0
- agent_cli/agents/memory/proxy.py +160 -0
- agent_cli/agents/rag_proxy.py +128 -0
- agent_cli/agents/speak.py +209 -0
- agent_cli/agents/transcribe.py +671 -0
- agent_cli/agents/transcribe_daemon.py +499 -0
- agent_cli/agents/voice_edit.py +291 -0
- agent_cli/api.py +22 -0
- agent_cli/cli.py +106 -0
- agent_cli/config.py +503 -0
- agent_cli/config_cmd.py +307 -0
- agent_cli/constants.py +27 -0
- agent_cli/core/__init__.py +1 -0
- agent_cli/core/audio.py +461 -0
- agent_cli/core/audio_format.py +299 -0
- agent_cli/core/chroma.py +88 -0
- agent_cli/core/deps.py +191 -0
- agent_cli/core/openai_proxy.py +139 -0
- agent_cli/core/process.py +195 -0
- agent_cli/core/reranker.py +120 -0
- agent_cli/core/sse.py +87 -0
- agent_cli/core/transcription_logger.py +70 -0
- agent_cli/core/utils.py +526 -0
- agent_cli/core/vad.py +175 -0
- agent_cli/core/watch.py +65 -0
- agent_cli/dev/__init__.py +14 -0
- agent_cli/dev/cli.py +1588 -0
- agent_cli/dev/coding_agents/__init__.py +19 -0
- agent_cli/dev/coding_agents/aider.py +24 -0
- agent_cli/dev/coding_agents/base.py +167 -0
- agent_cli/dev/coding_agents/claude.py +39 -0
- agent_cli/dev/coding_agents/codex.py +24 -0
- agent_cli/dev/coding_agents/continue_dev.py +15 -0
- agent_cli/dev/coding_agents/copilot.py +24 -0
- agent_cli/dev/coding_agents/cursor_agent.py +48 -0
- agent_cli/dev/coding_agents/gemini.py +28 -0
- agent_cli/dev/coding_agents/opencode.py +15 -0
- agent_cli/dev/coding_agents/registry.py +49 -0
- agent_cli/dev/editors/__init__.py +19 -0
- agent_cli/dev/editors/base.py +89 -0
- agent_cli/dev/editors/cursor.py +15 -0
- agent_cli/dev/editors/emacs.py +46 -0
- agent_cli/dev/editors/jetbrains.py +56 -0
- agent_cli/dev/editors/nano.py +31 -0
- agent_cli/dev/editors/neovim.py +33 -0
- agent_cli/dev/editors/registry.py +59 -0
- agent_cli/dev/editors/sublime.py +20 -0
- agent_cli/dev/editors/vim.py +42 -0
- agent_cli/dev/editors/vscode.py +15 -0
- agent_cli/dev/editors/zed.py +20 -0
- agent_cli/dev/project.py +568 -0
- agent_cli/dev/registry.py +52 -0
- agent_cli/dev/skill/SKILL.md +141 -0
- agent_cli/dev/skill/examples.md +571 -0
- agent_cli/dev/terminals/__init__.py +19 -0
- agent_cli/dev/terminals/apple_terminal.py +82 -0
- agent_cli/dev/terminals/base.py +56 -0
- agent_cli/dev/terminals/gnome.py +51 -0
- agent_cli/dev/terminals/iterm2.py +84 -0
- agent_cli/dev/terminals/kitty.py +77 -0
- agent_cli/dev/terminals/registry.py +48 -0
- agent_cli/dev/terminals/tmux.py +58 -0
- agent_cli/dev/terminals/warp.py +132 -0
- agent_cli/dev/terminals/zellij.py +78 -0
- agent_cli/dev/worktree.py +856 -0
- agent_cli/docs_gen.py +417 -0
- agent_cli/example-config.toml +185 -0
- agent_cli/install/__init__.py +5 -0
- agent_cli/install/common.py +89 -0
- agent_cli/install/extras.py +174 -0
- agent_cli/install/hotkeys.py +48 -0
- agent_cli/install/services.py +87 -0
- agent_cli/memory/__init__.py +7 -0
- agent_cli/memory/_files.py +250 -0
- agent_cli/memory/_filters.py +63 -0
- agent_cli/memory/_git.py +157 -0
- agent_cli/memory/_indexer.py +142 -0
- agent_cli/memory/_ingest.py +408 -0
- agent_cli/memory/_persistence.py +182 -0
- agent_cli/memory/_prompt.py +91 -0
- agent_cli/memory/_retrieval.py +294 -0
- agent_cli/memory/_store.py +169 -0
- agent_cli/memory/_streaming.py +44 -0
- agent_cli/memory/_tasks.py +48 -0
- agent_cli/memory/api.py +113 -0
- agent_cli/memory/client.py +272 -0
- agent_cli/memory/engine.py +361 -0
- agent_cli/memory/entities.py +43 -0
- agent_cli/memory/models.py +112 -0
- agent_cli/opts.py +433 -0
- agent_cli/py.typed +0 -0
- agent_cli/rag/__init__.py +3 -0
- agent_cli/rag/_indexer.py +67 -0
- agent_cli/rag/_indexing.py +226 -0
- agent_cli/rag/_prompt.py +30 -0
- agent_cli/rag/_retriever.py +156 -0
- agent_cli/rag/_store.py +48 -0
- agent_cli/rag/_utils.py +218 -0
- agent_cli/rag/api.py +175 -0
- agent_cli/rag/client.py +299 -0
- agent_cli/rag/engine.py +302 -0
- agent_cli/rag/models.py +55 -0
- agent_cli/scripts/.runtime/.gitkeep +0 -0
- agent_cli/scripts/__init__.py +1 -0
- agent_cli/scripts/check_plugin_skill_sync.py +50 -0
- agent_cli/scripts/linux-hotkeys/README.md +63 -0
- agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
- agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
- agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
- agent_cli/scripts/macos-hotkeys/README.md +45 -0
- agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
- agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
- agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
- agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
- agent_cli/scripts/nvidia-asr-server/README.md +99 -0
- agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
- agent_cli/scripts/nvidia-asr-server/server.py +255 -0
- agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
- agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
- agent_cli/scripts/run-openwakeword.sh +11 -0
- agent_cli/scripts/run-piper-windows.ps1 +30 -0
- agent_cli/scripts/run-piper.sh +24 -0
- agent_cli/scripts/run-whisper-linux.sh +40 -0
- agent_cli/scripts/run-whisper-macos.sh +6 -0
- agent_cli/scripts/run-whisper-windows.ps1 +51 -0
- agent_cli/scripts/run-whisper.sh +9 -0
- agent_cli/scripts/run_faster_whisper_server.py +136 -0
- agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
- agent_cli/scripts/setup-linux.sh +108 -0
- agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
- agent_cli/scripts/setup-macos.sh +76 -0
- agent_cli/scripts/setup-windows.ps1 +63 -0
- agent_cli/scripts/start-all-services-windows.ps1 +53 -0
- agent_cli/scripts/start-all-services.sh +178 -0
- agent_cli/scripts/sync_extras.py +138 -0
- agent_cli/server/__init__.py +3 -0
- agent_cli/server/cli.py +721 -0
- agent_cli/server/common.py +222 -0
- agent_cli/server/model_manager.py +288 -0
- agent_cli/server/model_registry.py +225 -0
- agent_cli/server/proxy/__init__.py +3 -0
- agent_cli/server/proxy/api.py +444 -0
- agent_cli/server/streaming.py +67 -0
- agent_cli/server/tts/__init__.py +3 -0
- agent_cli/server/tts/api.py +335 -0
- agent_cli/server/tts/backends/__init__.py +82 -0
- agent_cli/server/tts/backends/base.py +139 -0
- agent_cli/server/tts/backends/kokoro.py +403 -0
- agent_cli/server/tts/backends/piper.py +253 -0
- agent_cli/server/tts/model_manager.py +201 -0
- agent_cli/server/tts/model_registry.py +28 -0
- agent_cli/server/tts/wyoming_handler.py +249 -0
- agent_cli/server/whisper/__init__.py +3 -0
- agent_cli/server/whisper/api.py +413 -0
- agent_cli/server/whisper/backends/__init__.py +89 -0
- agent_cli/server/whisper/backends/base.py +97 -0
- agent_cli/server/whisper/backends/faster_whisper.py +225 -0
- agent_cli/server/whisper/backends/mlx.py +270 -0
- agent_cli/server/whisper/languages.py +116 -0
- agent_cli/server/whisper/model_manager.py +157 -0
- agent_cli/server/whisper/model_registry.py +28 -0
- agent_cli/server/whisper/wyoming_handler.py +203 -0
- agent_cli/services/__init__.py +343 -0
- agent_cli/services/_wyoming_utils.py +64 -0
- agent_cli/services/asr.py +506 -0
- agent_cli/services/llm.py +228 -0
- agent_cli/services/tts.py +450 -0
- agent_cli/services/wake_word.py +142 -0
- agent_cli-0.70.5.dist-info/METADATA +2118 -0
- agent_cli-0.70.5.dist-info/RECORD +196 -0
- agent_cli-0.70.5.dist-info/WHEEL +4 -0
- agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
- agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
agent_cli/core/audio.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""General audio utilities for SoundDevice device management and audio streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import logging
|
|
8
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Literal
|
|
11
|
+
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from agent_cli import constants
|
|
15
|
+
from agent_cli.core.utils import (
|
|
16
|
+
InteractiveStopEvent,
|
|
17
|
+
console,
|
|
18
|
+
print_device_index,
|
|
19
|
+
print_with_style,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
|
|
24
|
+
|
|
25
|
+
import sounddevice as sd
|
|
26
|
+
from rich.live import Live
|
|
27
|
+
|
|
28
|
+
from agent_cli import config
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class StreamConfig:
|
|
33
|
+
"""Configuration for an audio stream."""
|
|
34
|
+
|
|
35
|
+
rate: int
|
|
36
|
+
channels: int
|
|
37
|
+
dtype: str
|
|
38
|
+
device: int | None
|
|
39
|
+
blocksize: int
|
|
40
|
+
kind: Literal["input", "output"]
|
|
41
|
+
|
|
42
|
+
def to_stream(self) -> sd.Stream:
|
|
43
|
+
"""Create a SoundDevice stream from this configuration."""
|
|
44
|
+
import sounddevice as sd # noqa: PLC0415
|
|
45
|
+
|
|
46
|
+
if self.kind == "input":
|
|
47
|
+
stream_cls = sd.InputStream
|
|
48
|
+
elif self.kind == "output":
|
|
49
|
+
stream_cls = sd.OutputStream
|
|
50
|
+
else:
|
|
51
|
+
msg = f"Invalid stream kind: {self.kind}"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
|
|
54
|
+
return stream_cls(
|
|
55
|
+
samplerate=self.rate,
|
|
56
|
+
blocksize=self.blocksize,
|
|
57
|
+
device=self.device,
|
|
58
|
+
channels=self.channels,
|
|
59
|
+
dtype=self.dtype,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _AudioTee:
|
|
64
|
+
"""A thread-safe class to tee a continuous audio stream into multiple asyncio queues.
|
|
65
|
+
|
|
66
|
+
This class reads from a single audio stream in a background task and forwards
|
|
67
|
+
the audio chunks to any number of dynamically added consumer queues. It is designed
|
|
68
|
+
to be started once and run for the lifetime of the stream.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
stream: sd.InputStream,
|
|
74
|
+
stop_event: InteractiveStopEvent,
|
|
75
|
+
logger: logging.Logger,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize the AudioTee."""
|
|
78
|
+
self.stream = stream
|
|
79
|
+
self.stop_event = stop_event
|
|
80
|
+
self.logger = logger
|
|
81
|
+
self.queues: list[asyncio.Queue[bytes | None]] = []
|
|
82
|
+
self._task: asyncio.Task | None = None
|
|
83
|
+
self._stop_tee_event = asyncio.Event()
|
|
84
|
+
self._lock = asyncio.Lock() # For thread-safe modification of the queues list
|
|
85
|
+
|
|
86
|
+
async def add_queue(self) -> asyncio.Queue[bytes | None]:
|
|
87
|
+
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
88
|
+
async with self._lock:
|
|
89
|
+
self.queues.append(queue)
|
|
90
|
+
self.logger.debug("Added a queue to the tee. Total queues: %d", len(self.queues))
|
|
91
|
+
return queue
|
|
92
|
+
|
|
93
|
+
async def remove_queue(self, queue: asyncio.Queue[bytes | None]) -> None:
|
|
94
|
+
async with self._lock:
|
|
95
|
+
if queue in self.queues:
|
|
96
|
+
self.queues.remove(queue)
|
|
97
|
+
# Signal the end of the stream for this specific queue consumer
|
|
98
|
+
await queue.put(None)
|
|
99
|
+
self.logger.debug("Removed a queue from the tee. Total queues: %d", len(self.queues))
|
|
100
|
+
|
|
101
|
+
async def _run(self) -> None:
|
|
102
|
+
"""The main background task that reads from the stream and pushes to all queues."""
|
|
103
|
+
self.logger.debug("Starting continuous audio reading task.")
|
|
104
|
+
try:
|
|
105
|
+
while not self.stop_event.is_set() and not self._stop_tee_event.is_set():
|
|
106
|
+
# sd.InputStream.read() blocks until data is available
|
|
107
|
+
# We run it in a thread to avoid blocking the event loop
|
|
108
|
+
data, _overflow = await asyncio.to_thread(
|
|
109
|
+
self.stream.read,
|
|
110
|
+
constants.AUDIO_CHUNK_SIZE,
|
|
111
|
+
)
|
|
112
|
+
chunk = data.tobytes()
|
|
113
|
+
# Lock the queue list while iterating to prevent modification during iteration
|
|
114
|
+
async with self._lock:
|
|
115
|
+
for queue in self.queues:
|
|
116
|
+
await queue.put(chunk)
|
|
117
|
+
except Exception:
|
|
118
|
+
self.logger.exception("Error reading audio stream")
|
|
119
|
+
finally:
|
|
120
|
+
# Signal the end of the stream to all remaining consumers
|
|
121
|
+
self.logger.debug("Stopping audio reading task and signaling all consumers.")
|
|
122
|
+
async with self._lock:
|
|
123
|
+
for queue in self.queues:
|
|
124
|
+
await queue.put(None)
|
|
125
|
+
|
|
126
|
+
def start(self) -> None:
|
|
127
|
+
"""Start the background reading task."""
|
|
128
|
+
if self._task is None:
|
|
129
|
+
self._task = asyncio.create_task(self._run())
|
|
130
|
+
|
|
131
|
+
async def stop(self) -> None:
|
|
132
|
+
"""Stop the background reading task gracefully."""
|
|
133
|
+
if self._task and not self._task.done():
|
|
134
|
+
self._stop_tee_event.set()
|
|
135
|
+
await self._task
|
|
136
|
+
self.logger.debug("Audio tee stopped successfully.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@asynccontextmanager
|
|
140
|
+
async def tee_audio_stream(
|
|
141
|
+
stream: sd.InputStream,
|
|
142
|
+
stop_event: InteractiveStopEvent,
|
|
143
|
+
logger: logging.Logger,
|
|
144
|
+
) -> AsyncGenerator[_AudioTee, None]:
|
|
145
|
+
"""Context manager for an AudioTee."""
|
|
146
|
+
tee = _AudioTee(stream, stop_event, logger)
|
|
147
|
+
tee.start()
|
|
148
|
+
try:
|
|
149
|
+
yield tee
|
|
150
|
+
finally:
|
|
151
|
+
await tee.stop()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def read_from_queue(
|
|
155
|
+
queue: asyncio.Queue[bytes | None],
|
|
156
|
+
chunk_handler: Callable[[bytes], None] | Callable[[bytes], Awaitable[None]],
|
|
157
|
+
logger: logging.Logger,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Read audio chunks from a queue and call a handler."""
|
|
160
|
+
while True:
|
|
161
|
+
chunk = await queue.get()
|
|
162
|
+
if chunk is None:
|
|
163
|
+
break
|
|
164
|
+
if asyncio.iscoroutinefunction(chunk_handler):
|
|
165
|
+
await chunk_handler(chunk)
|
|
166
|
+
else:
|
|
167
|
+
chunk_handler(chunk)
|
|
168
|
+
logger.debug("Processed %d byte(s) of audio from queue", len(chunk))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@contextmanager
|
|
172
|
+
def open_audio_stream(
|
|
173
|
+
config: StreamConfig,
|
|
174
|
+
) -> Generator[sd.Stream, None, None]:
|
|
175
|
+
"""Context manager for a SoundDevice stream that ensures it's properly closed."""
|
|
176
|
+
stream = config.to_stream()
|
|
177
|
+
stream.start()
|
|
178
|
+
try:
|
|
179
|
+
yield stream
|
|
180
|
+
finally:
|
|
181
|
+
stream.stop()
|
|
182
|
+
stream.close()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def read_audio_stream(
|
|
186
|
+
stream: sd.InputStream,
|
|
187
|
+
stop_event: InteractiveStopEvent,
|
|
188
|
+
chunk_handler: Callable[[bytes], None] | Callable[[bytes], Awaitable[None]],
|
|
189
|
+
logger: logging.Logger,
|
|
190
|
+
*,
|
|
191
|
+
live: Live | None = None,
|
|
192
|
+
quiet: bool = False,
|
|
193
|
+
progress_message: str = "Processing audio",
|
|
194
|
+
progress_style: str = "blue",
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Core audio reading function - reads chunks and calls handler.
|
|
197
|
+
|
|
198
|
+
This is the single source of truth for audio reading logic.
|
|
199
|
+
All other audio functions should use this to avoid duplication.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
stream: SoundDevice InputStream
|
|
203
|
+
stop_event: Event to stop reading
|
|
204
|
+
chunk_handler: Function to handle each chunk (sync or async)
|
|
205
|
+
logger: Logger instance
|
|
206
|
+
live: Rich Live display for progress
|
|
207
|
+
quiet: If True, suppress console output
|
|
208
|
+
progress_message: Message to display
|
|
209
|
+
progress_style: Rich style for progress
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
seconds_streamed = 0.0
|
|
214
|
+
while not stop_event.is_set():
|
|
215
|
+
data, _overflow = await asyncio.to_thread(
|
|
216
|
+
stream.read,
|
|
217
|
+
constants.AUDIO_CHUNK_SIZE,
|
|
218
|
+
)
|
|
219
|
+
chunk = data.tobytes()
|
|
220
|
+
|
|
221
|
+
# Handle chunk (sync or async)
|
|
222
|
+
if asyncio.iscoroutinefunction(chunk_handler):
|
|
223
|
+
await chunk_handler(chunk)
|
|
224
|
+
else:
|
|
225
|
+
chunk_handler(chunk)
|
|
226
|
+
|
|
227
|
+
logger.debug("Processed %d byte(s) of audio", len(chunk))
|
|
228
|
+
|
|
229
|
+
# Update progress display
|
|
230
|
+
seconds_streamed += len(chunk) / (constants.AUDIO_RATE * constants.AUDIO_CHANNELS * 2)
|
|
231
|
+
if live and not quiet:
|
|
232
|
+
if stop_event.ctrl_c_pressed:
|
|
233
|
+
msg = f"Ctrl+C pressed. Stopping {progress_message.lower()}..."
|
|
234
|
+
live.update(Text(msg, style="yellow"))
|
|
235
|
+
else:
|
|
236
|
+
live.update(
|
|
237
|
+
Text(
|
|
238
|
+
f"{progress_message}... ({seconds_streamed:.1f}s)",
|
|
239
|
+
style=progress_style,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
except Exception:
|
|
244
|
+
logger.exception("Error reading audio")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def setup_input_stream(
|
|
248
|
+
input_device_index: int | None,
|
|
249
|
+
) -> StreamConfig:
|
|
250
|
+
"""Get standard audio input stream configuration.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
input_device_index: Input device index
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
StreamConfig for open_audio_stream
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
return StreamConfig(
|
|
260
|
+
dtype=constants.AUDIO_FORMAT_STR,
|
|
261
|
+
channels=constants.AUDIO_CHANNELS,
|
|
262
|
+
rate=constants.AUDIO_RATE,
|
|
263
|
+
kind="input",
|
|
264
|
+
blocksize=constants.AUDIO_CHUNK_SIZE,
|
|
265
|
+
device=input_device_index,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def setup_output_stream(
|
|
270
|
+
output_device_index: int | None,
|
|
271
|
+
*,
|
|
272
|
+
sample_rate: int | None = None,
|
|
273
|
+
sample_width: int | None = None,
|
|
274
|
+
channels: int | None = None,
|
|
275
|
+
) -> StreamConfig:
|
|
276
|
+
"""Get standard audio output stream configuration.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
output_device_index: Output device index
|
|
280
|
+
sample_rate: Custom sample rate (defaults to config)
|
|
281
|
+
sample_width: Custom sample width in bytes (defaults to config)
|
|
282
|
+
channels: Custom channel count (defaults to config)
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
StreamConfig for open_audio_stream
|
|
286
|
+
|
|
287
|
+
"""
|
|
288
|
+
# Map sample width to dtype if necessary. 2 -> int16.
|
|
289
|
+
dtype = constants.AUDIO_FORMAT_STR
|
|
290
|
+
if sample_width == 1:
|
|
291
|
+
dtype = "int8"
|
|
292
|
+
elif sample_width == 4: # noqa: PLR2004
|
|
293
|
+
dtype = "int32"
|
|
294
|
+
|
|
295
|
+
return StreamConfig(
|
|
296
|
+
dtype=dtype,
|
|
297
|
+
channels=channels or constants.AUDIO_CHANNELS,
|
|
298
|
+
rate=sample_rate or constants.AUDIO_RATE,
|
|
299
|
+
kind="output",
|
|
300
|
+
blocksize=constants.AUDIO_CHUNK_SIZE,
|
|
301
|
+
device=output_device_index,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@functools.cache
|
|
306
|
+
def _get_all_devices() -> list[dict]:
|
|
307
|
+
"""Get information for all audio devices with caching.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
List of device info dictionaries with added 'index' field
|
|
311
|
+
|
|
312
|
+
"""
|
|
313
|
+
import sounddevice as sd # noqa: PLC0415
|
|
314
|
+
|
|
315
|
+
devices = []
|
|
316
|
+
try:
|
|
317
|
+
query_result = sd.query_devices()
|
|
318
|
+
# sd.query_devices() returns a DeviceList (list-like) of dicts
|
|
319
|
+
for i, device in enumerate(query_result):
|
|
320
|
+
device_info = dict(device)
|
|
321
|
+
device_info["index"] = i
|
|
322
|
+
devices.append(device_info)
|
|
323
|
+
except Exception:
|
|
324
|
+
# Fallback or empty if no devices found/error
|
|
325
|
+
logging.getLogger(__name__).exception("Error querying audio devices")
|
|
326
|
+
return devices
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _get_device_by_index(input_device_index: int) -> dict:
|
|
330
|
+
"""Get device info by index from cached device list.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
input_device_index: Device index to look up
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Device info dictionary
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValueError: If device index is not found
|
|
340
|
+
|
|
341
|
+
"""
|
|
342
|
+
for device in _get_all_devices():
|
|
343
|
+
if device["index"] == input_device_index:
|
|
344
|
+
return device
|
|
345
|
+
msg = f"Device index {input_device_index} not found"
|
|
346
|
+
raise ValueError(msg)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _list_input_devices() -> None:
|
|
350
|
+
"""Print a numbered list of available input devices."""
|
|
351
|
+
console.print("[bold]Available input devices:[/bold]")
|
|
352
|
+
for device in _get_all_devices():
|
|
353
|
+
if device.get("max_input_channels", 0) > 0:
|
|
354
|
+
console.print(f" [yellow]{device['index']}[/yellow]: {device['name']}")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _list_output_devices() -> None:
|
|
358
|
+
"""Print a numbered list of available output devices."""
|
|
359
|
+
console.print("[bold]Available output devices:[/bold]")
|
|
360
|
+
for device in _get_all_devices():
|
|
361
|
+
if device.get("max_output_channels", 0) > 0:
|
|
362
|
+
console.print(f" [yellow]{device['index']}[/yellow]: {device['name']}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def list_all_devices() -> None:
|
|
366
|
+
"""Print a numbered list of all available audio devices with their capabilities."""
|
|
367
|
+
_list_input_devices()
|
|
368
|
+
console.print()
|
|
369
|
+
_list_output_devices()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _in_or_out_device(
|
|
373
|
+
input_device_name: str | None,
|
|
374
|
+
input_device_index: int | None,
|
|
375
|
+
key: str,
|
|
376
|
+
what: str,
|
|
377
|
+
) -> tuple[int | None, str | None]:
|
|
378
|
+
"""Find an input device by a prioritized, comma-separated list of keywords."""
|
|
379
|
+
if input_device_name is None and input_device_index is None:
|
|
380
|
+
return None, None
|
|
381
|
+
|
|
382
|
+
if input_device_index is not None:
|
|
383
|
+
info = _get_device_by_index(input_device_index)
|
|
384
|
+
return input_device_index, info.get("name")
|
|
385
|
+
assert input_device_name is not None
|
|
386
|
+
search_terms = [term.strip().lower() for term in input_device_name.split(",") if term.strip()]
|
|
387
|
+
|
|
388
|
+
if not search_terms:
|
|
389
|
+
msg = "Device name string is empty or contains only whitespace."
|
|
390
|
+
raise ValueError(msg)
|
|
391
|
+
|
|
392
|
+
devices = []
|
|
393
|
+
for device in _get_all_devices():
|
|
394
|
+
device_info_name = device.get("name")
|
|
395
|
+
if device_info_name and device.get(key, 0) > 0:
|
|
396
|
+
devices.append((device["index"], device_info_name))
|
|
397
|
+
|
|
398
|
+
for term in search_terms:
|
|
399
|
+
for index, name in devices:
|
|
400
|
+
if term in name.lower():
|
|
401
|
+
return index, name
|
|
402
|
+
|
|
403
|
+
msg = f"No {what} device found matching any of the keywords in {input_device_name!r}"
|
|
404
|
+
raise ValueError(msg)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _input_device(
|
|
408
|
+
input_device_name: str | None,
|
|
409
|
+
input_device_index: int | None,
|
|
410
|
+
) -> tuple[int | None, str | None]:
|
|
411
|
+
"""Find an input device by a prioritized, comma-separated list of keywords."""
|
|
412
|
+
return _in_or_out_device(input_device_name, input_device_index, "max_input_channels", "input")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _output_device(
|
|
416
|
+
input_device_name: str | None,
|
|
417
|
+
input_device_index: int | None,
|
|
418
|
+
) -> tuple[int | None, str | None]:
|
|
419
|
+
"""Find an output device by a prioritized, comma-separated list of keywords."""
|
|
420
|
+
return _in_or_out_device(
|
|
421
|
+
input_device_name,
|
|
422
|
+
input_device_index,
|
|
423
|
+
"max_output_channels",
|
|
424
|
+
"output",
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def setup_devices(
|
|
429
|
+
general_cfg: config.General,
|
|
430
|
+
audio_in_cfg: config.AudioInput | None,
|
|
431
|
+
audio_out_cfg: config.AudioOutput | None,
|
|
432
|
+
) -> tuple[int | None, str | None, int | None] | None:
|
|
433
|
+
"""Handle device listing and setup."""
|
|
434
|
+
if general_cfg.list_devices:
|
|
435
|
+
list_all_devices()
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
# Setup input device
|
|
439
|
+
input_device_index, input_device_name = _input_device(
|
|
440
|
+
audio_in_cfg.input_device_name if audio_in_cfg else None,
|
|
441
|
+
audio_in_cfg.input_device_index if audio_in_cfg else None,
|
|
442
|
+
)
|
|
443
|
+
if not general_cfg.quiet:
|
|
444
|
+
print_device_index(input_device_index, input_device_name)
|
|
445
|
+
|
|
446
|
+
# Setup output device for TTS if enabled
|
|
447
|
+
tts_output_device_index = audio_out_cfg.output_device_index if audio_out_cfg else None
|
|
448
|
+
if (
|
|
449
|
+
audio_out_cfg
|
|
450
|
+
and audio_out_cfg.enable_tts
|
|
451
|
+
and (audio_out_cfg.output_device_name or audio_out_cfg.output_device_index)
|
|
452
|
+
):
|
|
453
|
+
tts_output_device_index, tts_output_device_name = _output_device(
|
|
454
|
+
audio_out_cfg.output_device_name,
|
|
455
|
+
audio_out_cfg.output_device_index,
|
|
456
|
+
)
|
|
457
|
+
if tts_output_device_index is not None and not general_cfg.quiet:
|
|
458
|
+
msg = f"🔊 TTS output device [bold yellow]{tts_output_device_index}[/bold yellow] ([italic]{tts_output_device_name}[/italic])"
|
|
459
|
+
print_with_style(msg)
|
|
460
|
+
|
|
461
|
+
return input_device_index, input_device_name, tts_output_device_index
|