summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_stream_audio_transcode.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_stream_audio_transcode.py')
-rw-r--r--packages/meshbay-node/tests/test_stream_audio_transcode.py174
1 files changed, 174 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_stream_audio_transcode.py b/packages/meshbay-node/tests/test_stream_audio_transcode.py
new file mode 100644
index 0000000..5b1fc46
--- /dev/null
+++ b/packages/meshbay-node/tests/test_stream_audio_transcode.py
@@ -0,0 +1,174 @@
+"""
+Audio in a streamed video is always transcoded to AAC, never copied.
+
+Found live against a real 5.1 E-AC-3 WEB-DL (season 2 of a show whose season 1
+was AAC and played fine): "-c copy" on an E-AC-3 track makes ffmpeg itself
+refuse to write the fragmented MP4 header at all — "Cannot write moov atom
+before EAC3 packets parsed" — and even for the codecs that don't make ffmpeg
+outright refuse (plain AC-3, DTS, ...), no mainstream browser's MSE decodes
+them, so the viewer would see a stream that starts and immediately ends with
+no picture and no error. Video is untouched here — it is always copied,
+still H264 in every fixture below, since re-encoding it is the expensive
+thing this pipeline exists to avoid.
+
+These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi
+test sources, ~1s) rather than asserting against the source text — a source
+match cannot tell a working remux from one ffmpeg silently refuses to write.
+"""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession, _probe_video
+
+from conftest import one_root
+
+_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe")
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed"),
+]
+
+
+def _make_clip(path: Path, *, acodec: str, channels: int = 2) -> None:
+ """~1s of H264 video + `acodec` audio, muxed into an .mkv — a minimal
+ stand-in for a real WEB-DL with that audio codec."""
+ subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1",
+ "-f", "lavfi", "-i", f"sine=frequency=440:duration=1:sample_rate=48000",
+ "-ac", str(channels),
+ "-c:v", "libx264", "-preset", "ultrafast", "-c:a", acodec,
+ str(path)],
+ check=True, capture_output=True,
+ )
+
+
+def _session(tmp_path: Path, video_path: Path, gek: bytes) -> WebRTCPeerSession:
+ import blake3
+ file_bytes = video_path.read_bytes()
+ file_id = blake3.blake3(file_bytes).hexdigest()
+
+ sk_node = Ed25519PrivateKey.generate()
+ index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek)
+ from meshbay_common.protocol import IndexEntry
+ index.add_entry(IndexEntry(
+ id=file_id, name=video_path.name, path=video_path.parent.name,
+ size=len(file_bytes), type="video", added_at=0))
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": one_root(video_path.parent),
+ "index": index,
+ "gek": gek,
+ "sk_node": sk_node,
+ "max_concurrent_streams": 4,
+ }
+ session._group_id = None
+ session._user_id = "tester"
+ session._stream_stopped = False
+ session._stream_keepalives = 0
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session, file_id
+
+
+def _reassemble(sent: list[dict], gek: bytes, file_id: str) -> bytes:
+ file_hash = bytes.fromhex(file_id)
+ segments = sorted(
+ (m for m in sent if m.get("type") == "stream_data"),
+ key=lambda m: m["segment_index"])
+ out = b""
+ for m in segments:
+ key = chunk_key_aes(gek, file_hash, m["segment_index"])
+ out += decrypt_chunk_aes(key, m["nonce"], m["ct"])
+ return out
+
+
+async def test_eac3_audio_is_transcoded_not_copied(tmp_path):
+ """The exact reproduction: ffmpeg refuses "-c copy" on E-AC-3 outright."""
+ clip = tmp_path / "clip.mkv"
+ _make_clip(clip, acodec="eac3", channels=6)
+ gek = generate_gek()
+ session, file_id = _session(tmp_path, clip, gek)
+
+ await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
+
+ errors = [m for m in session.sent if m.get("type") == "error"]
+ assert not errors, f"streaming must not fail: {errors}"
+
+ init = next(m for m in session.sent if m.get("type") == "stream_init")
+ assert "mp4a.40.2" in init["codec"], \
+ "the reported codec must be AAC, never the source's eac3"
+
+ data_segments = [m for m in session.sent if m.get("type") == "stream_data"]
+ assert data_segments, "no video data was ever sent"
+ assert any(m.get("type") == "stream_end" for m in session.sent)
+
+ remuxed = _reassemble(session.sent, gek, file_id)
+ out_path = tmp_path / "out.mp4"
+ out_path.write_bytes(remuxed)
+ probe = subprocess.run(
+ ["ffprobe", "-v", "error", "-show_entries", "stream=codec_name,codec_type,channels",
+ "-of", "csv=p=0", str(out_path)],
+ check=True, capture_output=True, text=True)
+ rows = [line.split(",") for line in probe.stdout.strip().splitlines()]
+ codecs = {r[1]: r[0] for r in rows}
+ channels = {r[1]: r[2] for r in rows if len(r) > 2}
+ assert codecs.get("audio") == "aac", f"audio must be AAC on the wire: {codecs}"
+ assert codecs.get("video") == "h264", f"video must still be H264: {codecs}"
+ assert channels.get("audio") == "2", (
+ "audio must be downmixed to stereo: some browsers' MSE decoder "
+ f"silently rejects multichannel AAC once real fragments are "
+ f"appended, even though ffprobe/VLC accept it fine: {channels}")
+
+
+async def test_already_aac_audio_still_streams(tmp_path):
+ """Season-1-shaped file (already AAC): must keep working exactly as before."""
+ clip = tmp_path / "clip.mkv"
+ _make_clip(clip, acodec="aac", channels=2)
+ gek = generate_gek()
+ session, file_id = _session(tmp_path, clip, gek)
+
+ await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
+
+ assert not [m for m in session.sent if m.get("type") == "error"]
+ assert any(m.get("type") == "stream_data" for m in session.sent)
+ assert any(m.get("type") == "stream_end" for m in session.sent)
+
+
+async def test_probe_video_reports_aac_regardless_of_source_audio_codec(tmp_path):
+ clip = tmp_path / "clip.mkv"
+ _make_clip(clip, acodec="eac3", channels=6)
+
+ codec, duration, has_audio = await _probe_video(str(clip))
+
+ assert has_audio is True
+ assert duration > 0
+ assert codec is not None
+ assert "eac3" not in codec and "ec-3" not in codec
+ assert "mp4a.40.2" in codec
+
+
+async def test_probe_video_handles_no_audio_track(tmp_path):
+ clip = tmp_path / "silent.mkv"
+ subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1",
+ "-c:v", "libx264", "-preset", "ultrafast", "-an", str(clip)],
+ check=True, capture_output=True,
+ )
+
+ codec, duration, has_audio = await _probe_video(str(clip))
+
+ assert has_audio is False
+ assert codec is not None and "," not in codec, \
+ "no audio track must not produce a dangling ',' or a fake audio codec"