optimize for latency
This commit is contained in:
27
README.md
27
README.md
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
83
src/auracast/multicast_script.py
Normal file
83
src/auracast/multicast_script.py
Normal 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.33–0.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
|
||||
)
|
||||
)
|
||||
@@ -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'],
|
||||
),
|
||||
|
||||
112
src/auracast/utils/sounddevice_utils.py
Normal file
112
src/auracast/utils/sounddevice_utils.py
Normal 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']}")
|
||||
@@ -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())
|
||||
|
||||
# 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']}")
|
||||
|
||||
12
src/service/pipewire/99-lowlatency.conf
Normal file
12
src/service/pipewire/99-lowlatency.conf
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user