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.
Files changed (196) hide show
  1. agent_cli/__init__.py +5 -0
  2. agent_cli/__main__.py +6 -0
  3. agent_cli/_extras.json +14 -0
  4. agent_cli/_requirements/.gitkeep +0 -0
  5. agent_cli/_requirements/audio.txt +79 -0
  6. agent_cli/_requirements/faster-whisper.txt +215 -0
  7. agent_cli/_requirements/kokoro.txt +425 -0
  8. agent_cli/_requirements/llm.txt +183 -0
  9. agent_cli/_requirements/memory.txt +355 -0
  10. agent_cli/_requirements/mlx-whisper.txt +222 -0
  11. agent_cli/_requirements/piper.txt +176 -0
  12. agent_cli/_requirements/rag.txt +402 -0
  13. agent_cli/_requirements/server.txt +154 -0
  14. agent_cli/_requirements/speed.txt +77 -0
  15. agent_cli/_requirements/vad.txt +155 -0
  16. agent_cli/_requirements/wyoming.txt +71 -0
  17. agent_cli/_tools.py +368 -0
  18. agent_cli/agents/__init__.py +23 -0
  19. agent_cli/agents/_voice_agent_common.py +136 -0
  20. agent_cli/agents/assistant.py +383 -0
  21. agent_cli/agents/autocorrect.py +284 -0
  22. agent_cli/agents/chat.py +496 -0
  23. agent_cli/agents/memory/__init__.py +31 -0
  24. agent_cli/agents/memory/add.py +190 -0
  25. agent_cli/agents/memory/proxy.py +160 -0
  26. agent_cli/agents/rag_proxy.py +128 -0
  27. agent_cli/agents/speak.py +209 -0
  28. agent_cli/agents/transcribe.py +671 -0
  29. agent_cli/agents/transcribe_daemon.py +499 -0
  30. agent_cli/agents/voice_edit.py +291 -0
  31. agent_cli/api.py +22 -0
  32. agent_cli/cli.py +106 -0
  33. agent_cli/config.py +503 -0
  34. agent_cli/config_cmd.py +307 -0
  35. agent_cli/constants.py +27 -0
  36. agent_cli/core/__init__.py +1 -0
  37. agent_cli/core/audio.py +461 -0
  38. agent_cli/core/audio_format.py +299 -0
  39. agent_cli/core/chroma.py +88 -0
  40. agent_cli/core/deps.py +191 -0
  41. agent_cli/core/openai_proxy.py +139 -0
  42. agent_cli/core/process.py +195 -0
  43. agent_cli/core/reranker.py +120 -0
  44. agent_cli/core/sse.py +87 -0
  45. agent_cli/core/transcription_logger.py +70 -0
  46. agent_cli/core/utils.py +526 -0
  47. agent_cli/core/vad.py +175 -0
  48. agent_cli/core/watch.py +65 -0
  49. agent_cli/dev/__init__.py +14 -0
  50. agent_cli/dev/cli.py +1588 -0
  51. agent_cli/dev/coding_agents/__init__.py +19 -0
  52. agent_cli/dev/coding_agents/aider.py +24 -0
  53. agent_cli/dev/coding_agents/base.py +167 -0
  54. agent_cli/dev/coding_agents/claude.py +39 -0
  55. agent_cli/dev/coding_agents/codex.py +24 -0
  56. agent_cli/dev/coding_agents/continue_dev.py +15 -0
  57. agent_cli/dev/coding_agents/copilot.py +24 -0
  58. agent_cli/dev/coding_agents/cursor_agent.py +48 -0
  59. agent_cli/dev/coding_agents/gemini.py +28 -0
  60. agent_cli/dev/coding_agents/opencode.py +15 -0
  61. agent_cli/dev/coding_agents/registry.py +49 -0
  62. agent_cli/dev/editors/__init__.py +19 -0
  63. agent_cli/dev/editors/base.py +89 -0
  64. agent_cli/dev/editors/cursor.py +15 -0
  65. agent_cli/dev/editors/emacs.py +46 -0
  66. agent_cli/dev/editors/jetbrains.py +56 -0
  67. agent_cli/dev/editors/nano.py +31 -0
  68. agent_cli/dev/editors/neovim.py +33 -0
  69. agent_cli/dev/editors/registry.py +59 -0
  70. agent_cli/dev/editors/sublime.py +20 -0
  71. agent_cli/dev/editors/vim.py +42 -0
  72. agent_cli/dev/editors/vscode.py +15 -0
  73. agent_cli/dev/editors/zed.py +20 -0
  74. agent_cli/dev/project.py +568 -0
  75. agent_cli/dev/registry.py +52 -0
  76. agent_cli/dev/skill/SKILL.md +141 -0
  77. agent_cli/dev/skill/examples.md +571 -0
  78. agent_cli/dev/terminals/__init__.py +19 -0
  79. agent_cli/dev/terminals/apple_terminal.py +82 -0
  80. agent_cli/dev/terminals/base.py +56 -0
  81. agent_cli/dev/terminals/gnome.py +51 -0
  82. agent_cli/dev/terminals/iterm2.py +84 -0
  83. agent_cli/dev/terminals/kitty.py +77 -0
  84. agent_cli/dev/terminals/registry.py +48 -0
  85. agent_cli/dev/terminals/tmux.py +58 -0
  86. agent_cli/dev/terminals/warp.py +132 -0
  87. agent_cli/dev/terminals/zellij.py +78 -0
  88. agent_cli/dev/worktree.py +856 -0
  89. agent_cli/docs_gen.py +417 -0
  90. agent_cli/example-config.toml +185 -0
  91. agent_cli/install/__init__.py +5 -0
  92. agent_cli/install/common.py +89 -0
  93. agent_cli/install/extras.py +174 -0
  94. agent_cli/install/hotkeys.py +48 -0
  95. agent_cli/install/services.py +87 -0
  96. agent_cli/memory/__init__.py +7 -0
  97. agent_cli/memory/_files.py +250 -0
  98. agent_cli/memory/_filters.py +63 -0
  99. agent_cli/memory/_git.py +157 -0
  100. agent_cli/memory/_indexer.py +142 -0
  101. agent_cli/memory/_ingest.py +408 -0
  102. agent_cli/memory/_persistence.py +182 -0
  103. agent_cli/memory/_prompt.py +91 -0
  104. agent_cli/memory/_retrieval.py +294 -0
  105. agent_cli/memory/_store.py +169 -0
  106. agent_cli/memory/_streaming.py +44 -0
  107. agent_cli/memory/_tasks.py +48 -0
  108. agent_cli/memory/api.py +113 -0
  109. agent_cli/memory/client.py +272 -0
  110. agent_cli/memory/engine.py +361 -0
  111. agent_cli/memory/entities.py +43 -0
  112. agent_cli/memory/models.py +112 -0
  113. agent_cli/opts.py +433 -0
  114. agent_cli/py.typed +0 -0
  115. agent_cli/rag/__init__.py +3 -0
  116. agent_cli/rag/_indexer.py +67 -0
  117. agent_cli/rag/_indexing.py +226 -0
  118. agent_cli/rag/_prompt.py +30 -0
  119. agent_cli/rag/_retriever.py +156 -0
  120. agent_cli/rag/_store.py +48 -0
  121. agent_cli/rag/_utils.py +218 -0
  122. agent_cli/rag/api.py +175 -0
  123. agent_cli/rag/client.py +299 -0
  124. agent_cli/rag/engine.py +302 -0
  125. agent_cli/rag/models.py +55 -0
  126. agent_cli/scripts/.runtime/.gitkeep +0 -0
  127. agent_cli/scripts/__init__.py +1 -0
  128. agent_cli/scripts/check_plugin_skill_sync.py +50 -0
  129. agent_cli/scripts/linux-hotkeys/README.md +63 -0
  130. agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
  131. agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
  132. agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
  133. agent_cli/scripts/macos-hotkeys/README.md +45 -0
  134. agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
  135. agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
  136. agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
  137. agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
  138. agent_cli/scripts/nvidia-asr-server/README.md +99 -0
  139. agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
  140. agent_cli/scripts/nvidia-asr-server/server.py +255 -0
  141. agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
  142. agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
  143. agent_cli/scripts/run-openwakeword.sh +11 -0
  144. agent_cli/scripts/run-piper-windows.ps1 +30 -0
  145. agent_cli/scripts/run-piper.sh +24 -0
  146. agent_cli/scripts/run-whisper-linux.sh +40 -0
  147. agent_cli/scripts/run-whisper-macos.sh +6 -0
  148. agent_cli/scripts/run-whisper-windows.ps1 +51 -0
  149. agent_cli/scripts/run-whisper.sh +9 -0
  150. agent_cli/scripts/run_faster_whisper_server.py +136 -0
  151. agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
  152. agent_cli/scripts/setup-linux.sh +108 -0
  153. agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
  154. agent_cli/scripts/setup-macos.sh +76 -0
  155. agent_cli/scripts/setup-windows.ps1 +63 -0
  156. agent_cli/scripts/start-all-services-windows.ps1 +53 -0
  157. agent_cli/scripts/start-all-services.sh +178 -0
  158. agent_cli/scripts/sync_extras.py +138 -0
  159. agent_cli/server/__init__.py +3 -0
  160. agent_cli/server/cli.py +721 -0
  161. agent_cli/server/common.py +222 -0
  162. agent_cli/server/model_manager.py +288 -0
  163. agent_cli/server/model_registry.py +225 -0
  164. agent_cli/server/proxy/__init__.py +3 -0
  165. agent_cli/server/proxy/api.py +444 -0
  166. agent_cli/server/streaming.py +67 -0
  167. agent_cli/server/tts/__init__.py +3 -0
  168. agent_cli/server/tts/api.py +335 -0
  169. agent_cli/server/tts/backends/__init__.py +82 -0
  170. agent_cli/server/tts/backends/base.py +139 -0
  171. agent_cli/server/tts/backends/kokoro.py +403 -0
  172. agent_cli/server/tts/backends/piper.py +253 -0
  173. agent_cli/server/tts/model_manager.py +201 -0
  174. agent_cli/server/tts/model_registry.py +28 -0
  175. agent_cli/server/tts/wyoming_handler.py +249 -0
  176. agent_cli/server/whisper/__init__.py +3 -0
  177. agent_cli/server/whisper/api.py +413 -0
  178. agent_cli/server/whisper/backends/__init__.py +89 -0
  179. agent_cli/server/whisper/backends/base.py +97 -0
  180. agent_cli/server/whisper/backends/faster_whisper.py +225 -0
  181. agent_cli/server/whisper/backends/mlx.py +270 -0
  182. agent_cli/server/whisper/languages.py +116 -0
  183. agent_cli/server/whisper/model_manager.py +157 -0
  184. agent_cli/server/whisper/model_registry.py +28 -0
  185. agent_cli/server/whisper/wyoming_handler.py +203 -0
  186. agent_cli/services/__init__.py +343 -0
  187. agent_cli/services/_wyoming_utils.py +64 -0
  188. agent_cli/services/asr.py +506 -0
  189. agent_cli/services/llm.py +228 -0
  190. agent_cli/services/tts.py +450 -0
  191. agent_cli/services/wake_word.py +142 -0
  192. agent_cli-0.70.5.dist-info/METADATA +2118 -0
  193. agent_cli-0.70.5.dist-info/RECORD +196 -0
  194. agent_cli-0.70.5.dist-info/WHEEL +4 -0
  195. agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
  196. agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
@@ -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