DroneMind/voice_drone/core/mic_device_select.py
2026-04-14 09:54:26 +08:00

187 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""启动时列出 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