"""启动时列出 arecord -l 与 PyAudio 输入设备,并把 ALSA card/device 映射到 PyAudio 索引供交互选择。""" from __future__ import annotations import re import subprocess from typing import List, Optional, Tuple, Any from voice_drone.logging_ import get_logger logger = get_logger("mic_device_select") def run_arecord_l() -> str: try: r = subprocess.run( ["arecord", "-l"], capture_output=True, text=True, timeout=5, check=False, ) out = (r.stdout or "").rstrip() err = (r.stderr or "").strip() body = out + (f"\n{err}" if err else "") return body.strip() if body.strip() else "(arecord 无输出)" except FileNotFoundError: return "(未找到 arecord,可安装 alsa-utils)" except Exception as e: # noqa: BLE001 return f"(执行 arecord -l 失败: {e})" def parse_arecord_capture_lines(text: str) -> List[Tuple[int, int, str]]: rows: List[Tuple[int, int, str]] = [] for line in text.splitlines(): m = re.search(r"card\s+(\d+):.+?,\s*device\s+(\d+):", line, re.IGNORECASE) if m: rows.append((int(m.group(1)), int(m.group(2)), line.strip())) return rows def _pyaudio_input_devices() -> List[Tuple[int, Any]]: from voice_drone.core.portaudio_env import fix_ld_path_for_portaudio fix_ld_path_for_portaudio() import pyaudio pa = pyaudio.PyAudio() out: List[Tuple[int, Any]] = [] try: for i in range(pa.get_device_count()): try: inf = pa.get_device_info_by_index(i) if int(inf.get("maxInputChannels", 0)) <= 0: continue out.append((i, inf)) except Exception: continue return out finally: pa.terminate() def match_alsa_hw_to_pyaudio_index( card: int, dev: int, pa_items: List[Tuple[int, Any]], ) -> Optional[int]: want1 = f"(hw:{card},{dev})" want2 = f"(hw:{card}, {dev})" for idx, inf in pa_items: name = str(inf.get("name", "")) if want1 in name or want2 in name: return idx return None def print_mic_device_menu() -> List[int]: """ 打印 arecord + PyAudio + 映射表。 返回本菜单中列出的 PyAudio 索引列表(顺序与 [1]、[2]… 一致)。 """ alsa_text = run_arecord_l() pa_items = _pyaudio_input_devices() print("\n" + "=" * 72, flush=True) print("录音设备(先 ALSA,再 PortAudio;请记下要用的 PyAudio 索引)", flush=True) print("=" * 72, flush=True) print("\n--- arecord -l(系统硬件视角)---\n", flush=True) print(alsa_text, flush=True) print("\n--- PyAudio 可录音设备(maxInputChannels > 0)---\n", flush=True) ordered_indices: List[int] = [] if not pa_items: print("(无)\n", flush=True) for rank, (idx, inf) in enumerate(pa_items, start=1): ordered_indices.append(idx) mic = int(inf.get("maxInputChannels", 0)) outc = int(inf.get("maxOutputChannels", 0)) name = str(inf.get("name", "?")) print( f" [{rank}] PyAudio_index={idx} in={mic} out={outc} {name}", flush=True, ) alsa_rows = parse_arecord_capture_lines(alsa_text) print( "\n--- 映射:arecord 的 card / device → PyAudio 索引(匹配设备名中的 hw:X,Y)---\n", flush=True, ) if not alsa_rows: print(" (未解析到 card/device 行,请直接用上一表的 PyAudio_index)", flush=True) for card, dev, line in alsa_rows: pidx = match_alsa_hw_to_pyaudio_index(card, dev, pa_items) short = line if len(line) <= 76 else line[:73] + "..." if pidx is not None: print( f" card {card}, device {dev} → PyAudio 索引 {pidx}\n {short}", flush=True, ) else: print( f" card {card}, device {dev} → (无 in>0 设备名含 hw:{card},{dev})\n {short}", flush=True, ) print( "\n说明:程序只会用「一个」PyAudio 索引打开麦克风;" "HDMI 等若 in=0 不会出现在可录音列表。\n" + "=" * 72 + "\n", flush=True, ) return ordered_indices def prompt_for_input_device_index() -> int: """交互式选择,返回写入 audio.input_device_index 的 PyAudio 索引。""" ordered = print_mic_device_menu() if not ordered: print("错误:没有发现可录音的 PyAudio 设备。", flush=True) raise SystemExit(2) valid_set = set(ordered) print( "请输入菜单序号 [1-" f"{len(ordered)}](推荐),或直接输入 PyAudio_index 数字;q 退出。", flush=True, ) while True: try: raw = input("录音设备> ").strip() except EOFError: raise SystemExit(1) from None if not raw: continue if raw.lower() in ("q", "quit", "exit"): raise SystemExit(0) if not raw.isdigit(): print("请输入正整数或 q。", flush=True) continue n = int(raw) if 1 <= n <= len(ordered): chosen = ordered[n - 1] print(f"已选择:菜单 [{n}] → PyAudio 索引 {chosen}\n", flush=True) logger.info("交互选择录音设备 PyAudio index=%s", chosen) return chosen if n in valid_set: print(f"已选择:PyAudio 索引 {n}\n", flush=True) logger.info("交互选择录音设备 PyAudio index=%s", n) return n print( f"无效:{n} 不在可选列表。可选序号为 1~{len(ordered)}," f"或索引之一 {sorted(valid_set)}。", flush=True, ) def apply_input_device_index_only(index: int) -> None: """写入运行时配置:仅用索引选设备,其余 yaml 中的 hw/名称匹配不再参与 logic。""" from voice_drone.core.configuration import SYSTEM_AUDIO_CONFIG SYSTEM_AUDIO_CONFIG["input_device_index"] = int(index) SYSTEM_AUDIO_CONFIG["input_hw_card_device"] = None SYSTEM_AUDIO_CONFIG["input_device_name_match"] = None SYSTEM_AUDIO_CONFIG["input_strict_selection"] = False