Add microphone picker and device management features

- Implement `list_input_devices` to retrieve and filter input-capable audio devices.
- Create `MicPickerDialog` for selecting and saving microphone settings.
- Add `set_env_value` function to update .env file with selected microphone index.
- Update UI components to integrate microphone selection functionality.
- Enhance tests for microphone device handling and environment variable management.
This commit is contained in:
2026-09-24 23:31:18 -06:00
parent ad9925da02
commit 54030a7b31
8 changed files with 283 additions and 245 deletions
+37 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import os
import platform
import re
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/)
@@ -29,6 +30,26 @@ def _load_env() -> None:
_load_env()
def set_env_value(key: str, value: str) -> None:
"""Write *key=value* into .env, in place — replacing (or uncommenting)
an existing line for it, appending a new one otherwise. Used by the tray's
microphone picker so a device pick survives a restart without hand-editing
the file. Settings read once at import time (like MIC_DEVICE) still need
that restart to take effect; this only updates the file and os.environ."""
env_path = HERE / ".env"
lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
pattern = re.compile(rf"^\s*#?\s*{re.escape(key)}\s*=")
new_line = f"{key}={value}"
for i, line in enumerate(lines):
if pattern.match(line):
lines[i] = new_line
break
else:
lines.append(new_line)
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
os.environ[key] = value
def _node_name() -> str:
try:
return platform.node() or "desktop"
@@ -115,7 +136,22 @@ DIALOGUE_VOICES = os.environ.get("DIALOGUE_VOICES", "")
# ── mic / VAD (same tuning knobs as bolt_desk.py) ───────────────────────────
MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index
def _mic_device(raw: str):
"""sounddevice treats an int index and a string name very differently: a
string is matched as a (fragile, often ambiguous) substring against every
device name, while an int is used as-is. .env only ever gives us a
string, so a numeric one — what the tray's mic picker saves, since the
same physical device usually shows up once per host API (MME,
DirectSound, WASAPI, ...) under the same name — has to be cast back to
int here, or it goes through the substring path and matches nothing (or
everything) instead."""
raw = raw.strip()
if not raw:
return None
return int(raw) if raw.lstrip("-").isdigit() else raw
MIC_DEVICE = _mic_device(os.environ.get("MIC_DEVICE", ""))
SPEAKER_DEVICE = os.environ.get("SPEAKER_DEVICE", "") or None
RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))