187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
"""启动时列出 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
|