initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.vscode/
|
||||
*/__pycache__/*
|
||||
38
README.md
Normal file
38
README.md
Normal 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
46
poetry.lock
generated
Normal 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
22
pyproject.toml
Normal 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
207
src/aes67_sink.py
Normal 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
174
src/aes67_source.py
Normal 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
66
src/sap_discovery.py
Normal 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)
|
||||
Reference in New Issue
Block a user