initial commit

This commit is contained in:
2025-07-11 15:17:53 +02:00
commit 30d727b196
7 changed files with 555 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.vscode/
*/__pycache__/*

38
README.md Normal file
View File

@@ -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

46
poetry.lock generated Normal file
View File

@@ -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"

22
pyproject.toml Normal file
View File

@@ -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"

207
src/aes67_sink.py Normal file
View File

@@ -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()

174
src/aes67_source.py Normal file
View File

@@ -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()

66
src/sap_discovery.py Normal file
View File

@@ -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)