932 lines
41 KiB
Python
932 lines
41 KiB
Python
"""Audio I/O manager — recording and playback via PyAudio.
|
|
|
|
Handles microphone capture, speaker playback, and speaker-monitor recording.
|
|
Thread-safe; one playback at a time via play_lock.
|
|
|
|
Device selection is dynamic — read from voice.audio_devices on each refresh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import wave
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
import numpy as np
|
|
_HAS_NUMPY = True
|
|
except ImportError:
|
|
np = None
|
|
_HAS_NUMPY = False
|
|
|
|
try:
|
|
import pyaudio
|
|
except ImportError:
|
|
pyaudio = None # optional — only needed for local PCM playback
|
|
|
|
# G1 AudioClient — used to route playback through the robot chest speaker
|
|
# via DDS `PlayStream` (the same pipe Gemini uses). Without this, WAV
|
|
# playback would go to the Jetson's built-in audio codec, which isn't
|
|
# wired to any audible output on the G1.
|
|
try:
|
|
from unitree_sdk2py.g1.audio.g1_audio_client import AudioClient
|
|
from unitree_sdk2py.g1.audio.g1_audio_api import (
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
)
|
|
_HAS_G1_AUDIO = True
|
|
except ImportError:
|
|
AudioClient = None
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY = 0
|
|
_HAS_G1_AUDIO = False
|
|
|
|
from Project.Sanad.config import (
|
|
CHANNELS,
|
|
CHUNK_SIZE,
|
|
RECEIVE_SAMPLE_RATE,
|
|
SINK as DEFAULT_SINK,
|
|
SOURCE as DEFAULT_SOURCE,
|
|
MONITOR_SOURCE,
|
|
)
|
|
from Project.Sanad.core.logger import get_logger
|
|
from Project.Sanad.voice import audio_devices as ad
|
|
|
|
log = get_logger("audio_manager")
|
|
|
|
FORMAT = pyaudio.paInt16 if pyaudio else 8
|
|
|
|
# Cached current selection — updated by refresh_devices()
|
|
_DEVICE_LOCK = threading.Lock()
|
|
_current_sink = DEFAULT_SINK
|
|
_current_source = DEFAULT_SOURCE
|
|
|
|
|
|
def _run_pactl(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(["pactl", *args], check=True, text=True, capture_output=True)
|
|
|
|
|
|
def _resolve_devices() -> tuple[str, str]:
|
|
"""Return current (sink, source) — falls back to config defaults."""
|
|
try:
|
|
cur = ad.current_selection()
|
|
sink = cur.get("sink") or DEFAULT_SINK
|
|
source = cur.get("source") or DEFAULT_SOURCE
|
|
return sink, source
|
|
except Exception as exc:
|
|
log.warning("Could not resolve audio devices: %s", exc)
|
|
return DEFAULT_SINK, DEFAULT_SOURCE
|
|
|
|
|
|
def ensure_audio_defaults():
|
|
"""Re-scan all USB ports, resolve the active profile, set pactl defaults.
|
|
|
|
This is called at startup AND before every playback/recording so that
|
|
even if the user unplugs/re-plugs a device into a different port, the
|
|
correct sink/source is always used.
|
|
"""
|
|
try:
|
|
result = ad.apply_current_selection()
|
|
cur = result.get("selection", {})
|
|
sink = cur.get("sink", "")
|
|
source = cur.get("source", "")
|
|
with _DEVICE_LOCK:
|
|
global _current_sink, _current_source
|
|
_current_sink = sink or DEFAULT_SINK
|
|
_current_source = source or DEFAULT_SOURCE
|
|
except Exception as exc:
|
|
log.warning("Audio defaults not applied: %s", exc)
|
|
|
|
|
|
class _PulseOpenFailed(RuntimeError):
|
|
"""Signal from `_play_pcm_via_pulse` that PortAudio refused to open the
|
|
output stream (sink gone, bad I/O combination, etc.) — lets `play_wav`
|
|
fall back to G1 DDS chest playback so the user still hears the clip."""
|
|
|
|
|
|
class AudioManager:
|
|
def __init__(self):
|
|
if pyaudio is None:
|
|
raise RuntimeError(
|
|
"pyaudio not installed — AudioManager cannot play local PCM. "
|
|
"Install with `pip install pyaudio` (needs portaudio headers), "
|
|
"or rely on the G1 speaker via AudioClient.PlayStream."
|
|
)
|
|
self.pya = pyaudio.PyAudio()
|
|
self.play_lock = threading.Lock()
|
|
# Lazily-initialised G1 DDS audio client (for play_wav → chest speaker)
|
|
self._g1_audio_client: Any = None
|
|
# G1 playback state — present during an active play_wav() call,
|
|
# None when idle. Mutated by pause_playback/resume_playback/stop_playback
|
|
# from other threads while _play_pcm_via_g1 holds play_lock.
|
|
self._play_state_lock = threading.Lock()
|
|
self._play_state: dict[str, Any] | None = None
|
|
# Resolve devices and set PulseAudio defaults at startup
|
|
self.refresh_devices()
|
|
ensure_audio_defaults()
|
|
|
|
def _get_g1_audio_client(self):
|
|
"""Return a cached G1 AudioClient (DDS) — creates on first use.
|
|
|
|
Assumes `ChannelFactoryInitialize` has already been called (our
|
|
ArmController does this at startup on eth0). Returns None if the
|
|
Unitree SDK is unavailable or init fails.
|
|
"""
|
|
if not _HAS_G1_AUDIO:
|
|
return None
|
|
if self._g1_audio_client is not None:
|
|
return self._g1_audio_client
|
|
try:
|
|
c = AudioClient()
|
|
c.SetTimeout(5.0)
|
|
c.Init()
|
|
try:
|
|
c.SetVolume(100)
|
|
except Exception:
|
|
pass
|
|
self._g1_audio_client = c
|
|
log.info("G1 AudioClient initialized (for chest-speaker playback)")
|
|
except Exception as exc:
|
|
log.warning("G1 AudioClient init failed: %s", exc)
|
|
self._g1_audio_client = None
|
|
return self._g1_audio_client
|
|
|
|
def refresh_devices(self) -> dict[str, str]:
|
|
"""Re-read selected sink/source from audio_devices module."""
|
|
sink, source = _resolve_devices()
|
|
with _DEVICE_LOCK:
|
|
global _current_sink, _current_source
|
|
_current_sink, _current_source = sink, source
|
|
log.info("AudioManager devices refreshed: sink=%s source=%s", sink, source)
|
|
return {"sink": sink, "source": source}
|
|
|
|
@property
|
|
def current_sink(self) -> str:
|
|
with _DEVICE_LOCK:
|
|
return _current_sink
|
|
|
|
@property
|
|
def current_source(self) -> str:
|
|
with _DEVICE_LOCK:
|
|
return _current_source
|
|
|
|
def close(self):
|
|
self.pya.terminate()
|
|
|
|
def sample_width(self) -> int:
|
|
return self.pya.get_sample_size(FORMAT)
|
|
|
|
# -- playback --
|
|
|
|
def play_pcm(self, pcm_bytes: bytes, channels: int, sample_rate: int, sample_width: int):
|
|
with self.play_lock:
|
|
ensure_audio_defaults()
|
|
stream = self.pya.open(
|
|
format=self.pya.get_format_from_width(sample_width),
|
|
channels=channels,
|
|
rate=sample_rate,
|
|
output=True,
|
|
frames_per_buffer=CHUNK_SIZE,
|
|
)
|
|
try:
|
|
frame_bytes = CHUNK_SIZE * channels * sample_width
|
|
for offset in range(0, len(pcm_bytes), frame_bytes):
|
|
stream.write(pcm_bytes[offset : offset + frame_bytes])
|
|
finally:
|
|
stream.stop_stream()
|
|
stream.close()
|
|
|
|
# Sink-name substrings that mean "PulseAudio routes this somewhere
|
|
# audible without DDS" — extend the tuple to add more USB cards (e.g.
|
|
# hollyland sink). Matched case-insensitively.
|
|
_PULSE_SINK_MARKERS = ("anker", "powerconf", "hollyland")
|
|
# Sample rate Anker PowerConf (and most USB UAC1 cards) accept natively
|
|
# — used as the resample target before opening a PortAudio stream so
|
|
# we don't hit paInvalidSampleRate when the WAV's native rate
|
|
# (24kHz from Gemini TTS, 22050 from old TTS, etc.) doesn't match
|
|
# the card's HW caps.
|
|
_PULSE_TARGET_RATE = 48_000
|
|
|
|
@staticmethod
|
|
def _resample_pcm16(pcm_bytes: bytes, channels: int,
|
|
src_rate: int, dst_rate: int) -> bytes:
|
|
"""Linear-interpolation resample of int16 PCM. numpy-only (no scipy)
|
|
— matches the pattern used by `_play_pcm_via_g1`.
|
|
|
|
Returns the resampled PCM bytes (same channel layout). No-op when
|
|
rates already match. Requires numpy (caller guards with _HAS_NUMPY).
|
|
"""
|
|
if src_rate == dst_rate or not pcm_bytes:
|
|
return pcm_bytes
|
|
arr = np.frombuffer(pcm_bytes, dtype=np.int16)
|
|
if channels > 1:
|
|
# De-interleave so each channel resamples independently
|
|
# (cheap on numpy; avoids stereo→mono surprises).
|
|
if arr.size % channels != 0:
|
|
arr = arr[: arr.size - (arr.size % channels)]
|
|
arr = arr.reshape(-1, channels)
|
|
n_in = arr.shape[0]
|
|
n_out = max(1, int(n_in * dst_rate / src_rate))
|
|
xp = np.arange(n_in, dtype=np.float64)
|
|
x_new = np.linspace(0, n_in, n_out, endpoint=False)
|
|
cols = [
|
|
np.interp(x_new, xp, arr[:, ch].astype(np.float64))
|
|
for ch in range(channels)
|
|
]
|
|
out = np.column_stack(cols).astype(np.int16)
|
|
return out.tobytes()
|
|
n_in = arr.size
|
|
n_out = max(1, int(n_in * dst_rate / src_rate))
|
|
out = np.interp(
|
|
np.linspace(0, n_in, n_out, endpoint=False),
|
|
np.arange(n_in, dtype=np.float64),
|
|
arr.astype(np.float64),
|
|
).astype(np.int16)
|
|
return out.tobytes()
|
|
|
|
def _active_sink_name(self) -> str:
|
|
"""Return the currently-tracked default sink name, ORIGINAL case
|
|
preserved.
|
|
|
|
Reads `self.current_sink` which is kept in lock-step with pactl
|
|
defaults by `refresh_devices()` (called by the dashboard Apply
|
|
endpoint and by the live-Gemini watcher on profile swaps). Empty
|
|
string if nothing's tracked yet.
|
|
|
|
IMPORTANT: PulseAudio sink names are CASE-SENSITIVE. paplay
|
|
--device=<name> needs the exact name pactl uses (e.g.
|
|
`alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo`).
|
|
Routing-decision substring checks (against `_PULSE_SINK_MARKERS`)
|
|
lowercase BOTH sides explicitly so the case-sensitivity of the
|
|
sink name doesn't break marker matching.
|
|
"""
|
|
try:
|
|
return (self.current_sink or "").strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
def play_wav(self, path: Path,
|
|
record_name: str | None = None) -> dict[str, Any]:
|
|
"""Play a WAV file through the speaker that matches the active
|
|
PulseAudio default sink:
|
|
|
|
• Default sink is a USB conference speaker (Anker PowerConf,
|
|
Hollyland, anything matching `_PULSE_SINK_MARKERS`) → write
|
|
via PyAudio → PortAudio 'pulse' device → PulseAudio default
|
|
sink. This works even when the user picked the device via
|
|
the dashboard's "Manual sink/source override" (no profile
|
|
id) — we key off the sink name, not the profile.
|
|
• Default sink is the Jetson platform-sound (or anything that
|
|
doesn't match a marker) → use G1 DDS (`AudioClient.PlayStream`)
|
|
because platform-sound isn't wired to any audible speaker on
|
|
the G1; only the DDS pipe reaches the chest loudspeaker.
|
|
|
|
`record_name` is purely a label surfaced via `playback_status()`
|
|
so the dashboard can show "Now playing: t6_1" etc.
|
|
"""
|
|
with wave.open(str(path), "rb") as wf:
|
|
channels = wf.getnchannels()
|
|
sw = wf.getsampwidth()
|
|
rate = wf.getframerate()
|
|
data = wf.readframes(wf.getnframes())
|
|
|
|
sink = self._active_sink_name()
|
|
sink_lc = sink.lower()
|
|
# Marker check is case-insensitive; the original `sink` (with case
|
|
# preserved) is what gets passed to paplay --device.
|
|
use_pulse = any(m in sink_lc for m in self._PULSE_SINK_MARKERS)
|
|
client = self._get_g1_audio_client() if not use_pulse else None
|
|
|
|
if not use_pulse and client is not None and _HAS_NUMPY and sw == 2:
|
|
log.info("play_wav route=g1_dds sink=%s record=%s",
|
|
sink or "?", record_name or "?")
|
|
self._play_pcm_via_g1(data, channels, rate, record_name=record_name)
|
|
route = "g1_dds"
|
|
else:
|
|
if not use_pulse and _HAS_G1_AUDIO and client is None:
|
|
log.warning("play_wav: non-PulseAudio sink but G1 AudioClient "
|
|
"unavailable — falling back to PulseAudio default")
|
|
# Prefer paplay subprocess when it's installed — bypasses
|
|
# PortAudio (which on this Jetson's conda env doesn't expose a
|
|
# 'pulse' device, leading to PyAudio defaulting to the silent
|
|
# Jetson platform-sound card). paplay routes through PulseAudio
|
|
# at the daemon level so audio actually reaches the Anker sink.
|
|
use_paplay = bool(self._paplay_binary())
|
|
try:
|
|
if use_paplay:
|
|
log.info("play_wav route=paplay sink=%s record=%s",
|
|
sink or "default", record_name or "?")
|
|
self._play_pcm_via_paplay(data, channels, rate, sw,
|
|
record_name=record_name)
|
|
route = "paplay"
|
|
else:
|
|
log.info("play_wav route=pulse sink=%s record=%s "
|
|
"(paplay not installed — using PyAudio)",
|
|
sink or "default", record_name or "?")
|
|
self._play_pcm_via_pulse(data, channels, rate, sw,
|
|
record_name=record_name)
|
|
route = "pulse"
|
|
except _PulseOpenFailed as exc:
|
|
# paplay spawn failed, USB device gone mid-flight, etc.
|
|
# Fall back to DDS chest if available so the user gets
|
|
# audio out of *something* rather than silence.
|
|
fb_client = self._get_g1_audio_client()
|
|
if fb_client is not None and _HAS_NUMPY and sw == 2:
|
|
log.warning("play_wav route=%s failed (%s); falling "
|
|
"back to g1_dds",
|
|
"paplay" if use_paplay else "pulse", exc)
|
|
self._play_pcm_via_g1(data, channels, rate,
|
|
record_name=record_name)
|
|
route = ("paplay" if use_paplay else "pulse") + "_failed_to_g1_dds"
|
|
else:
|
|
log.warning("play_wav pulse path failed (%s); no DDS "
|
|
"fallback available", exc)
|
|
route = ("paplay" if use_paplay else "pulse") + "_failed"
|
|
|
|
duration = len(data) / (rate * channels * sw) if rate else 0
|
|
return {"path": str(path), "duration_seconds": round(duration, 3),
|
|
"route": route, "sink": sink or "default"}
|
|
|
|
# -- G1 DDS-routed playback --
|
|
|
|
_G1_STREAM_APP = "sanad_playback"
|
|
_G1_HW_RATE = 16_000
|
|
|
|
def stop_playback(self) -> None:
|
|
"""Stop any in-flight G1 DDS audio stream + tear down the playback
|
|
state so a pause/resume cycle can't keep trying.
|
|
|
|
Used by the dashboard's Stop button. Safe to call even when
|
|
nothing is playing — the DDS call is idempotent.
|
|
"""
|
|
with self._play_state_lock:
|
|
if self._play_state is not None:
|
|
self._play_state["stop"] = True
|
|
client = self._get_g1_audio_client()
|
|
if client is None:
|
|
return
|
|
try:
|
|
client._Call(
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
json.dumps({"app_name": self._G1_STREAM_APP}),
|
|
)
|
|
log.info("G1 audio stream stopped (app=%s)", self._G1_STREAM_APP)
|
|
except Exception as exc:
|
|
log.warning("stop_playback failed: %s", exc)
|
|
|
|
def pause_playback(self) -> dict[str, Any]:
|
|
"""Pause the active G1 playback. The play loop notices the flag,
|
|
sends STOP_PLAY to halt the chest speaker, and advances the saved
|
|
position by the time elapsed since this chunk started. resume()
|
|
re-pushes from there. No-op if nothing is playing."""
|
|
with self._play_state_lock:
|
|
if self._play_state is None:
|
|
return {"ok": False, "reason": "nothing playing"}
|
|
if self._play_state["paused"]:
|
|
return {"ok": True, "already": True, "paused": True}
|
|
self._play_state["paused"] = True
|
|
log.info("Playback paused (record=%s)",
|
|
self._play_state.get("record_name") or "?")
|
|
return {"ok": True, "paused": True}
|
|
|
|
def resume_playback(self) -> dict[str, Any]:
|
|
"""Resume after a pause. The play loop re-pushes pcm[pos:] to G1
|
|
and re-enters the wait/poll cycle."""
|
|
with self._play_state_lock:
|
|
if self._play_state is None:
|
|
return {"ok": False, "reason": "nothing playing"}
|
|
if not self._play_state["paused"]:
|
|
return {"ok": True, "already": True, "paused": False}
|
|
self._play_state["paused"] = False
|
|
log.info("Playback resumed (record=%s)",
|
|
self._play_state.get("record_name") or "?")
|
|
return {"ok": True, "resumed": True}
|
|
|
|
def playback_status(self) -> dict[str, Any]:
|
|
"""Snapshot of the current playback for the dashboard. Returns
|
|
`playing=False` when idle. `position_sec` is best-effort —
|
|
derived from elapsed wall time since the last PlayStream call."""
|
|
with self._play_state_lock:
|
|
if self._play_state is None:
|
|
return {"playing": False, "paused": False, "record_name": None,
|
|
"position_sec": 0.0, "duration_sec": 0.0}
|
|
rate = self._play_state["rate"] or 1
|
|
total = self._play_state["total_samples"]
|
|
pos = self._play_state["pos"]
|
|
if (not self._play_state["paused"]
|
|
and self._play_state["play_started_at"] > 0):
|
|
elapsed = time.time() - self._play_state["play_started_at"]
|
|
advance = int(max(0.0, elapsed) * rate)
|
|
pos = min(self._play_state["play_started_pos"] + advance, total)
|
|
return {
|
|
"playing": True,
|
|
"paused": self._play_state["paused"],
|
|
"record_name": self._play_state.get("record_name"),
|
|
"position_sec": round(pos / rate, 2),
|
|
"duration_sec": round(total / rate, 2) if rate else 0.0,
|
|
}
|
|
|
|
def _play_pcm_via_g1(self, pcm_bytes: bytes, channels: int,
|
|
source_rate: int,
|
|
record_name: str | None = None) -> None:
|
|
"""Stream int16 PCM to the G1 chest speaker via AudioClient.PlayStream,
|
|
with pause / resume / stop support.
|
|
|
|
Converts stereo → mono and resamples to 16 kHz (the rate
|
|
AudioClient expects). The play loop pushes pcm[pos:] in one
|
|
PlayStream call, then polls _play_state every 50 ms while the
|
|
clip drains so pause / stop are honoured promptly. Pause sends
|
|
STOP_PLAY, snapshots the position from elapsed wall time, then
|
|
loops until resumed or stopped. Resume re-pushes pcm[pos:].
|
|
"""
|
|
client = self._get_g1_audio_client()
|
|
if client is None:
|
|
raise RuntimeError("G1 AudioClient not available")
|
|
|
|
arr = np.frombuffer(pcm_bytes, dtype=np.int16)
|
|
if channels == 2 and arr.size % 2 == 0:
|
|
arr = arr.reshape(-1, 2).mean(axis=1).astype(np.int16)
|
|
if source_rate != self._G1_HW_RATE and arr.size:
|
|
target_len = max(1, int(len(arr) * self._G1_HW_RATE / source_rate))
|
|
arr = np.interp(
|
|
np.linspace(0, len(arr), target_len, endpoint=False),
|
|
np.arange(len(arr)),
|
|
arr.astype(np.float64),
|
|
).astype(np.int16)
|
|
rate = self._G1_HW_RATE
|
|
total_samples = len(arr)
|
|
|
|
with self._play_state_lock:
|
|
self._play_state = {
|
|
"record_name": record_name,
|
|
"rate": rate,
|
|
"total_samples": total_samples,
|
|
"pos": 0,
|
|
"paused": False,
|
|
"stop": False,
|
|
"play_started_at": 0.0,
|
|
"play_started_pos": 0,
|
|
}
|
|
|
|
# play_lock serialises overlapping play_wav() calls; pause/resume/stop
|
|
# do NOT take it (they only touch _play_state under _play_state_lock).
|
|
with self.play_lock:
|
|
try:
|
|
while True:
|
|
# Snapshot the state for this iteration
|
|
with self._play_state_lock:
|
|
st = self._play_state
|
|
if st is None or st["stop"]:
|
|
break
|
|
if st["paused"]:
|
|
paused_now = True
|
|
sub_bytes = None
|
|
sub_total_sec = 0.0
|
|
else:
|
|
paused_now = False
|
|
pos = st["pos"]
|
|
if pos >= total_samples:
|
|
break
|
|
sub_bytes = arr[pos:].tobytes()
|
|
sub_total_sec = (total_samples - pos) / rate
|
|
st["play_started_pos"] = pos
|
|
st["play_started_at"] = time.time()
|
|
|
|
if paused_now:
|
|
time.sleep(0.1)
|
|
continue
|
|
|
|
# Push remainder to G1
|
|
stream_id = f"wav_{int(time.time() * 1000)}"
|
|
try:
|
|
client._Call(
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
json.dumps({"app_name": self._G1_STREAM_APP}),
|
|
)
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.15)
|
|
client.PlayStream(self._G1_STREAM_APP, stream_id, sub_bytes)
|
|
|
|
# Poll for pause / stop while the clip drains
|
|
poll_deadline = time.time() + sub_total_sec + 0.3
|
|
interrupted = False
|
|
while time.time() < poll_deadline:
|
|
with self._play_state_lock:
|
|
if self._play_state is None or self._play_state["stop"]:
|
|
interrupted = True
|
|
try:
|
|
client._Call(
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
json.dumps({"app_name": self._G1_STREAM_APP}),
|
|
)
|
|
except Exception:
|
|
pass
|
|
break
|
|
if self._play_state["paused"]:
|
|
# Halt G1 and snapshot the new position
|
|
try:
|
|
client._Call(
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
json.dumps({"app_name": self._G1_STREAM_APP}),
|
|
)
|
|
except Exception:
|
|
pass
|
|
elapsed = (time.time()
|
|
- self._play_state["play_started_at"])
|
|
advance = int(max(0.0, elapsed) * rate)
|
|
self._play_state["pos"] = min(
|
|
self._play_state["play_started_pos"] + advance,
|
|
total_samples,
|
|
)
|
|
interrupted = True
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
if not interrupted:
|
|
# Finished naturally — mark fully consumed and exit
|
|
with self._play_state_lock:
|
|
if self._play_state is not None:
|
|
self._play_state["pos"] = total_samples
|
|
try:
|
|
client._Call(
|
|
ROBOT_API_ID_AUDIO_STOP_PLAY,
|
|
json.dumps({"app_name": self._G1_STREAM_APP}),
|
|
)
|
|
except Exception:
|
|
pass
|
|
break
|
|
finally:
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
|
|
# paplay binary path. Cached on first probe so we don't keep re-shelling
|
|
# `which paplay` on every play_wav call. None = probe pending; "" = absent.
|
|
_PAPLAY_BIN: str | None = None
|
|
|
|
@classmethod
|
|
def _paplay_binary(cls) -> str:
|
|
"""Return the absolute path to `paplay` if installed, else "".
|
|
Cached for the lifetime of the process — paplay doesn't appear/
|
|
disappear mid-run."""
|
|
if cls._PAPLAY_BIN is None:
|
|
from shutil import which
|
|
cls._PAPLAY_BIN = which("paplay") or ""
|
|
return cls._PAPLAY_BIN
|
|
|
|
def _play_pcm_via_paplay(self, pcm_bytes: bytes, channels: int,
|
|
sample_rate: int, sample_width: int,
|
|
record_name: str | None = None) -> None:
|
|
"""Play int16 PCM via the `paplay` subprocess. Bypasses PortAudio
|
|
entirely — we just pipe raw PCM into paplay's stdin and let
|
|
PulseAudio do the resampling/format conversion/device routing.
|
|
|
|
Why this exists: on conda's bundled PyAudio (the build shipped in
|
|
the gemini_sdk env on this Jetson), PortAudio does NOT enumerate a
|
|
'pulse' device — only direct ALSA hw:N entries. Opening
|
|
`output_device_index=None` then defaults to hw:0 which is the
|
|
Jetson `platform-sound` card → silent (not wired to any speaker).
|
|
Opening a discrete `hw:N` for the Anker grabs the card exclusively
|
|
and PulseAudio drops it. Neither path actually plays through the
|
|
Anker. paplay sidesteps the whole stack.
|
|
|
|
Targets the dashboard's currently-selected sink by name via
|
|
`--device=<sink>`, which guarantees the audio goes to the same
|
|
place pactl set-default-sink would have routed.
|
|
|
|
Reuses the same `_play_state` machinery as the DDS path so the
|
|
dashboard's Pause / Stop / position-meter behave identically.
|
|
"""
|
|
sink_name = self._active_sink_name()
|
|
bytes_per_sample = max(1, channels * sample_width)
|
|
total_bytes = len(pcm_bytes) - (len(pcm_bytes) % bytes_per_sample)
|
|
total_samples = total_bytes // bytes_per_sample
|
|
chunk_bytes = max(
|
|
bytes_per_sample, (sample_rate // 10) * bytes_per_sample,
|
|
)
|
|
# paplay format codes: s16le is the only one we ever produce here.
|
|
fmt = "s16le" if sample_width == 2 else \
|
|
"s32le" if sample_width == 4 else \
|
|
"u8"
|
|
# Keep cmd minimal — older paplay versions reject unknown long
|
|
# options and exit immediately (manifests as instant paplay death +
|
|
# a flood of BrokenPipeError on stdin write). --raw / --format /
|
|
# --rate / --channels / --device are all standard since 0.9.x.
|
|
cmd = [
|
|
self._paplay_binary(), "--raw",
|
|
f"--format={fmt}", f"--rate={sample_rate}",
|
|
f"--channels={channels}",
|
|
]
|
|
if sink_name:
|
|
cmd.extend(["--device", sink_name])
|
|
|
|
with self._play_state_lock:
|
|
self._play_state = {
|
|
"record_name": record_name,
|
|
"rate": sample_rate,
|
|
"total_samples": total_samples,
|
|
"pos": 0,
|
|
"paused": False,
|
|
"stop": False,
|
|
"play_started_at": 0.0,
|
|
"play_started_pos": 0,
|
|
}
|
|
|
|
with self.play_lock:
|
|
try:
|
|
while True:
|
|
with self._play_state_lock:
|
|
st = self._play_state
|
|
if st is None or st["stop"]:
|
|
break
|
|
if st["paused"]:
|
|
time.sleep(0.1)
|
|
continue
|
|
pos = st["pos"]
|
|
if pos >= total_samples:
|
|
break
|
|
st["play_started_pos"] = pos
|
|
st["play_started_at"] = time.time()
|
|
|
|
byte_pos = pos * bytes_per_sample
|
|
local_pos = pos
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd, stdin=subprocess.PIPE,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
)
|
|
except Exception as exc:
|
|
log.warning("paplay spawn failed (%s) — signalling "
|
|
"DDS fallback", exc)
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
raise _PulseOpenFailed(str(exc)) from exc
|
|
|
|
# Brief settle so paplay can validate args + connect to
|
|
# PulseAudio. If it's going to die (bad sink, format,
|
|
# connection refused), it dies within ~50ms. Without
|
|
# this check, the next stdin.write() would get a sea
|
|
# of BrokenPipeError messages and the outer loop would
|
|
# keep re-spawning forever.
|
|
time.sleep(0.05)
|
|
if proc.poll() is not None:
|
|
try:
|
|
err = (proc.stderr.read() or b"").decode(
|
|
"utf-8", "replace").strip()[:400]
|
|
except Exception:
|
|
err = ""
|
|
log.warning("paplay died immediately rc=%d device=%s err=%s",
|
|
proc.returncode, sink_name or "default", err)
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
raise _PulseOpenFailed(
|
|
f"paplay rc={proc.returncode} {err or 'no stderr'}"
|
|
)
|
|
|
|
interrupted = False
|
|
fatal_exc: Exception | None = None
|
|
try:
|
|
while byte_pos < total_bytes:
|
|
with self._play_state_lock:
|
|
ps = self._play_state
|
|
if ps is None or ps["stop"]:
|
|
interrupted = True
|
|
break
|
|
if ps["paused"]:
|
|
ps["pos"] = local_pos
|
|
interrupted = True
|
|
break
|
|
end = min(byte_pos + chunk_bytes, total_bytes)
|
|
try:
|
|
proc.stdin.write(pcm_bytes[byte_pos:end])
|
|
proc.stdin.flush()
|
|
except (BrokenPipeError, OSError) as exc:
|
|
# paplay died mid-stream (USB unplugged,
|
|
# PulseAudio crashed, etc.). Abort entire
|
|
# clip — DO NOT let the outer loop respawn
|
|
# paplay; we just got hundreds of
|
|
# broken-pipe lines as a result of that bug.
|
|
try:
|
|
err = (proc.stderr.read() or b"").decode(
|
|
"utf-8", "replace").strip()[:400]
|
|
except Exception:
|
|
err = ""
|
|
log.warning("paplay died mid-stream (%s) "
|
|
"device=%s stderr=%s",
|
|
exc, sink_name or "default", err)
|
|
fatal_exc = _PulseOpenFailed(
|
|
f"paplay died: {err or exc}")
|
|
break
|
|
byte_pos = end
|
|
local_pos = byte_pos // bytes_per_sample
|
|
finally:
|
|
try:
|
|
proc.stdin.close()
|
|
except Exception:
|
|
pass
|
|
if interrupted or fatal_exc is not None:
|
|
proc.terminate()
|
|
try:
|
|
rc = proc.wait(timeout=3.0)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
rc = -1
|
|
if rc != 0 and not interrupted and fatal_exc is None:
|
|
# Drained successfully but paplay exited non-zero
|
|
# — surface stderr so the failure isn't silent.
|
|
try:
|
|
err = (proc.stderr.read() or b"").decode(
|
|
"utf-8", "replace").strip()[:300]
|
|
except Exception:
|
|
err = ""
|
|
log.warning("paplay exit rc=%d device=%s err=%s",
|
|
rc, sink_name or "default", err)
|
|
|
|
if fatal_exc is not None:
|
|
# Re-raise OUTSIDE the inner try/finally so play_wav
|
|
# catches it and falls back to G1 DDS chest. Without
|
|
# this, the outer `while True` loop would respawn
|
|
# paplay and we'd loop indefinitely.
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
raise fatal_exc
|
|
|
|
if not interrupted:
|
|
with self._play_state_lock:
|
|
if self._play_state is not None:
|
|
self._play_state["pos"] = total_samples
|
|
break
|
|
finally:
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
|
|
def _play_pcm_via_pulse(self, pcm_bytes: bytes, channels: int,
|
|
sample_rate: int, sample_width: int,
|
|
record_name: str | None = None) -> None:
|
|
"""Play int16 PCM via PyAudio (→ PulseAudio default sink) with
|
|
pause / resume / stop support.
|
|
|
|
Mirrors `_play_pcm_via_g1`'s state-poll pattern so the dashboard's
|
|
Play / Pause / Stop / Position buttons behave identically whether
|
|
the active profile uses DDS or PyAudio. Writes ~100 ms chunks so
|
|
pause / stop latency is bounded.
|
|
"""
|
|
# Make sure pactl defaults reflect the current selection — this is
|
|
# a no-op when the watcher or dashboard Apply already aligned them.
|
|
ensure_audio_defaults()
|
|
|
|
# Resample to a USB-native rate before opening the stream.
|
|
# PortAudio's ALSA backend (the one PyAudio uses) opens the underlying
|
|
# hardware via the ALSA 'pulse' plugin, which on this Jetson does
|
|
# NOT advertise rate conversion in `snd_pcm_hw_params` — so opening
|
|
# at the WAV's native rate (24kHz from Gemini TTS, etc.) gets
|
|
# rejected with paInvalidSampleRate. Resampling app-side mirrors
|
|
# what `_play_pcm_via_g1` already does for the DDS path. Anker
|
|
# PowerConf and most USB UAC1 cards report 48kHz s16le stereo
|
|
# natively, so target that.
|
|
if _HAS_NUMPY and sample_width == 2 and sample_rate != self._PULSE_TARGET_RATE:
|
|
try:
|
|
pcm_bytes = self._resample_pcm16(
|
|
pcm_bytes, channels, sample_rate, self._PULSE_TARGET_RATE,
|
|
)
|
|
log.info("_play_pcm_via_pulse: resampled %dHz → %dHz "
|
|
"(USB card native rate)",
|
|
sample_rate, self._PULSE_TARGET_RATE)
|
|
sample_rate = self._PULSE_TARGET_RATE
|
|
except Exception as exc:
|
|
log.warning("_play_pcm_via_pulse: resample failed (%s) — "
|
|
"trying native rate, may hit paInvalidSampleRate",
|
|
exc)
|
|
|
|
bytes_per_sample = max(1, channels * sample_width)
|
|
total_bytes = len(pcm_bytes) - (len(pcm_bytes) % bytes_per_sample)
|
|
total_samples = total_bytes // bytes_per_sample
|
|
chunk_bytes = max(bytes_per_sample, (sample_rate // 10) * bytes_per_sample)
|
|
|
|
with self._play_state_lock:
|
|
self._play_state = {
|
|
"record_name": record_name,
|
|
"rate": sample_rate,
|
|
"total_samples": total_samples,
|
|
"pos": 0,
|
|
"paused": False,
|
|
"stop": False,
|
|
"play_started_at": 0.0,
|
|
"play_started_pos": 0,
|
|
}
|
|
|
|
# play_lock serialises overlapping play_wav() calls; pause/resume/stop
|
|
# only touch _play_state under _play_state_lock so they don't block.
|
|
with self.play_lock:
|
|
try:
|
|
while True:
|
|
# Snapshot — decide whether to play, wait, or exit
|
|
with self._play_state_lock:
|
|
st = self._play_state
|
|
if st is None or st["stop"]:
|
|
break
|
|
if st["paused"]:
|
|
paused_now = True
|
|
pos = 0
|
|
else:
|
|
paused_now = False
|
|
pos = st["pos"]
|
|
if pos >= total_samples:
|
|
break
|
|
st["play_started_pos"] = pos
|
|
st["play_started_at"] = time.time()
|
|
if paused_now:
|
|
time.sleep(0.1)
|
|
continue
|
|
|
|
byte_pos = pos * bytes_per_sample
|
|
local_pos = pos
|
|
try:
|
|
stream = self.pya.open(
|
|
format=self.pya.get_format_from_width(sample_width),
|
|
channels=channels,
|
|
rate=sample_rate,
|
|
output=True,
|
|
frames_per_buffer=CHUNK_SIZE,
|
|
)
|
|
except Exception as exc:
|
|
# PortAudio open failed (sink gone, paBadIODevice
|
|
# combination, etc.). Signal the caller so play_wav
|
|
# can fall back to DDS chest rather than silently
|
|
# dropping the clip.
|
|
log.warning("Pulse playback open failed: %s — "
|
|
"signalling caller for DDS fallback", exc)
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
raise _PulseOpenFailed(str(exc)) from exc
|
|
interrupted = False
|
|
try:
|
|
while byte_pos < total_bytes:
|
|
with self._play_state_lock:
|
|
ps = self._play_state
|
|
if ps is None or ps["stop"]:
|
|
interrupted = True
|
|
break
|
|
if ps["paused"]:
|
|
ps["pos"] = local_pos
|
|
interrupted = True
|
|
break
|
|
end = min(byte_pos + chunk_bytes, total_bytes)
|
|
try:
|
|
stream.write(pcm_bytes[byte_pos:end])
|
|
except Exception as exc:
|
|
log.warning("Pulse playback write failed: %s", exc)
|
|
interrupted = True
|
|
break
|
|
byte_pos = end
|
|
local_pos = byte_pos // bytes_per_sample
|
|
finally:
|
|
try:
|
|
stream.stop_stream()
|
|
stream.close()
|
|
except Exception:
|
|
pass
|
|
|
|
if not interrupted:
|
|
with self._play_state_lock:
|
|
if self._play_state is not None:
|
|
self._play_state["pos"] = total_samples
|
|
break
|
|
# Interrupted by pause → outer loop will wait for resume
|
|
# or exit on stop. Interrupted by stop → outer loop exits.
|
|
finally:
|
|
with self._play_state_lock:
|
|
self._play_state = None
|
|
|
|
# -- recording --
|
|
|
|
def record_mic(self, duration_sec: float) -> bytes:
|
|
"""Record from default mic for *duration_sec* seconds, return raw PCM."""
|
|
ensure_audio_defaults()
|
|
stream = self.pya.open(
|
|
format=FORMAT,
|
|
channels=CHANNELS,
|
|
rate=RECEIVE_SAMPLE_RATE,
|
|
input=True,
|
|
frames_per_buffer=CHUNK_SIZE,
|
|
)
|
|
frames: list[bytes] = []
|
|
total_chunks = int(RECEIVE_SAMPLE_RATE / CHUNK_SIZE * duration_sec)
|
|
try:
|
|
for _ in range(total_chunks):
|
|
frames.append(stream.read(CHUNK_SIZE, exception_on_overflow=False))
|
|
finally:
|
|
stream.stop_stream()
|
|
stream.close()
|
|
return b"".join(frames)
|
|
|
|
def save_wav(self, pcm_bytes: bytes, path: Path, channels: int, sample_rate: int):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with wave.open(str(path), "wb") as wf:
|
|
wf.setnchannels(channels)
|
|
wf.setsampwidth(self.sample_width())
|
|
wf.setframerate(sample_rate)
|
|
wf.writeframes(pcm_bytes)
|