commit 30d727b19630bed6798ffd11b7fc1bbd7ff99f4f Author: pstruebi Date: Fri Jul 11 15:17:53 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..831e91f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +*/__pycache__/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..446bdc4 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Install deps on ubuntu + +## Pre-setup on Raspberry Pi (Raspberry Pi OS) +Before installing dependencies, ensure your Raspberry Pi is up to date and has audio enabled: + +1. **Update your system:** + ```sh + sudo apt update && sudo apt upgrade + ``` +2. **Enable audio:** + - Use `raspi-config` to enable audio output (if not already enabled): + ```sh + sudo raspi-config + # Navigate to 'Advanced Options' > 'Audio' and select your output (HDMI or 3.5mm jack) + ``` +3. **Reboot if needed:** + ```sh + sudo reboot + ``` +4. **Test audio:** + ```sh + speaker-test -t wav + ``` + or + ```sh + aplay /usr/share/sounds/alsa/Front_Center.wav + ``` +5. **Connect USB microphone or sound card if required.** + +Proceed with the dependency installation steps below. + +- make sure poetry was installed with pipx +sudo apt update +sudo apt install build-essential libcairo2-dev libgirepository1.0-dev pkg-config +sudo apt install libgirepository-2.0-dev gir1.2-girepository-2.0 build-essential pkg-config +sudo apt install gir1.2-gstreamer-1.0 gir1.2-gst-plugins-base-1.0 + +# Install on rpi \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..16bf2dc --- /dev/null +++ b/poetry.lock @@ -0,0 +1,46 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "pycairo" +version = "1.28.0" +description = "Python interface for cairo" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycairo-1.28.0-cp310-cp310-win32.whl", hash = "sha256:53e6dbc98456f789965dad49ef89ce2c62f9a10fc96c8d084e14da0ffb73d8a6"}, + {file = "pycairo-1.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:c8ab91a75025f984bc327ada335c787efb61c929ea0512063793cb36cee503d4"}, + {file = "pycairo-1.28.0-cp310-cp310-win_arm64.whl", hash = "sha256:e955328c1a5147bf71ee94e206413ce15e12630296a79788fcd246c80e5337b8"}, + {file = "pycairo-1.28.0-cp311-cp311-win32.whl", hash = "sha256:0fee15f5d72b13ba5fd065860312493dc1bca6ff2dce200ee9d704e11c94e60a"}, + {file = "pycairo-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:6339979bfec8b58a06476094a9a5c104bd5a99932ddaff16ca0d9203d2f4482c"}, + {file = "pycairo-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6ae15392e28ebfc0b35d8dc05d395d3b6be4bad9ad4caecf0fa12c8e7150225"}, + {file = "pycairo-1.28.0-cp312-cp312-win32.whl", hash = "sha256:c00cfbb7f30eb7ca1d48886712932e2d91e8835a8496f4e423878296ceba573e"}, + {file = "pycairo-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:d50d190f5033992b55050b9f337ee42a45c3568445d5e5d7987bab96c278d8a6"}, + {file = "pycairo-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:957e0340ee1c279d197d4f7cfa96f6d8b48e453eec711fca999748d752468ff4"}, + {file = "pycairo-1.28.0-cp313-cp313-win32.whl", hash = "sha256:d13352429d8a08a1cb3607767d23d2fb32e4c4f9faa642155383980ec1478c24"}, + {file = "pycairo-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:082aef6b3a9dcc328fa648d38ed6b0a31c863e903ead57dd184b2e5f86790140"}, + {file = "pycairo-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:026afd53b75291917a7412d9fe46dcfbaa0c028febd46ff1132d44a53ac2c8b6"}, + {file = "pycairo-1.28.0-cp39-cp39-win32.whl", hash = "sha256:3ed16d48b8a79cc584cb1cb0ad62dfb265f2dda6d6a19ef5aab181693e19c83c"}, + {file = "pycairo-1.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:da0d1e6d4842eed4d52779222c6e43d254244a486ca9fdab14e30042fd5bdf28"}, + {file = "pycairo-1.28.0-cp39-cp39-win_arm64.whl", hash = "sha256:458877513eb2125513122e8aa9c938630e94bb0574f94f4fb5ab55eb23d6e9ac"}, + {file = "pycairo-1.28.0.tar.gz", hash = "sha256:26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc"}, +] + +[[package]] +name = "pygobject" +version = "3.52.3" +description = "Python bindings for GObject Introspection" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "pygobject-3.52.3.tar.gz", hash = "sha256:00e427d291e957462a8fad659a9f9c8be776ff82a8b76bdf402f1eaeec086d82"}, +] + +[package.dependencies] +pycairo = ">=1.16" + +[metadata] +lock-version = "2.1" +python-versions = "~3.12" +content-hash = "f81ce71b9e08a67710c4c22ea280289755e0493d517420fad994313416eba36b" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..08f2f58 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "gstreamer-test" +version = "0.1.0" +description = "" +authors = [ + {name = "pstruebi",email = "struebin.patrick@gmail.com"} +] +readme = "README.md" +requires-python = "~3.12" +dependencies = [ + "pygobject (>=3.52.3,<4.0.0)" +] + + +[tool.poetry] +packages = [ + { include = "src" } +] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/aes67_sink.py b/src/aes67_sink.py new file mode 100644 index 0000000..80af7e7 --- /dev/null +++ b/src/aes67_sink.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +AES67 Sink Example (RTP multicast receiver @ 48 kHz) + +This script enumerates local audio output devices (GStreamer "Audio/Sink") and +lets you pick one to receive an AES67 RTP multicast stream. It also advertises +its availability via SAP so that transmitters can discover the sink +(contrary to typical AES67 usage where sources announce themselves). + +Usage: + python aes67_sink.py [--multicast 239.255.0.1] [--port 5004] + +Requirements: + • PyGObject bindings for GStreamer (pip install pygobject) + • GStreamer runtime with "good" plugins. +""" +from __future__ import annotations + +import argparse +import sys +from typing import List + +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("GstSdp", "1.0") +from gi.repository import Gst # noqa: E402 +from gi.repository import GstSdp # noqa: E402 +from gi.repository import GLib # noqa: E402 + +Gst.init(None) + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + +def enumerate_audio_sinks() -> List[Gst.Device]: + """Return a list of output devices recognised by GStreamer.""" + mon = Gst.DeviceMonitor() + mon.add_filter("Audio/Sink") + mon.start() + devices = mon.get_devices() + mon.stop() + return devices + + +def prompt_for_device(devices: List[Gst.Device], prompt: str) -> Gst.Device: + if not devices: + print("No audio output devices found.") + sys.exit(1) + + print(prompt) + for idx, dev in enumerate(devices): + print(f"[{idx}] {dev.get_display_name()}") + + while True: + try: + sel = int(input("Select device #: ")) + if 0 <= sel < len(devices): + return devices[sel] + except ValueError: + pass + print("Invalid selection, try again.") + + +# --------------------------------------------------------------------------- +# Pipeline construction +# --------------------------------------------------------------------------- + +def build_pipeline(device: Gst.Device, multicast: str, port: int) -> Gst.Pipeline: + props = device.get_properties() or {} + device_str = None + for key in ("device", "device.path", "device.string"): + if props.has_field(key): + device_str = props.get_string(key) + break + + if device_str: + sink = f"pulsesink device={device_str}" + else: + sink = "autoaudiosink" + + desc = ( + f"udpsrc address={multicast} port={port} caps=" + "application/x-rtp,media=audio,encoding-name=L16,channels=2,rate=48000,payload=96 " + "! rtpL16depay ! audioconvert ! audioresample ! " + sink + ) + + print("GStreamer pipeline:") + print(desc, "\n") + + return Gst.parse_launch(desc) # type: ignore[return-value] + + +# --------------------------------------------------------------------------- +# SAP advertisement (announce sink) +# --------------------------------------------------------------------------- + +_SAP_DEST = ("224.2.127.254", 9875) + + +def _create_sdp(multicast: str, port: int) -> str: + result, msg = GstSdp.SDPMessage.new() + if result != GstSdp.SDPResult.OK: + raise RuntimeError("Failed to create SDP message") + msg.set_version("0") + msg.set_session_name("AES67 Sink Listening") + msg.set_connection("IN", "IP4", multicast, 0, 0) + result, med = GstSdp.SDPMedia.new() + if result != GstSdp.SDPResult.OK: + raise RuntimeError("Failed to create SDPMedia") + med.set_media("audio") + med.set_port_info(port, 1) + med.set_proto("RTP/AVP") + med.add_attribute("rtpmap", "96 L16/48000/2") + msg.add_media(med) + return msg.as_text() + + +class _SapSender: + def __init__(self, sdp: str, interval: int = 2): + self.sdp = sdp.encode() + self.interval = interval + import socket + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + # Enable multicast TTL (optional, default 1) + self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + self.dest = (_SAP_DEST[0], _SAP_DEST[1]) + + def _send(self): + header = bytes([0x20]) + b"\x00\x00\x00" + self.sock.sendto(header + self.sdp, self.dest) + return True + + def start(self): + GLib.timeout_add_seconds(self.interval, self._send) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="AES67 Sink") + parser.add_argument("--multicast", default="239.255.0.1", help="Multicast group to listen to") + parser.add_argument("--port", type=int, default=5004, help="UDP port") + args = parser.parse_args() + + devs = enumerate_audio_sinks() + out_dev = prompt_for_device(devs, "Available output devices:") + + # Build and start pipeline + pipeline = build_pipeline(out_dev, args.multicast, args.port) + + # Advertise via SAP + sdp_text = _create_sdp(args.multicast, args.port) + print("\n--- SDP being announced via SAP ---\n" + sdp_text + "\n-------------------------------\n") + _SapSender(sdp_text).start() + + loop = GLib.MainLoop() + + def on_msg(_, msg): + t = msg.type + if t == Gst.MessageType.ERROR: + err, dbg = msg.parse_error() + print("Pipeline error:", err, dbg) + loop.quit() + elif t == Gst.MessageType.EOS: + print("EOS reached.") + loop.quit() + + bus = pipeline.get_bus() + bus.add_signal_watch() + def on_msg(_, msg): + t = msg.type + print(f"[GstBus] Message: {t}") + if t == Gst.MessageType.ERROR: + err, dbg = msg.parse_error() + print("Pipeline error:", err, dbg) + loop.quit() + elif t == Gst.MessageType.EOS: + print("EOS reached.") + loop.quit() + else: + s = msg.get_structure() + if s: + print(f"[GstBus] Structure: {s.to_string()}") + bus.connect("message", on_msg) + + print("Setting pipeline state to PLAYING...") + if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE: + print("Failed to start pipeline.") + sys.exit(1) + + print("Receiving… Press Ctrl+C to stop.") + try: + loop.run() + except KeyboardInterrupt: + pass + finally: + pipeline.set_state(Gst.State.NULL) + print("Pipeline set to NULL.") + + +if __name__ == "__main__": + main() diff --git a/src/aes67_source.py b/src/aes67_source.py new file mode 100644 index 0000000..0a40527 --- /dev/null +++ b/src/aes67_source.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +AES67 Source Example (Microphone to Network RTP @ 48 kHz) + +This script enumerates local microphone devices (GStreamer "Audio/Source") +and lets you pick one to publish as an AES67-compatible RTP multicast stream. + +It also advertises the stream via SAP so that receivers on the same subnet can +automatically discover and subscribe. + +Tested with GStreamer 1.24 and Python 3.12. + +Usage: + python aes67_source.py [--multicast 239.255.0.1] [--port 5004] + +Requirements: + • PyGObject bindings for GStreamer (pip install pygobject) + • The corresponding GStreamer runtime with "good" plugins. + +Copyright 2025 © Patrick Struebi +""" +from __future__ import annotations + +import argparse +import sys +from typing import List +from sap_discovery import discover_sinks + +import gi + +gi.require_version("Gst", "1.0") +from gi.repository import Gst # noqa: E402 +from gi.repository import GLib # noqa: E402 + +Gst.init(None) + + +def enumerate_audio_sources() -> List[Gst.Device]: + """Return a list of microphone devices recognised by GStreamer.""" + monitor = Gst.DeviceMonitor() + # Filter for capture sources (microphones) + monitor.add_filter("Audio/Source") + monitor.start() + devices = monitor.get_devices() + monitor.stop() + return devices + + +def prompt_for_device(devices: List[Gst.Device], prompt: str) -> Gst.Device: + if not devices: + print("No audio input devices found.") + sys.exit(1) + if len(devices) == 1: + print(f"Auto-selected only available input device: {devices[0].get_display_name()}") + return devices[0] + print(prompt) + for idx, dev in enumerate(devices): + print(f"[{idx}] {dev.get_display_name()}") + while True: + try: + choice = int(input("Select device #: ")) + if 0 <= choice < len(devices): + return devices[choice] + except ValueError: + pass + print("Invalid selection, try again.") + + +def build_pipeline(device: Gst.Device, multicast: str, port: int) -> Gst.Pipeline: + """Construct the GStreamer pipeline string and create a pipeline.""" + + # Attempt to extract a gst-launch-compatible device string + dev_props = device.get_properties() or {} + device_str = None + if dev_props: + # Common property names depending on backend + for key in ("device", "device.path", "device.string"): + if dev_props.has_field(key): + device_str = dev_props.get_string(key) + break + # Fallback to auto-selection if we cannot determine the exact string + if device_str: + src = f"pulsesrc device={device_str}" + else: + src = "autoaudiosrc" + + # AES67 requires 48 kHz, 16-bit, 2-channel PCM + pipeline_description = ( + f"{src} ! audioconvert ! audioresample ! audio/x-raw,channels=2,rate=48000 " + "! rtpL16pay ! multiudpsink clients=" + f"{multicast}:{port} sync=false" + ) + + print("GStreamer pipeline:") + print(pipeline_description, "\n") + + pipeline = Gst.parse_launch(pipeline_description) + return pipeline # type: ignore[return-value] + + + + + + +def prompt_for_sink(sinks): + if not sinks: + print("No sinks discovered via SAP announcements.") + sys.exit(1) + if len(sinks) == 1: + print(f"Auto-selected only discovered sink: {sinks[0].get('name', 'Unknown')} @ {sinks[0].get('address', '?')}:{sinks[0].get('port', '?')}") + return sinks[0] + print("Discovered sinks:") + for idx, sink in enumerate(sinks): + name = sink.get('name', 'Unknown') + addr = sink.get('address', '?') + port = sink.get('port', '?') + print(f"[{idx}] {name} @ {addr}:{port}") + while True: + try: + sel = int(input("Select sink #: ")) + if 0 <= sel < len(sinks): + return sinks[sel] + except ValueError: + pass + print("Invalid selection, try again.") + +def main(): + parser = argparse.ArgumentParser(description="AES67 Source (Microphone)") + parser.add_argument("--sap-wait", type=float, default=5.0, help="Seconds to listen for SAP sinks") + args = parser.parse_args() + + print("Listening for SAP announcements...") + sinks = discover_sinks(timeout=args.sap_wait) + sink = prompt_for_sink(sinks) + multicast = sink['address'] + port = int(sink['port']) + + devices = enumerate_audio_sources() + mic = prompt_for_device(devices, "Available microphone devices:") + + pipeline = build_pipeline(mic, multicast, port) + + loop = GLib.MainLoop() + + def on_message(_, msg): + t = msg.type + if t == Gst.MessageType.EOS: + print("End-of-Stream reached.") + loop.quit() + elif t == Gst.MessageType.ERROR: + err, dbg = msg.parse_error() + print("Pipeline error:", err, dbg) + loop.quit() + + bus = pipeline.get_bus() + bus.add_signal_watch() + bus.connect("message", on_message) + + ret = pipeline.set_state(Gst.State.PLAYING) + if ret == Gst.StateChangeReturn.FAILURE: + print("Failed to start pipeline.") + sys.exit(1) + + print(f"Streaming to {multicast}:{port}… Press Ctrl+C to stop.") + try: + loop.run() + except KeyboardInterrupt: + pass + finally: + pipeline.set_state(Gst.State.NULL) + + +if __name__ == "__main__": + main() diff --git a/src/sap_discovery.py b/src/sap_discovery.py new file mode 100644 index 0000000..f3f2f05 --- /dev/null +++ b/src/sap_discovery.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +SAP Listener/Discovery utility for AES67 Sinks. + +Listens for SAP packets on the standard multicast address/port and parses SDP +descriptions for AES67 sinks. Intended for use by the AES67 source script to +discover available sinks on the network. + +Usage: import and use discover_sinks() from another script. +""" +import socket +import struct +import time +from typing import List, Tuple, Dict + +SAP_GROUP = '224.2.127.254' +SAP_PORT = 9875 + + +def _parse_sdp(sdp: str) -> Dict[str, str]: + """Extracts relevant info from an SDP string.""" + result = {} + for line in sdp.splitlines(): + if line.startswith('c=IN IP4 '): + result['address'] = line.split()[-1] + elif line.startswith('m=audio '): + parts = line.split() + if len(parts) > 2: + result['port'] = parts[1] + elif line.startswith('s='): + result['name'] = line[2:] + return result + + +def discover_sinks(timeout: float = 2.0) -> List[Dict[str, str]]: + """Listen for SAP packets and return a list of discovered sinks (SDP info).""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((SAP_GROUP, SAP_PORT)) + mreq = struct.pack('4sl', socket.inet_aton(SAP_GROUP), socket.INADDR_ANY) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + sock.settimeout(timeout) + + seen = set() + sinks = [] + start = time.time() + while time.time() - start < timeout: + try: + data, _ = sock.recvfrom(2048) + # SAP header is 4 bytes, then SDP text + sdp = data[4:].decode(errors='ignore') + info = _parse_sdp(sdp) + key = (info.get('address'), info.get('port'), info.get('name')) + if key not in seen and 'address' in info and 'port' in info: + sinks.append(info) + seen.add(key) + except socket.timeout: + break + except Exception: + continue + sock.close() + return sinks + +if __name__ == "__main__": + for sink in discover_sinks(): + print(sink)