diff --git a/README.md b/README.md index 6c2899e..07ae802 100644 --- a/README.md +++ b/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 diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 77b5f04..dbf5983 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -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 ( diff --git a/src/auracast/multicast_script.py b/src/auracast/multicast_script.py new file mode 100644 index 0000000..6366773 --- /dev/null +++ b/src/auracast/multicast_script.py @@ -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 + ) + ) diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 3a0d22c..2d383f7 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -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'], ), diff --git a/src/auracast/utils/sounddevice_utils.py b/src/auracast/utils/sounddevice_utils.py new file mode 100644 index 0000000..5c019b3 --- /dev/null +++ b/src/auracast/utils/sounddevice_utils.py @@ -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']}") diff --git a/src/scripts/list_pw_nodes.py b/src/scripts/list_pw_nodes.py index dceb2b0..c1cf925 100644 --- a/src/scripts/list_pw_nodes.py +++ b/src/scripts/list_pw_nodes.py @@ -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()) \ No newline at end of file +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']}") diff --git a/src/service/pipewire/99-lowlatency.conf b/src/service/pipewire/99-lowlatency.conf new file mode 100644 index 0000000..eb23eec --- /dev/null +++ b/src/service/pipewire/99-lowlatency.conf @@ -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 +} \ No newline at end of file