refactor: standardize audio latency to 3ms across PipeWire and AES67 stacks

This commit is contained in:
pstruebi
2025-08-27 11:57:56 +02:00
parent 49ceb3e597
commit 09e8d69614
5 changed files with 36 additions and 129 deletions

View File

@@ -106,103 +106,6 @@ audio_io.WaveAudioInput = ModWaveAudioInput
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Low-latency SoundDevice input shim
# -----------------------------------------------------------------------------
class ModSoundDeviceAudioInput(audio_io.ThreadedAudioInput):
"""Audio input using sounddevice with explicit low-latency settings.
This mirrors bumble.audio.io.SoundDeviceAudioInput but requests a small
PortAudio blocksize and low latency to reduce buffering on PipeWire/ALSA.
Tunables via env:
- AURACAST_SD_BLOCKSIZE (default 64)
- AURACAST_SD_LATENCY (default 'low', can be 'default', 'high' or seconds)
"""
def __init__(self, device_name: str, pcm_format: audio_io.PcmFormat) -> None:
super().__init__()
self._device = int(device_name) if device_name else None
self._pcm_format = pcm_format
self._stream = None
def _open(self) -> audio_io.PcmFormat:
import os
import sounddevice # type: ignore
# Read tunables
try:
blocksize = int(os.environ.get('AURACAST_SD_BLOCKSIZE', '128'))
except Exception:
blocksize = 128
raw_latency = os.environ.get('AURACAST_SD_LATENCY', 'low')
if raw_latency in ('low', 'high', 'default'):
latency: typing.Union[str, float] = raw_latency
else:
try:
latency = float(raw_latency)
except Exception:
latency = 'low'
# Log the chosen parameters and device info
try:
dev_info = sounddevice.query_devices(self._device, 'input') if self._device is not None else sounddevice.query_devices(kind='input')
except Exception:
dev_info = {}
logging.info(
"SoundDevice RawInputStream: rate=%s ch=%s blocksize=%s latency=%s device=%s hostapi=%s",
self._pcm_format.sample_rate,
self._pcm_format.channels,
blocksize,
latency,
dev_info.get('name', self._device),
dev_info.get('hostapi', 'unknown'),
)
# Create the raw input stream with tighter buffering
self._stream = sounddevice.RawInputStream(
samplerate=self._pcm_format.sample_rate,
device=self._device,
channels=self._pcm_format.channels,
dtype='int16',
blocksize=blocksize,
latency=latency,
)
self._stream.start()
# Report stereo output format to match Bumble's original behavior
return audio_io.PcmFormat(
audio_io.PcmFormat.Endianness.LITTLE,
audio_io.PcmFormat.SampleType.INT16,
self._pcm_format.sample_rate,
2,
)
def _read(self, frame_size: int) -> bytes:
if not self._stream:
return b''
pcm_buffer, overflowed = self._stream.read(frame_size)
if overflowed:
logging.warning("SoundDevice input overflow: frame_size=%s", frame_size)
# Duplicate mono to stereo for downstream expectations
if self._pcm_format.channels == 1:
stereo_buffer = bytearray()
for i in range(frame_size):
sample = pcm_buffer[i * 2 : i * 2 + 2]
stereo_buffer += sample + sample
return bytes(stereo_buffer)
return bytes(pcm_buffer)
def _close(self):
if self._stream:
self._stream.stop()
self._stream = None
# Replace Bumble's default with the low-latency variant
audio_io.SoundDeviceAudioInput = ModSoundDeviceAudioInput
@contextlib.asynccontextmanager
async def create_device(config: auracast_config.AuracastGlobalConfig) -> AsyncGenerator[bumble.device.Device, Any]:
async with await bumble.transport.open_transport(config.transport) as (

View File

@@ -27,18 +27,8 @@ Environment variables
- LANGUATE: ISO 639-3 language code used by config (intentional key name).
Default: "deu".
- AURACAST_SD_BLOCKSIZE: Hint for PortAudio/PipeWire block size in frames.
Default: 128.
- AURACAST_SD_LATENCY: PortAudio latency hint in seconds.
Default: 0.0027 (~128/48000 s).
- PULSE_LATENCY_MSEC: Pulse/PipeWire latency hint in milliseconds.
Default: 1.
- PIPEWIRE_LATENCY: PipeWire latency hint in the form "<frames>/<rate>".
Default is initially set to "128/48000" and then overwritten at runtime to
"128/<CAPTURE_SRATE>" based on the capture rate used by this script.
Default: 3.
Examples (.env)
---------------
@@ -67,12 +57,7 @@ if __name__ == "__main__":
# Load .env located next to this script (only uppercase keys will be referenced)
load_dotenv(dotenv_path='.env')
# Minimal setting to request PipeWire quantum ~128 at 48 kHz via PortAudio
os.environ.setdefault("AURACAST_SD_BLOCKSIZE", "128")
# Also hint PortAudio/Pulse and PipeWire about small buffers
os.environ.setdefault("AURACAST_SD_LATENCY", "0.0027") # ~128/48000 s
os.environ.setdefault("PULSE_LATENCY_MSEC", "1")
os.environ.setdefault("PIPEWIRE_LATENCY", "128/48000")
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
usb_inputs = list_usb_pw_inputs()
logging.info("USB pw inputs:")
@@ -92,6 +77,7 @@ if __name__ == "__main__":
input_mode = (parts[0] or 'usb').lower()
iface_substr = (parts[1].lower() if len(parts) > 1 and parts[1] else None)
selected_dev = None
if input_mode == 'aes67':
if not aes67_inputs and not iface_substr:
# No AES67 inputs and no specific target -> fail fast
@@ -103,17 +89,18 @@ if __name__ == "__main__":
sel = next(((i, d) for i, d in current if iface_substr in (d.get('name','').lower())), None)
if sel:
input_sel = sel[0]
selected_dev = sel[1]
logging.info(f"Selected AES67 input by match '{iface_substr}': index={input_sel}")
break
logging.info(f"Waiting for AES67 input matching '{iface_substr}'... retrying in 2s")
time.sleep(2)
else:
input_sel = aes67_inputs[0][0]
logging.info(f"Selected first AES67 input: index={input_sel}, device={aes67_inputs[0][1]['name']}")
input_sel, selected_dev = aes67_inputs[0]
logging.info(f"Selected first AES67 input: index={input_sel}, device={selected_dev['name']}")
else:
if usb_inputs:
input_sel = usb_inputs[0][0]
logging.info(f"Selected first USB input: index={input_sel}, device={usb_inputs[0][1]['name']}")
input_sel, selected_dev = usb_inputs[0]
logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}")
else:
raise RuntimeError("No USB audio inputs found.")
@@ -124,18 +111,19 @@ if __name__ == "__main__":
LC3_SRATE = 24000
OCTETS_PER_FRAME=60
# Ensure PipeWire latency hint reflects the actual capture rate
try:
os.environ["PIPEWIRE_LATENCY"] = f"128/{CAPTURE_SRATE}"
except Exception:
pass
# Read uppercase-only settings from environment/.env
broadcast_name = os.environ.get('BROADCAST_NAME', 'Broadcast0')
program_info = os.environ.get('PROGRAM_INFO', 'Some Announcements')
# Note: 'LANGUATE' (typo) is intentionally used as requested, maps to config.language
language = os.environ.get('LANGUATE', 'deu')
# Determine capture channel count based on selected device (prefer up to 2)
try:
max_in = int((selected_dev or {}).get('max_input_channels', 1))
except Exception:
max_in = 1
channels = max(1, min(2, max_in))
config = auracast_config.AuracastConfigGroup(
bigs = [
auracast_config.AuracastBigConfig(
@@ -144,7 +132,7 @@ if __name__ == "__main__":
language=language,
iso_que_len=1,
audio_source=f'device:{input_sel}',
input_format=f"int16le,{CAPTURE_SRATE},1",
input_format=f"int16le,{CAPTURE_SRATE},{channels}",
sampling_frequency=LC3_SRATE,
octets_per_frame=OCTETS_PER_FRAME,
),

View File

@@ -10,6 +10,11 @@
context.properties = {
## Configure properties in the system.
default.clock.rate = 48000
default.clock.allowed-rates = [ 48000 ]
# Enforce 3ms quantum on this AES67 PipeWire instance
clock.force-quantum = 144
default.clock.quantum = 144
#mem.warn-mlock = false
#mem.allow-mlock = true
#mem.mlock-all = false
@@ -87,7 +92,8 @@ context.modules = [
media.class = "Audio/Source"
device.api = aes67
# You can adjust the latency buffering here. Use integer values only
sess.latency.msec = 5
sess.latency.msec = 6
node.latency = "144/48000"
node.group = pipewire.ptp0
}
}

View File

@@ -1,12 +1,13 @@
context.properties = {
default.clock.rate = 48000
default.clock.allowed-rates = [ 48000 ]
default.clock.quantum = 64 # 64/48000 ≈ 1.33 ms
default.clock.quantum = 144 # 144/48000 = 3.0 ms
default.clock.min-quantum = 32
default.clock.max-quantum = 128
default.clock.max-quantum = 256
}
stream.properties = {
node.latency = "64/48000"
# Prefer to let specific nodes (e.g. AES67) or clients set node.latency.
node.latency = "144/48000"
resample.quality = 0
}

View File

@@ -11,6 +11,10 @@ sudo cp /home/caster/bumble-auracast/src/service/ptp_aes67.service /etc/systemd/
mkdir -p /home/caster/.config/systemd/user
cp /home/caster/bumble-auracast/src/service/pipewire-aes67.service /home/caster/.config/systemd/user/pipewire-aes67.service
# Install PipeWire user config to persist 3ms@48kHz (default.clock.quantum=144)
mkdir -p /home/caster/.config/pipewire/pipewire.conf.d
cp /home/caster/bumble-auracast/src/service/pipewire/99-lowlatency.conf /home/caster/.config/pipewire/pipewire.conf.d/99-lowlatency.conf
# Reload systemd to recognize new/updated services
sudo systemctl daemon-reload
systemctl --user daemon-reload
@@ -20,13 +24,18 @@ sudo systemctl enable ptp_aes67.service
systemctl --user enable pipewire-aes67.service
# Restart services
systemctl --user restart pipewire.service pipewire-pulse.service
sudo systemctl restart ptp_aes67.service
systemctl --user restart pipewire-aes67.service
echo "\n--- pipewire.service status (user) ---"
systemctl --user status pipewire.service --no-pager
echo "\n--- ptp_aes67.service status ---"
sudo systemctl status ptp_aes67.service --no-pager
echo "\n--- pipewire-aes67.service status (user) ---"
systemctl --user status pipewire-aes67.service --no-pager
echo "AES67 services updated, enabled, restarted, and status printed successfully."