optimize for latency

This commit is contained in:
pstruebi
2025-08-12 12:17:37 +02:00
parent dfe1f8073b
commit 42a47f2bb2
7 changed files with 333 additions and 8 deletions

View File

@@ -138,13 +138,28 @@ sudo systemctl status auracast-frontend
If you want to run the services as a specific user, edit the `User=` line in the service files accordingly.
# install port audio so it can see pipewire devices on raspian
# Setup the audio system
sudo apt remove -y libportaudio2 portaudio19-dev libportaudiocpp0
echo "y" | rpi-update stable
sudo apt update
sudo apt install --no-install-recommends \
# TODO: needed ?
sudo apt install pipewire wireplumber pipewire-audio-client-libraries rtkit
mkdir -p ~/.config/pipewire/pipewire.conf.d
cp src/service/pipewire/99-lowlatency.conf ~/.config/pipewire/pipewire.conf.d/
/etc/modprobe.d/usb-audio-lowlatency.conf
snd_usb_audio nrpacks=1
sudo apt install -y cpufrequtils
sudo cpufreq-set -g performance
sudo apt install -y --no-install-recommends \
git build-essential cmake pkg-config \
libasound2-dev libpulse-dev libjack-jackd2-dev
libasound2-dev libpulse-dev libjack-jackd2-dev jackd \
pipewire ethtool linuxptp
git clone https://github.com/PortAudio/portaudio.git
cd portaudio
@@ -152,9 +167,9 @@ git checkout 9abe5fe7db729280080a0bbc1397a528cd3ce658
rm -rf build
cmake -S . -B build -G"Unix Makefiles" \
-DBUILD_SHARED_LIBS=ON \
-DPA_USE_ALSA=ON \
-DPA_USE_PIPEWIRE=ON \
-DPA_USE_JACK=OFF
-DPA_USE_ALSA=OFF \
-DPA_USE_PULSEAUDIO=ON \
-DPA_USE_JACK=ON
cmake --build build -j$(nproc)
sudo cmake --install build # installs to /usr/local/lib
sudo ldconfig # refresh linker cache

View File

@@ -106,6 +106,88 @@ 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', '64'))
except Exception:
blocksize = 64
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'
# 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("input overflow")
# 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

@@ -0,0 +1,83 @@
import logging
import os
import asyncio
import aioconsole
from auracast import multicast
from auracast import auracast_config
from auracast.utils.sounddevice_utils import list_usb_pw_inputs, list_network_pw_inputs
if __name__ == "__main__":
logging.basicConfig( #export LOG_LEVEL=DEBUG
level=os.environ.get('LOG_LEVEL', logging.INFO),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
os.chdir(os.path.dirname(__file__))
# Hint PipeWire to use a very small processing quantum for this client
# (we will refine after SRATE is known). Defaults target ~0.330.67 ms.
os.environ.setdefault("PIPEWIRE_LATENCY", "16/48000")
# Lower PulseAudio-side buffering (PortAudio backend typically goes through PA)
os.environ.setdefault("PULSE_LATENCY_MSEC", "1")
# Request smaller PortAudio blocks via sounddevice shim
os.environ.setdefault("AURACAST_SD_BLOCKSIZE", "32")
# Accepts 'low'/'high'/'default' or seconds (float). Our shim parses number strings to float.
os.environ.setdefault("AURACAST_SD_LATENCY", "0.0015")
print("USB pw inputs:")
usb_inputs = list_usb_pw_inputs()
aes67_inputs = list_network_pw_inputs()
for i, d in usb_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
for i, d in aes67_inputs:
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
input_sel = usb_inputs[0][0]
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
# Capture at 48 kHz to avoid PipeWire resampler latency; encode LC3 at 24 kHz
CAPTURE_SRATE = 48000
LC3_SRATE = 24000
OCTETS_PER_FRAME=60
# After capture rate is known, constrain client buffers precisely relative to
# the active capture rate (so the client quantum matches the device rate).
try:
os.environ["PIPEWIRE_LATENCY"] = f"16/{CAPTURE_SRATE}"
except Exception:
pass
os.environ["PULSE_LATENCY_MSEC"] = os.environ.get("PULSE_LATENCY_MSEC", "1")
os.environ["AURACAST_SD_BLOCKSIZE"] = os.environ.get("AURACAST_SD_BLOCKSIZE", "32")
os.environ["AURACAST_SD_LATENCY"] = os.environ.get("AURACAST_SD_LATENCY", "0.0015")
config = auracast_config.AuracastConfigGroup(
bigs = [
auracast_config.AuracastBigConfig(
iso_que_len=1,
audio_source=f'device:{input_sel}',
input_format=f"int16le,{CAPTURE_SRATE},1",
sampling_frequency=LC3_SRATE,
octets_per_frame=OCTETS_PER_FRAME,
),
#auracast_config.AuracastBigConfigEng(),
]
)
config.qos_config=auracast_config.AuracastQosHigh()
config.transport=TRANSPORT1
# TODO: encrypted streams are not working
config.auracast_sampling_rate_hz = LC3_SRATE
config.octets_per_frame = OCTETS_PER_FRAME # 32kbps@16kHz
#config.debug = True
multicast.run_async(
multicast.broadcast(
config,
config.bigs
)
)

View File

@@ -309,7 +309,7 @@ else:
)
),
input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"),
iso_que_len=1, # TODO: this should be way less to decrease delay
iso_que_len=1,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
),

View File

@@ -0,0 +1,112 @@
import sounddevice as sd
import os, re, json, subprocess
def devices_by_backend(backend_name: str):
hostapis = sd.query_hostapis() # list of host APIs
# find the host API index by (case-insensitive) name match
try:
hostapi_idx = next(
i for i, ha in enumerate(hostapis)
if backend_name.lower() in ha['name'].lower()
)
except StopIteration:
raise ValueError(f"No host API matching {backend_name!r}. "
f"Available: {[ha['name'] for ha in hostapis]}")
# return (global_index, device_dict) pairs filtered by that host API
return [(i, d) for i, d in enumerate(sd.query_devices())
if d['hostapi'] == hostapi_idx]
def _pa_like_hostapi_index():
for i, ha in enumerate(sd.query_hostapis()):
if any(k in ha["name"] for k in ("PipeWire", "PulseAudio")):
return i
raise RuntimeError("PipeWire/PulseAudio host API not present in PortAudio.")
def _pw_dump():
return json.loads(subprocess.check_output(["pw-dump"]))
def _sd_matches_from_names(pa_idx, names):
names_l = {n.lower() for n in names if n}
out = []
for i, d in enumerate(sd.query_devices()):
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
continue
dn = d["name"].lower()
if any(n in dn for n in names_l):
out.append((i, d))
return out
def list_usb_pw_inputs():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
backed by **USB** devices (excludes monitor sources).
"""
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
# Map device.id -> device.bus ("usb"/"pci"/"platform"/"network"/...)
device_bus = {}
for obj in pw:
if obj.get("type") == "PipeWire:Interface:Device":
props = (obj.get("info") or {}).get("props") or {}
device_bus[obj["id"]] = (props.get("device.bus") or "").lower()
# Collect names/descriptions of USB input nodes
usb_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
continue
props = (obj.get("info") or {}).get("props") or {}
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
continue
# skip monitor sources ("Monitor of ..." or *.monitor)
nname = (props.get("node.name") or "").lower()
ndesc = (props.get("node.description") or "").lower()
if ".monitor" in nname or "monitor" in ndesc:
continue
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
if bus == "usb":
usb_input_names.add(props.get("node.description") or props.get("node.name"))
# Map to sounddevice devices on PipeWire host API
return _sd_matches_from_names(pa_idx, usb_input_names)
def list_network_pw_inputs():
"""
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
look like network/AES67/RTP sources (excludes monitor sources).
"""
pa_idx = _pa_like_hostapi_index()
pw = _pw_dump()
network_input_names = set()
for obj in pw:
if obj.get("type") != "PipeWire:Interface:Node":
continue
props = (obj.get("info") or {}).get("props") or {}
media = (props.get("media.class") or "").lower()
if "source" not in media and "stream/input" not in media:
continue
nname = (props.get("node.name") or "")
ndesc = (props.get("node.description") or "")
# skip monitor sources
if ".monitor" in nname.lower() or "monitor" in ndesc.lower():
continue
# Heuristics for network/AES67/RTP
text = (nname + " " + ndesc).lower()
is_network = (
(props.get("device.bus") or "").lower() == "network" or
any(k in text for k in ("rtp", "sap", "aes67", "network", "raop", "airplay"))
)
if is_network:
network_input_names.add(ndesc or nname)
return _sd_matches_from_names(pa_idx, network_input_names)
# Example usage:
# for i, d in list_usb_pw_inputs():
# print(f"USB IN {i}: {d['name']} in={d['max_input_channels']}")
# for i, d in list_network_pw_inputs():
# print(f"NET IN {i}: {d['name']} in={d['max_input_channels']}")

View File

@@ -1,7 +1,28 @@
import sounddevice as sd, pprint
from auracast.utils.sounddevice_utils import devices_by_backend, list_usb_pw_inputs, list_network_pw_inputs
print("PortAudio library:", sd._libname)
print("PortAudio version:", sd.get_portaudio_version())
print("\nHost APIs:")
pprint.pprint(sd.query_hostapis())
print("\nDevices:")
pprint.pprint(sd.query_devices())
pprint.pprint(sd.query_devices())
# Example: only PulseAudio devices on Linux
print("\nOnly PulseAudio devices:")
for i, d in devices_by_backend("PulseAudio"):
print(f"{i}: {d['name']} in={d['max_input_channels']} out={d['max_output_channels']}")
# Example: only PulseAudio devices on Linux
# print("\nOnly JACK devices:")
# for i, d in devices_by_backend("JACK"):
# print(f"{i}: {d['name']} in={d['max_input_channels']} out={d['max_output_channels']}")
print("Network pw inputs:")
for i, d in list_network_pw_inputs():
print(f"{i}: {d['name']} in={d['max_input_channels']}")
print("USB pw inputs:")
for i, d in list_usb_pw_inputs():
print(f"{i}: {d['name']} in={d['max_input_channels']}")

View File

@@ -0,0 +1,12 @@
context.properties = {
default.clock.rate = 48000
default.clock.allowed-rates = [ 48000 ]
default.clock.quantum = 64 # 64/48000 ≈ 1.33 ms
default.clock.min-quantum = 32
default.clock.max-quantum = 128
}
stream.properties = {
node.latency = "64/48000"
resample.quality = 0
}