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.
|
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
|
sudo apt remove -y libportaudio2 portaudio19-dev libportaudiocpp0
|
||||||
|
echo "y" | rpi-update stable
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
sudo apt update
|
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 \
|
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
|
git clone https://github.com/PortAudio/portaudio.git
|
||||||
cd portaudio
|
cd portaudio
|
||||||
@@ -152,9 +167,9 @@ git checkout 9abe5fe7db729280080a0bbc1397a528cd3ce658
|
|||||||
rm -rf build
|
rm -rf build
|
||||||
cmake -S . -B build -G"Unix Makefiles" \
|
cmake -S . -B build -G"Unix Makefiles" \
|
||||||
-DBUILD_SHARED_LIBS=ON \
|
-DBUILD_SHARED_LIBS=ON \
|
||||||
-DPA_USE_ALSA=ON \
|
-DPA_USE_ALSA=OFF \
|
||||||
-DPA_USE_PIPEWIRE=ON \
|
-DPA_USE_PULSEAUDIO=ON \
|
||||||
-DPA_USE_JACK=OFF
|
-DPA_USE_JACK=ON
|
||||||
cmake --build build -j$(nproc)
|
cmake --build build -j$(nproc)
|
||||||
sudo cmake --install build # installs to /usr/local/lib
|
sudo cmake --install build # installs to /usr/local/lib
|
||||||
sudo ldconfig # refresh linker cache
|
sudo ldconfig # refresh linker cache
|
||||||
|
|||||||
@@ -106,6 +106,88 @@ audio_io.WaveAudioInput = ModWaveAudioInput
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@contextlib.asynccontextmanager
|
||||||
async def create_device(config: auracast_config.AuracastGlobalConfig) -> AsyncGenerator[bumble.device.Device, Any]:
|
async def create_device(config: auracast_config.AuracastGlobalConfig) -> AsyncGenerator[bumble.device.Device, Any]:
|
||||||
async with await bumble.transport.open_transport(config.transport) as (
|
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"),
|
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'],
|
sampling_frequency=q['rate'],
|
||||||
octets_per_frame=q['octets'],
|
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
|
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 library:", sd._libname)
|
||||||
print("PortAudio version:", sd.get_portaudio_version())
|
print("PortAudio version:", sd.get_portaudio_version())
|
||||||
print("\nHost APIs:")
|
print("\nHost APIs:")
|
||||||
pprint.pprint(sd.query_hostapis())
|
pprint.pprint(sd.query_hostapis())
|
||||||
print("\nDevices:")
|
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']}")
|
||||||
|
|||||||
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