177 lines
5.6 KiB
Python
177 lines
5.6 KiB
Python
"""Audio input device_select persistence (Pulse name must survive start)."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
SRC_PATH = PROJECT_ROOT / "src"
|
|
if str(SRC_PATH) not in sys.path:
|
|
sys.path.insert(0, str(SRC_PATH))
|
|
|
|
from microdot import Microdot # noqa: E402
|
|
from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402
|
|
|
|
SNOWBALL = (
|
|
"alsa_input.usb-BLUE_MICROPHONE_Blue_Snowball_SUGA_2020_10_09_41646-00.mono-fallback"
|
|
)
|
|
|
|
|
|
def _start_app(app: Microdot, port: int = 0):
|
|
def runner():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
loop.run_until_complete(app.start_server(host="127.0.0.1", port=port))
|
|
finally:
|
|
loop.close()
|
|
|
|
thread = threading.Thread(target=runner, daemon=True)
|
|
thread.start()
|
|
deadline = time.time() + 5.0
|
|
while time.time() < deadline:
|
|
server = getattr(app, "server", None)
|
|
if server and getattr(server, "sockets", None):
|
|
sockets = server.sockets or []
|
|
if sockets:
|
|
return thread, sockets[0].getsockname()[1]
|
|
time.sleep(0.05)
|
|
raise RuntimeError("server failed to start")
|
|
|
|
|
|
@pytest.fixture
|
|
def audio_run_path(tmp_path, monkeypatch):
|
|
path = tmp_path / "audio_run.json"
|
|
monkeypatch.setattr(
|
|
"util.audio_run_persist._db_path",
|
|
lambda: str(path),
|
|
)
|
|
return path
|
|
|
|
|
|
def test_write_start_keeps_pulse_device_select_not_portaudio_index(audio_run_path):
|
|
write_audio_run_state(
|
|
enabled=True,
|
|
device=1,
|
|
device_select=SNOWBALL,
|
|
)
|
|
st = read_audio_run_state()
|
|
assert st["device_select"] == SNOWBALL
|
|
assert st["device"] == 1
|
|
|
|
|
|
def test_put_device_saves_pulse_name(audio_run_path):
|
|
app = Microdot()
|
|
|
|
@app.route("/api/audio/device", methods=["PUT"])
|
|
async def audio_set_device(request):
|
|
payload = request.json if isinstance(request.json, dict) else {}
|
|
device_select = str(payload.get("device_select") or "").strip()
|
|
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
|
|
|
prev = read_audio_run_state()
|
|
write_audio_run_state(
|
|
enabled=bool(prev.get("enabled")),
|
|
device=device_select if device_select else None,
|
|
device_override="",
|
|
device_select=device_select,
|
|
)
|
|
return {"ok": True, "audio_run": read_audio_run_state()}
|
|
|
|
_, port = _start_app(app)
|
|
base = f"http://127.0.0.1:{port}"
|
|
resp = requests.put(
|
|
f"{base}/api/audio/device",
|
|
json={"device_select": SNOWBALL, "device_override": ""},
|
|
timeout=5,
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["audio_run"]["device_select"] == SNOWBALL
|
|
assert read_audio_run_state()["device_select"] == SNOWBALL
|
|
|
|
|
|
def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
|
|
detector = MagicMock()
|
|
detector.status.return_value = {"running": True, "device": 2}
|
|
|
|
def fake_resolve(device):
|
|
assert device == SNOWBALL
|
|
return 2
|
|
|
|
monkeypatch.setattr(
|
|
"util.pulse_audio_devices.resolve_capture_device",
|
|
fake_resolve,
|
|
)
|
|
|
|
app = Microdot()
|
|
|
|
@app.route("/api/audio/start", methods=["POST"])
|
|
async def audio_start(request):
|
|
payload = request.json if isinstance(request.json, dict) else {}
|
|
device = payload.get("device", None)
|
|
if device in ("", None):
|
|
device = None
|
|
device_select = str(payload.get("device_select") or "").strip()
|
|
if not device_select and device not in ("", None):
|
|
device_select = str(device).strip()
|
|
from util.pulse_audio_devices import resolve_capture_device
|
|
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
|
|
|
device = resolve_capture_device(device)
|
|
detector.start(device=device)
|
|
write_audio_run_state(
|
|
enabled=True,
|
|
device=device,
|
|
device_override="",
|
|
device_select=device_select,
|
|
)
|
|
st = detector.status()
|
|
st["audio_run"] = read_audio_run_state()
|
|
return {"ok": True, "status": st}
|
|
|
|
@app.route("/api/audio/status")
|
|
async def audio_status(request):
|
|
_ = request
|
|
from util.audio_run_persist import read_audio_run_state
|
|
|
|
st = detector.status()
|
|
st["audio_run"] = read_audio_run_state()
|
|
return {"status": st}
|
|
|
|
_, port = _start_app(app)
|
|
base = f"http://127.0.0.1:{port}"
|
|
start = requests.post(
|
|
f"{base}/api/audio/start",
|
|
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
|
|
timeout=5,
|
|
)
|
|
assert start.status_code == 200, start.text
|
|
run = start.json()["status"]["audio_run"]
|
|
assert run["device_select"] == SNOWBALL
|
|
assert run["device"] == 2
|
|
|
|
status = requests.get(f"{base}/api/audio/status", timeout=5).json()["status"]
|
|
assert status["audio_run"]["device_select"] == SNOWBALL
|
|
|
|
|
|
def test_pulse_device_list_uses_stable_pulse_ids():
|
|
from util.pulse_audio_devices import list_pulse_matched_input_devices
|
|
|
|
devs = list_pulse_matched_input_devices()
|
|
if not devs:
|
|
pytest.skip("pactl not available")
|
|
snow = next((d for d in devs if "Snowball" in d.get("display_name", "")), None)
|
|
if snow is None:
|
|
pytest.skip("Blue Snowball not connected")
|
|
assert snow["id"] == snow["pulse_name"]
|
|
assert str(snow["id"]).startswith("alsa_input.")
|