Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.
native/ the service: capture backends (PulseAudio, ALSA, exec,
test tone, all dlopen-based), DSP, and one file per sink
frontend/ D-pad driven UI at a fixed 1920x1080
servicefiles/ native service manifest plus the boot script
host/ RTP receiver and the loopback installer for the HyperHDR
machine
tools/ build/package, asset generation, Homebrew Channel
manifest, on-TV probe
test/ host-side suites: FlatBuffers and RTP verified against
real decoders, the engine end to end, the page in jsdom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.6 KiB
Python
188 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Checks the TV's RTP sink against the host receiver.
|
|
|
|
The C sink writes a known ramp; this binds the port, collects the datagrams and
|
|
decodes them with the very functions host/lgtv-audiocap-receiver.py uses. If
|
|
the two ever disagree about the header layout or the sample byte order, the
|
|
ramp comes back wrong and this fails.
|
|
|
|
Also checks the SAP/SDP announcement, since PulseAudio's module-rtp-recv builds
|
|
its source purely from that text.
|
|
|
|
python3 test/verify_rtp.py [path-to-rtp_send]
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.join(HERE, os.pardir, "host"))
|
|
|
|
# The receiver's filename is not an identifier, so load it by path.
|
|
try:
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"audiocap_receiver",
|
|
os.path.join(HERE, os.pardir, "host", "lgtv-audiocap-receiver.py"))
|
|
receiver = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(receiver)
|
|
except Exception as exc: # pragma: no cover - only when the file is missing
|
|
print("cannot load the host receiver: %s" % exc)
|
|
sys.exit(1)
|
|
|
|
BLOCKS = 8
|
|
FRAMES = 512
|
|
CHANNELS = 2
|
|
RATE = 48000
|
|
|
|
checks = 0
|
|
failures = 0
|
|
|
|
|
|
def check(name, condition, detail=None):
|
|
global checks, failures
|
|
checks += 1
|
|
if condition:
|
|
print(" ok %s" % name)
|
|
else:
|
|
failures += 1
|
|
print(" FAIL %s%s" % (name, "" if detail is None else " — %s" % detail))
|
|
|
|
|
|
def eq(name, actual, expected):
|
|
check(name, actual == expected, "got %r, wanted %r" % (actual, expected))
|
|
|
|
|
|
def sample_at(index):
|
|
"""Must match sample_at() in test/rtp_send.c."""
|
|
value = (index * 251) % 65536 - 32768
|
|
return value
|
|
|
|
|
|
def main():
|
|
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "rtp_send")
|
|
if not os.path.exists(binary):
|
|
print("build test/rtp_send.c first (run-tests.sh does it for you)")
|
|
return 1
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
sock.settimeout(2.0)
|
|
|
|
proc = subprocess.run([binary, str(port), str(BLOCKS), str(FRAMES)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if proc.returncode != 0:
|
|
print("rtp_send failed: %s" % proc.stderr.decode("utf-8", "replace"))
|
|
return 1
|
|
expected_samples = int(proc.stdout.decode().strip())
|
|
|
|
packets = []
|
|
try:
|
|
while True:
|
|
data, _ = sock.recvfrom(4096)
|
|
packets.append(data)
|
|
except socket.timeout:
|
|
pass
|
|
sock.close()
|
|
|
|
print("wire format")
|
|
check("packets arrived", len(packets) > 0, "%d packets" % len(packets))
|
|
if not packets:
|
|
return 1
|
|
|
|
first = receiver.parse_rtp(packets[0])
|
|
check("receiver parses the header", first is not None)
|
|
eq("payload type", first.payload_type, 96)
|
|
eq("version 2, no CSRCs, no extension", packets[0][0], 0x80)
|
|
eq("marker bit clear", packets[0][1] >> 7, 0)
|
|
|
|
# Every packet must stay inside a 1500-byte MTU with room for the IP and
|
|
# UDP headers, or the stream fragments and loss goes from bad to total.
|
|
largest = max(len(p) for p in packets)
|
|
check("no packet exceeds the MTU budget", largest <= 1472, "%d bytes" % largest)
|
|
|
|
print("sequencing")
|
|
parsed = [receiver.parse_rtp(p) for p in packets]
|
|
check("all packets parse", all(p is not None for p in parsed))
|
|
ssrcs = set(p.ssrc for p in parsed)
|
|
eq("one SSRC for the run", len(ssrcs), 1)
|
|
|
|
sequences = [p.sequence for p in parsed]
|
|
expected_sequences = [(sequences[0] + i) & 0xFFFF for i in range(len(sequences))]
|
|
eq("sequence numbers increment by one", sequences, expected_sequences)
|
|
|
|
frame_bytes = 2 * CHANNELS
|
|
stamps = [p.timestamp for p in parsed]
|
|
steps = set((stamps[i + 1] - stamps[i]) & 0xFFFFFFFF for i in range(len(stamps) - 1))
|
|
frames_per_packet = set(len(p.payload) // frame_bytes for p in parsed[:-1])
|
|
eq("timestamp advances by the frame count", steps, frames_per_packet)
|
|
|
|
print("payload")
|
|
pcm = b"".join(receiver.to_native_pcm(p.payload) for p in parsed)
|
|
samples = struct.unpack("<%dh" % (len(pcm) // 2), pcm)
|
|
eq("every sample arrived", len(samples), expected_samples)
|
|
wrong = [i for i, v in enumerate(samples) if v != sample_at(i)]
|
|
check("the ramp survives the round trip", not wrong,
|
|
"%d samples differ, first at %s" % (len(wrong), wrong[:1]))
|
|
|
|
# A wrong byte order still produces "audio", just noise; check explicitly
|
|
# that the payload really is big-endian on the wire.
|
|
raw_be = struct.unpack(">%dh" % (len(parsed[0].payload) // 2), parsed[0].payload)
|
|
eq("payload is big-endian on the wire", raw_be[0], sample_at(0))
|
|
|
|
print("SAP announcement")
|
|
sdp = capture_sap()
|
|
if sdp is None:
|
|
print(" SKIP: no announcement seen (multicast on loopback is often"
|
|
" blocked); the SDP text itself is unchecked")
|
|
else:
|
|
check("SDP names an L16 stream", "L16/%d/%d" % (RATE, CHANNELS) in sdp, sdp)
|
|
check("SDP carries a media line", re.search(r"m=audio \d+ RTP/AVP 96", sdp)
|
|
is not None, sdp)
|
|
check("SDP is recvonly", "a=recvonly" in sdp, sdp)
|
|
|
|
print("\n%d/%d checks passed" % (checks - failures, checks))
|
|
return 1 if failures else 0
|
|
|
|
|
|
def capture_sap(timeout=1.5):
|
|
"""Listens for one SAP announcement from a second, SAP-enabled run."""
|
|
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "rtp_send")
|
|
sap = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sap.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
sap.bind(("", 9875))
|
|
# Join on whichever interface the kernel picks: the announcement leaves
|
|
# by the default route, so that is where it can loop back from.
|
|
membership = socket.inet_aton("224.0.0.56") + struct.pack("=I", socket.INADDR_ANY)
|
|
sap.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
|
|
except OSError:
|
|
sap.close()
|
|
return None
|
|
sap.settimeout(timeout)
|
|
|
|
subprocess.run([binary, "9999", "2", "512", "sap"], stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL)
|
|
try:
|
|
data, _ = sap.recvfrom(2048)
|
|
except socket.timeout:
|
|
return None
|
|
finally:
|
|
sap.close()
|
|
|
|
# RFC 2974: 4-byte header, 4-byte source, NUL-terminated MIME type.
|
|
body = data[8:]
|
|
end = body.find(b"\x00")
|
|
return body[end + 1:].decode("utf-8", "replace") if end >= 0 else None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|