refactor: standardize audio latency to 3ms across PipeWire and AES67 stacks
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user