diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index f21651d2045..f47baef063c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -18795,7 +18795,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediatrackstreamproducer_finish() != - 57446) { + 6627) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediatrackstreamproducer_write() != diff --git a/js/net/src/zod.test.ts b/js/net/src/zod.test.ts new file mode 100644 index 00000000000..ddc8cee2798 --- /dev/null +++ b/js/net/src/zod.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import * as z from "@zod/mini"; +import { Producer } from "./group.ts"; +import { textFrame } from "./mock.ts"; +import * as Zod from "./zod.ts"; + +test("read validates each frame and ends only at the end of the group", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + // Falsy values are frames like any other, not the end of the stream. + for (const json of ["0", "false", "null", '"ok"']) producer.writeFrame(textFrame(json)); + producer.close(); + + const schema = z.union([z.number(), z.boolean(), z.null(), z.string()]); + expect(await Zod.read(consumer, schema)).toBe(0); + expect(await Zod.read(consumer, schema)).toBe(false); + expect(await Zod.read(consumer, schema)).toBe(null); + expect(await Zod.read(consumer, schema)).toBe("ok"); + expect(await Zod.read(consumer, schema)).toBeUndefined(); +}); + +test("read rejects a frame the schema refuses", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + producer.writeFrame(textFrame('{"count":"three"}')); + producer.close(); + + await expect(Zod.read(consumer, z.object({ count: z.number() }))).rejects.toThrow(); +}); diff --git a/quest/m1/README.md b/quest/m1/README.md index 0cec85655e8..5ce8e2ae50e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -122,7 +122,6 @@ lands before the cut. - [Dead code and API surface audit](/quest/m1/audit-dead-code.md) - repeat the removal audit before the next major release - [Dependency and alternatives audit](/quest/m1/audit-deps.md) - dependencies are current, deduplicated, needed, and the best maintained option -- [Test coverage audit](/quest/m1/audit-test-coverage.md) - repeat the coverage audit against the m0 baselines before the next major release - [Test cost audit](/quest/m1/audit-test-cost.md) - the slowest, flakiest, and least valuable tests are fixed, deleted, or planned - [Interop report](/quest/m1/interop-report.md) - nightly Interop results publish as a color-coded matrix on the Pages site instead of only in logs - [CI workflow audit](/quest/m1/audit-ci.md) - every workflow and job gives a signal someone sees, at a cost that signal justifies, or is fixed, moved, or deleted diff --git a/quest/m1/audit-test-coverage.md b/quest/m1/audit-test-coverage.md deleted file mode 100644 index 62355049d6e..00000000000 --- a/quest/m1/audit-test-coverage.md +++ /dev/null @@ -1,117 +0,0 @@ -# [M] Test coverage audit - -## Goal - -Behavior that matters but no test exercises is covered or planned, and every -test runs in CI. - -## Plan - -Recurs once per major release (decided 2026-10-19). The PR that finishes -this run also adds the next one with the same file name at the end of the next -milestone's `Required` (m0 to m1, m1 to m2). The successor keeps the Goal -and this paragraph; the rest of its Plan is rewritten with what this run -learned: baselines, results, and what to check first. A successor placed in m2 -also lists the [release cut](/quest/m2/release/cut.md) under `Required`, so it -waits for the release in flight to ship. Only `/quest-delete` ends the -recurrence. Fixes land in the run's PR, one commit each, rather than as follow-ups. -Only a change that needs a maintainer decision (public API, scope) or is -independently landable and too large to review alongside the rest becomes a -follow-up through `/quest-plan`. - -### Tools - -Nightly keeps both reports as artifacts: `just rs coverage` (cargo-llvm-cov -over nextest, the native workspace at default features, both fuzz packages -and the native moq-wasm stub excluded) and `just js coverage` (Bun's own -coverage, one process per package, line hits unioned across processes). -Start from those artifacts rather than re-running the instrumented -workspace, which is expensive on a shared machine. - -- `just rs coverage ` is not comparable to the workspace table: - without other packages' feature unification it compiles less of the - crate. moq-video measured 4553 / 7741 lines alone against 6431 / 9965 in - the workspace run. -- Default features hide feature-gated tests that do run nightly in the - `rs features` leg (`--all-features`), such as moq-video's encode producer - (`capture`, `openh264`). Check that leg before calling a low file untested. -- Bun 1.4.2 undercounts a module loaded again with a query suffix (signals - reported 19.38% in a run where it was 89.80%). Verify an apparent JS gap - before acting on it, and never merge packages into one `bun test` - process: package-level mocks then contaminate unrelated tests. - -### m0 baselines - -From `origin/main` at `2f1f062c6`: 8,848 native tests passed with 35 ignored, -and 18 JS package processes ran 3,678 tests. Lines covered / measured: - -| Rust crate | Lines | Rust crate | Lines | -| --- | --- | --- | --- | -| hang | 2848 / 2924 | moq-loc | 141 / 153 | -| kio | 2023 / 2115 | moq-msf | 674 / 684 | -| moq-archive | 4351 / 4480 | moq-mux | 63457 / 65700 | -| moq-audio | 5346 / 5610 | moq-net | 80294 / 84169 | -| moq-auth | 4894 / 5006 | moq-nvenc | 3445 / 7771 | -| moq-bench | 1614 / 2068 | moq-pattern | 931 / 960 | -| moq-boy | 138 / 671 | moq-quic | 21325 / 22420 | -| moq-c | 8967 / 9562 | moq-relay | 10839 / 11492 | -| moq-cli | 6578 / 7838 | moq-room | 342 / 369 | -| moq-clock | 1571 / 1625 | moq-rtc | 2500 / 2876 | -| moq-clock-macros | 27 / 33 | moq-rtmp | 6816 / 7630 | -| moq-e2ee | 679 / 759 | moq-rtsp | 1033 / 1057 | -| moq-ffi | 10756 / 11705 | moq-shaper | 2042 / 2116 | -| moq-flate | 839 / 892 | moq-sock | 1316 / 1486 | -| moq-gst | 6320 / 6698 | moq-srt | 1286 / 1481 | -| moq-hls | 8360 / 8741 | moq-stats | 4413 / 4600 | -| moq-ice | 319 / 361 | moq-tokio | 15702 / 17740 | -| moq-json | 4903 / 5237 | moq-transcode | 2758 / 2987 | -| moq-uring | 4643 / 5391 | moq-v4l | 9 / 1457 | -| moq-video | 6431 / 9965 | | | - -| JS package | Lines | Unloaded files | JS package | Lines | Unloaded files | -| --- | --- | --- | --- | --- | --- | -| common | 200 / 250 | 5 | loc | 127 / 145 | 0 | -| net | 17812 / 23595 | 4 | msf | 103 / 130 | 0 | -| p2p | 293 / 303 | 1 | video | 277 / 336 | 0 | -| clock | 286 / 300 | 1 | watch | 3075 / 4547 | 31 | -| pattern | 435 / 523 | 0 | publish | 2773 / 3308 | 22 | -| auth | 720 / 783 | 2 | room | 493 / 530 | 1 | -| e2ee | 568 / 569 | 2 | signals | 565 / 747 | 3 | -| flate | 233 / 333 | 0 | wasm | 0 / 0 | 0 | -| json | 831 / 1141 | 0 | moq-boy | 155 / 194 | 8 | -| hang | 2044 / 2859 | 1 | | | | - -These are not acceptance thresholds. Compare the next run's report against -them and explain what moved. - -### What m0 found - -Two shipped bugs hid in code no test executed: echo cancellation never -received its playback reference on a 44.1 kHz output (the resampled tap was -never marked read), and two DOM helpers dropped children or registered -cleanup against an emptied fragment. Both were found by running code that -existed but had no caller, which makes ignored and unloaded code the first -place to look. - -- Every `#[ignore]` carries a reason. Ignored benchmarks run in - `sh/gh/nightly.sh rust-test`; the m0 run gave a nightly recipe to every - other one a hosted runner can host: `rs quic-stress`, `rs audio-devices` - (a private PulseAudio null sink), `rs multicast` (mDNS loops back in the - kernel), and `rs render-sw` (wgpu on Mesa lavapipe). An `#[ignore]` whose - caller is neither a recipe nor a quest is the first finding. -- Hardware-only tests map to [GPU CI](/quest/m2/gpu-ci.md) (NVIDIA, and the - host Vulkan driver the VA-API renderer import tests also need inside the - Nix shell), [Video hardware validation](/quest/m3/video-hardware.md) - (Windows, Metal, cameras, portals), [Windows.Graphics.Capture](/quest/m2/capture-wgc.md), - [MediaCodec on an emulator](/quest/m2/android-mediacodec-ci.md), and - [V4L2 virtual camera](/quest/m2/v4l2-virtual-camera.md). Check whether - those landed before re-auditing the same tests. -- Interop: [Bindings in the interop matrix](/quest/m2/interop-bindings.md) - adds Kotlin, Swift, and Dart; until it lands, those wrappers' network - delivery is untested. -- JS: `` runs in the interop player, and `` - has a Chromium test in `just test harness`. The React signals adapter is - still untested; it needs a DOM renderer. The remaining unloaded files are - type-only, worklets covered by the browser lanes, or formatters. -- The last 25 `fix` commits before the m0 run all carried a regression test - or were compile/link checks. Repeat that sample from the m0 PR forward. diff --git a/quest/m2/README.md b/quest/m2/README.md index 502885431fa..a9657ce3e04 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -53,6 +53,7 @@ ranked just below deterministic TS legs, which it requires. - [AudioToolbox encode](/quest/m2/audio-encode-audiotoolbox.md) - macOS and iOS encode AAC-LC - [CMAF surround Opus](/quest/m2/cmaf-opus-surround.md) - a released mp4-atom carries the `dOps` channel mapping table, and fMP4 import and export carry it through the OpusHead - [GPU CI](/quest/m2/gpu-ci.md) - NVIDIA tests run nightly on real hardware, and `just rs nvidia` runs them locally instead of skipping +- [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) - the ignored VA-API renderer import tests run in `just rs gpu` on AMD and Intel and fail instead of skipping - [Vendor-free CARLA bridge](/quest/m2/carla-bridge.md) - the CARLA demo's GPU bridge lets moq-video pick the encoder, and its stand-in test runs on AMD as well as NVIDIA - [V4L2 virtual camera](/quest/m2/v4l2-virtual-camera.md) - V4L2 capture runs nightly against the `vivid` virtual driver instead of only on a developer's camera - [Probe](/quest/m2/probe/README.md) - a session probes up to the bitrate it wants to send or receive, so a stopped simulcast rendition resumes and a viewer up-switches @@ -75,6 +76,7 @@ ranked just below deterministic TS legs, which it requires. - [Android capture](/quest/m2/mobile-capture-android.md) - Rust captures through NDK/JNI on Android, reusing the existing codecs and setting the catalog rotation - [Time stretch](/quest/m2/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent - [Audio join budget](/quest/m2/audio-join-budget.md) - `moq-audio` joining at the latest group loses no group a publisher bursts before its budget widens +- [PulseAudio capture periods](/quest/m2/pulseaudio-capture-period.md) - capture on cpal's PulseAudio host arrives in short periods, not the server's 2 s default fragment, proven on the null sink's monitor - [Bindings size profile](/quest/m2/ffi-size-profile.md) - a benchmark decides whether the moq-ffi builds ship at opt-level "s", which halves the dylib - [The catalog publish path compiles once](/quest/m2/catalog-publish-once.md) - the catalog producer and its JSON diff codegen in `moq-mux` instead of once per importer crate, about 1 MiB of the CLI - [Zero-copy byte returns](/quest/m2/ffi-bytes-return.md) - returned payloads reach the host without a copy, if the payload numbers justify it @@ -176,3 +178,4 @@ ranked just below deterministic TS legs, which it requires. - [Relay metrics through prometheus-client](/quest/m2/relay-prometheus-client.md) - `/metrics` renders through the maintained client with the same series - [Dead routes on IETF name their cause](/quest/m2/js-ietf-dead-route-error.md) - a JS subscription on a dead moq-transport session ends with the session error, not `unauthorized` - [Error cleanups](/quest/m2/error-cleanups.md) - delete the unused `Error::SessionClosed`; raw transport failures already map to `Unroutable` +- [Test coverage audit](/quest/m2/audit-test-coverage.md) - repeat the coverage audit against the m1 baselines after the next release ships diff --git a/quest/m2/audit-test-coverage.md b/quest/m2/audit-test-coverage.md new file mode 100644 index 00000000000..45b76199ca9 --- /dev/null +++ b/quest/m2/audit-test-coverage.md @@ -0,0 +1,143 @@ +# [M] Test coverage audit + +## Goal + +Behavior that matters but no test exercises is covered or planned, and every +test runs in CI. + +## Plan + +Recurs once per major release (decided 2026-10-19). The PR that finishes +this run also adds the next one with the same file name at the end of the next +milestone's `Required` (m0 to m1, m1 to m2). The successor keeps the Goal +and this paragraph; the rest of its Plan is rewritten with what this run +learned: baselines, results, and what to check first. A successor placed in m2 +also lists the [release cut](/quest/m2/release/cut.md) under `Required`, so it +waits for the release in flight to ship. Only `/quest-delete` ends the +recurrence. Fixes land in the run's PR, one commit each, rather than as follow-ups. +Only a change that needs a maintainer decision (public API, scope) or is +independently landable and too large to review alongside the rest becomes a +follow-up through `/quest-plan`. + +### Tools + +Nightly keeps both reports as artifacts: `just rs coverage` (cargo-llvm-cov +over nextest, the native workspace at default features, both fuzz packages +and the native moq-wasm stub excluded) and `just js coverage` (Bun's own +coverage, one process per package, line hits unioned across processes). +Start from those artifacts (`gh run download -n rust-coverage` +and `-n js-coverage`) rather than re-running the instrumented workspace, +which is expensive on a shared machine. + +- `just rs coverage ` is not comparable to the workspace table: + without other packages' feature unification it compiles less of the + crate. +- Default features hide feature-gated tests that run nightly in the + `rs features` leg (`--all-features`). Check that leg passed before calling + a low file untested: on 2026-10-20 it stopped at a moq-auth example's lint, + so nothing after it ran. +- Bun 1.4.2 undercounts a module loaded again with a query suffix. Verify an + apparent JS gap before acting on it, and never merge packages into one + `bun test` process. +- The most productive scan in m1 was public functions whose signature line + the lcov report never hit: walk `DA:` records for lines matching + `pub fn`/`pub(crate) fn` outside test, bench, and fuzz files. It found the + binding facades below in minutes. Per-file uncovered line counts mostly + point at GPU code and generated bindings instead. +- When sampling `fix` commits for regression tests, count + `#[moq_clock::test]` as well as `#[test]` and `#[tokio::test]`. + +### m1 baselines + +From nightly run 39421743227 at `f13283b302` (2026-10-20): 9,050 native +tests passed with 33 ignored, and 18 JS package processes ran 3,924 tests. +Lines covered / measured: + +| Rust crate | Lines | Rust crate | Lines | +| --- | --- | --- | --- | +| hang | 2848 / 2924 | moq-loc | 141 / 153 | +| kio | 2045 / 2137 | moq-msf | 674 / 684 | +| moq-archive | 4353 / 4484 | moq-mux | 64372 / 66631 | +| moq-audio | 5455 / 5716 | moq-net | 83698 / 87593 | +| moq-auth | 5187 / 5313 | moq-nvenc | 3445 / 7771 | +| moq-bench | 1614 / 2068 | moq-pattern | 931 / 960 | +| moq-boy | 169 / 707 | moq-quic | 21696 / 22815 | +| moq-c | 8946 / 9516 | moq-relay | 10952 / 11607 | +| moq-cli | 6431 / 7566 | moq-room | 432 / 460 | +| moq-clock | 1858 / 1921 | moq-rtc | 2506 / 2882 | +| moq-e2ee | 679 / 759 | moq-rtmp | 6826 / 7638 | +| moq-ffi | 10819 / 11749 | moq-rtsp | 1033 / 1057 | +| moq-flate | 839 / 892 | moq-shaper | 2149 / 2219 | +| moq-gst | 6362 / 6740 | moq-sock | 1328 / 1486 | +| moq-hls | 8473 / 8857 | moq-srt | 1286 / 1481 | +| moq-ice | 319 / 361 | moq-stats | 4414 / 4601 | +| moq-json | 4897 / 5231 | moq-tokio | 15962 / 18005 | +| moq-uring | 4802 / 5558 | moq-transcode | 2997 / 3239 | +| moq-v4l | 9 / 1457 | moq-video | 6462 / 9988 | +| carla bridge (demo) | 923 / 1676 | | | + +| JS package | Lines | Unloaded files | JS package | Lines | Unloaded files | +| --- | --- | --- | --- | --- | --- | +| common | 200 / 250 | 5 | loc | 127 / 145 | 0 | +| net | 19248 / 25149 | 4 | msf | 103 / 130 | 0 | +| p2p | 293 / 303 | 1 | video | 277 / 336 | 0 | +| clock | 310 / 333 | 1 | watch | 3123 / 4596 | 29 | +| pattern | 435 / 523 | 0 | publish | 2773 / 3308 | 22 | +| auth | 725 / 788 | 2 | room | 493 / 530 | 1 | +| e2ee | 568 / 569 | 2 | signals | 580 / 763 | 2 | +| flate | 233 / 333 | 0 | wasm | 0 / 0 | 0 | +| json | 804 / 1093 | 0 | moq-boy | 155 / 194 | 8 | +| hang | 2076 / 2899 | 1 | | | | + +These are not acceptance thresholds. Compare the next run's report against +them and explain what moved. Against m0, every crate held its ratio: the +growth (moq-net +3.4k measured lines, moq-auth's in-band auth, moq-clock) +arrived tested, and moq-cli and @moq/json shrank. The m1 run's new tests +land after this baseline, so the next report should show moq-mux's +`import/track.rs` and moq-ffi's `video.rs` and `media.rs` rise. + +### What m1 found + +- `just rs audio-devices`, added by the m0 run, failed all five tests on its + first nightly. Two causes: cpal's PulseAudio host opened playback with the + server's default 2 s buffer (3.96 s end to end), and the tests assumed a + device starts within 100 ms while PulseAudio's idle null sink renders up to + two seconds ahead. Playback now asks PulseAudio for 20 ms periods, and the + tests wait until the device's clock moves. Watch that the first nightly + after this run is green, and keep any new device test behind the same wait. + Capture likely shares the buffer default: [PulseAudio capture periods](/quest/m2/pulseaudio-capture-period.md). +- The binding facades were the largest untested public surface: + `moq_mux::import::Track` had decoded only Avc3, Hvc1, VP8, and the audio + codecs other than MP3; `TrackStream` (the FFI raw-stream producer) had + never decoded a byte; and `MoqVideoEncoder` (`moq.video.Encoder` in every + wrapper) had no test in any language. Its doc and the Swift copy also + claimed `finish` drops the trailing access unit, which it does not. +- `just rs gpu` could not load a Vulkan loader inside the Nix shell on a + non-NixOS host. AMD and Intel now run through the flake's Mesa; NVIDIA + stays with [GPU CI](/quest/m2/gpu-ci.md). +- The VA-API renderer import tests have no caller: the m0 run mapped them to + GPU CI, which covers only NVIDIA. [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) + owns them now. +- Every `#[ignore]` has a reason, and each one's caller is a recipe or a + quest. Several reasons name neither (the WGC, MediaCodec, and NVIDIA + Vulkan/CUDA tests), but their quests name the tests. Re-run the check by + test name, not by reason text. +- All 33 `fix` commits from the m0 PR to `7d2a91fe66` that changed code + carried a regression test; the rest were dependency, CI, Nix, or bench + compile fixes. + +### What to check first + +- The nightly legs that run tests outside `just check`: `rs audio-devices`, + `rs multicast`, `rs render-sw`, `rs quic-stress`, `rs features`, and the + `rust-test` benchmark group. A red leg hides everything after it. +- Public functions never executed, by the scan above, starting with moq-c, + moq-ffi, and moq-mux's import facades. +- Whether [Bindings in the interop matrix](/quest/m2/interop-bindings.md), + [GPU CI](/quest/m2/gpu-ci.md), [V4L2 virtual camera](/quest/m2/v4l2-virtual-camera.md), + and [MediaCodec on an emulator](/quest/m2/android-mediacodec-ci.md) landed, + before re-auditing the tests they own. + +## Required + +- [Release cut](/quest/m2/release/cut.md) - the release in flight ships before this run starts diff --git a/quest/m2/pulseaudio-capture-period.md b/quest/m2/pulseaudio-capture-period.md new file mode 100644 index 00000000000..dbe3099e026 --- /dev/null +++ b/quest/m2/pulseaudio-capture-period.md @@ -0,0 +1,32 @@ +# [S] PulseAudio capture periods + +## Goal + +Capture through cpal's PulseAudio host (moq-audio's opt-in `pulseaudio` +feature) delivers microphone audio in periods of tens of milliseconds, not +the server's default fragment, and a capture test in `just rs audio-devices` +proves it against the private null sink's monitor source. + +## Plan + +Found by the m1 [coverage audit](/quest/m2/audit-test-coverage.md). Playback +on that host asked for `BufferSize::Default`, which cpal's PulseAudio host +maps to the server's default buffer attributes: a 2 s `tlength`, 3.96 s end +to end. The audit fixed playback (`playback::device::buffer_size` asks for +20 ms periods). Capture (`rs/moq-audio/src/capture.rs`, `resolve`) also opens +with the default buffer size, and PulseAudio's default record `fragsize` is +the same 2 s, so capture likely arrives in 2 s bursts. Echo cancellation +needs both halves within its delay window, so this matters for `aec` too. + +- Reproduce first: capture `moq-null.monitor` under `test/lib/pulse.py` + while a playback engine plays a tone, and measure the callback cadence. + The pulse log's `Requested fragsize` line shows what the stream asked for. +- If it reproduces, share the playback rule (only the PulseAudio host, a + 20 ms period clamped to the reported range) between capture and playback + rather than copying it, since `capture` builds without `playback`. +- Add the capture test to the `audio-devices` recipe's selection, waiting + for the device to start as the playback tests do. + +## Related + +- [A cpal release loads libasound at runtime](/quest/m2/cpal-alsa-runtime.md) - would make the PulseAudio host the default on hosts without libasound diff --git a/quest/m2/vaapi-render-tests.md b/quest/m2/vaapi-render-tests.md new file mode 100644 index 00000000000..b8bbd5de68b --- /dev/null +++ b/quest/m2/vaapi-render-tests.md @@ -0,0 +1,39 @@ +# [S] VA-API renderer tests + +## Goal + +The four ignored VA-API renderer import tests in +`rs/moq-video/src/render/renderer.rs` (`the_nv12_dmabuf_import_matches_the_cpu_path`, +`the_i420_dmabuf_import_matches_the_cpu_path`, +`decoded_frames_reach_the_gpu_without_a_download`, and +`recycled_decoder_surfaces_draw_the_latest_picture`) and +`packed_dmabuf_renders_through_vulkan` run in `just rs gpu` on AMD and Intel +hosts and fail there instead of skipping. + +## Plan + +Found by the m1 [coverage audit](/quest/m2/audit-test-coverage.md): no recipe +or quest named these tests. The m0 run mapped them to [GPU CI](/quest/m2/gpu-ci.md), +which covers only NVIDIA. + +- `just rs gpu` already drives AMD and Intel through the flake's Mesa + (`sh/rs/gpu.sh`). Run with that Mesa on 2026-10-20 (an Intel iGPU plus an + AMD dGPU): the decode tests printed "skipping: no VA-API H.264 + decoder", I420 skipped for "this driver does not do YU12 DMA-BUFs", and + NV12 failed with "no NV12 DMA-BUF could be allocated to import". Point + libva at the flake's drivers (Mesa's radeonsi for AMD, intel-media-driver's + iHD for Intel) and find out which of those remain real driver limits. +- Inside `just rs gpu` a missing driver is a failure, matching the rest of + the recipe. A driver limit the test cannot work around (YU12 on one + vendor) stays a skip that names it, outside the recipe's selection. +- `packed_dmabuf_renders_through_vulkan` needs a PipeWire desktop as well; + if the recipe cannot provide one, leave it to + [Video hardware validation](/quest/m3/video-hardware.md) and say so in its + reason. +- No hosted runner has these GPUs, so this stays a local recipe until a + GPU runner exists ([GPU CI](/quest/m2/gpu-ci.md) owns that decision). + +## Related + +- [GPU CI](/quest/m2/gpu-ci.md) - the NVIDIA half of `just rs gpu` +- [VA-API encodes an external Vulkan image](/quest/m2/vaapi-vulkan-import.md) - the encode side of the same VA-API surfaces diff --git a/rs/moq-audio/src/aec.rs b/rs/moq-audio/src/aec.rs index b19552a05ea..8ed9dd2d350 100644 --- a/rs/moq-audio/src/aec.rs +++ b/rs/moq-audio/src/aec.rs @@ -1143,7 +1143,11 @@ mod tests { let mut energy = 0.0f64; let mut buf = vec![0.0f32; REFERENCE_FRAME * REFERENCE_CHANNELS]; - for _ in 0..20 { + // Until the tap hears it rather than for a fixed count: writes are dropped + // until the device first reads, and a device can take seconds to start + // (PulseAudio's idle null sink renders two seconds ahead). + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + while energy <= 1.0 && tokio::time::Instant::now() < deadline { let _ = sink.write(&tone).expect("write"); tokio::time::sleep(std::time::Duration::from_millis(100)).await; diff --git a/rs/moq-audio/src/playback/device.rs b/rs/moq-audio/src/playback/device.rs index 680e6a80f7b..d3156e77ae4 100644 --- a/rs/moq-audio/src/playback/device.rs +++ b/rs/moq-audio/src/playback/device.rs @@ -148,6 +148,26 @@ pub(super) fn negotiate(device: &cpal::Device) -> Result cpal::BufferSize { + #[cfg(feature = "pulseaudio")] + if let (Ok(id), cpal::SupportedBufferSize::Range { min, max }) = (device.id(), supported.buffer_size()) + && id.host() == cpal::HostId::PulseAudio + { + return cpal::BufferSize::Fixed((supported.sample_rate() / 50).clamp(*min, *max)); + } + cpal::BufferSize::Default +} + /// Pick the best of the stream configurations a device reports. /// /// Split out from [`negotiate`] so it can be tested without a device: the diff --git a/rs/moq-audio/src/playback/driver.rs b/rs/moq-audio/src/playback/driver.rs index 03a1dc9d47a..897b21cf417 100644 --- a/rs/moq-audio/src/playback/driver.rs +++ b/rs/moq-audio/src/playback/driver.rs @@ -777,7 +777,8 @@ impl Driver { let supported = super::device::negotiate(&device)?; let format = supported.sample_format(); - let config: cpal::StreamConfig = supported.into(); + let mut config = supported.config(); + config.buffer_size = super::device::buffer_size(&device, &supported); let rate = config.sample_rate; let channels = config.channels as usize; diff --git a/rs/moq-audio/tests/integration/playback.rs b/rs/moq-audio/tests/integration/playback.rs index d2c141a0424..e1362a5a995 100644 --- a/rs/moq-audio/tests/integration/playback.rs +++ b/rs/moq-audio/tests/integration/playback.rs @@ -42,6 +42,33 @@ fn input() -> playback::Input { input } +/// Open a device with [`open_any`] and wait until it pulls on its own clock. +/// +/// A sink's writes are dropped until the device first reads them, and a device +/// can take a while to start: PulseAudio's null sink, while idle, renders up +/// to two seconds ahead and only picks a new stream up after that. Without the +/// wait, every test here passed or failed on how that startup fell. +async fn open() -> playback::Engine { + let engine = open_any().await; + let mut observer = engine.observe().expect("an observer"); + + // The first pull fills the device's buffer at once, so only a clock that + // keeps moving after it shows the device is playing. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut first = None; + loop { + let clock = observer.read().expect("an observation").clock; + if !clock.is_zero() && clock >= *first.get_or_insert(clock) + Duration::from_millis(100) { + return engine; + } + assert!( + tokio::time::Instant::now() < deadline, + "the device never started pulling" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + /// Open `$MOQ_AUDIO_DEVICE`, the system default, or the first device that will /// have us. /// @@ -51,7 +78,7 @@ fn input() -> playback::Input { /// keeps these tests about our code rather than about the box's audio routing, /// and the environment variable pins a specific device when you want to hear /// the result rather than only meter it. -async fn open() -> playback::Engine { +async fn open_any() -> playback::Engine { if let Ok(id) = std::env::var("MOQ_AUDIO_DEVICE") { let mut config = playback::Config::default(); config.device = Some(id); @@ -102,6 +129,33 @@ async fn plays_a_tone_through_the_default_device() { assert!(drained, "the device never drained the queue"); } +/// The device runs a period or two ahead of the speaker, not seconds. +/// +/// PulseAudio's unconfigured buffer is two seconds, filled in bursts, so a +/// sink's first writes were dropped until the next burst read it, and every +/// test above passed or failed on how the bursts happened to fall. +#[tokio::test] +#[ignore = "needs an output device; run by just rs audio-devices"] +async fn the_device_buffers_periods_not_seconds() { + let engine = open().await; + let mut observer = engine.observe().expect("an observer"); + + let mut latency = None; + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(50)).await; + latency = observer.read().expect("an observation").latency; + if latency.is_some() { + break; + } + } + + let latency = latency.expect("the host never reported a latency"); + assert!( + latency < Duration::from_millis(500), + "the device runs {latency:?} ahead of the speaker" + ); +} + /// Two sinks on one device, which is the case a call with several participants /// hits and the reason `Engine` and `Sink` are separate types. #[tokio::test] diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 2ada4028234..c9262b2a458 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -1066,11 +1066,7 @@ impl MoqMediaTrackStreamProducer { Ok(()) } - /// Finalize the track. - /// - /// The importer emits each access unit when the *next* one's start code arrives, so a trailing - /// access unit with no following delimiter (e.g. the last frame at EOF) is not emitted. This - /// matches moq-cli's stdin path. + /// Finalize the track, publishing the last access unit still waiting for a following start code. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index f64b91adc8e..7328cbc9f44 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2208,6 +2208,39 @@ async fn video_publish_named_track() { assert!(catalog.video.contains_key("hd"), "catalog: {:?}", catalog.video.keys()); } +/// The raw-stream producer frames Annex-B written in arbitrary chunks, and +/// `finish` publishes the last access unit even with no start code after it. +#[tokio::test] +async fn track_stream_producer_publishes_its_last_access_unit() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let consumer = broadcast.consume().unwrap(); + let stream = MoqMediaTrackStreamProducer::video( + &broadcast, + MoqMediaTarget::Named { + name: Some("video".into()), + }, + video_init(MoqVideoFormat::Avc3, Vec::new()), + ) + .unwrap(); + let track = consumer.subscribe_track("video".into(), None).await.unwrap(); + + let idr = [0x65, 0x88, 0x84, 0x21]; + let mut bytes = h264_init(); + bytes.extend_from_slice(&[0, 0, 0, 1]); + bytes.extend_from_slice(&idr); + for chunk in bytes.chunks(5) { + stream.write(chunk.to_vec()).unwrap(); + } + stream.finish().unwrap(); + + let frame = tokio::time::timeout(TIMEOUT, track.read_frame()) + .await + .expect("timed out waiting for the access unit") + .unwrap() + .expect("finish dropped the last access unit"); + assert!(frame.payload.ends_with(&idr), "{:02x?}", frame.payload); +} + #[tokio::test] async fn requested_track_keeps_its_name() { let broadcast = MoqBroadcastProducer::new().unwrap(); diff --git a/rs/moq-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index abaf4ca0b29..2b06f339e04 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -1016,6 +1016,50 @@ mod decode_tests { mod finish_tests { use super::*; + /// The codec-only encoder every wrapper exposes (`moq.video.Encoder` and + /// friends): a keyframe opens the stream, `cut` forces the next one, and + /// `finish` closes it. No test in any language had driven it. + #[test] + fn encoder_emits_keyframes_where_asked() { + let (width, height) = (64u32, 48u32); + let encoder = MoqVideoEncoder::new( + MoqVideoEncoderInput { + format: MoqVideoPixelFormat::I420, + width, + height, + framerate: 30, + }, + MoqVideoEncoderOutput { + codec: MoqVideoCodec::H264, + track: None, + bitrate: Some(500_000), + gop: None, + kind: MoqVideoEncoderKind::Software, + }, + ) + .unwrap(); + let picture = |i: u64| MoqVideoFrame { + timestamp_us: i * 33_333, + data: vec![(i * 40) as u8; (width * height * 3 / 2) as usize], + }; + + let mut packets = Vec::new(); + for i in 0..3 { + packets.extend(encoder.encode(picture(i)).unwrap()); + } + packets.extend(encoder.flush().unwrap()); + encoder.set_bitrate(250_000).unwrap(); + encoder.cut().unwrap(); + packets.extend(encoder.encode(picture(3)).unwrap()); + packets.extend(encoder.finish().unwrap()); + + let keyframes: Vec = packets.iter().filter(|p| p.keyframe).map(|p| p.timestamp_us).collect(); + assert_eq!(packets.len(), 4, "one access unit per picture"); + assert_eq!(keyframes, [0, 3 * 33_333], "keyframes at the start and the cut"); + assert!(packets.iter().all(|p| !p.data.is_empty()), "an empty access unit"); + assert!(matches!(encoder.encode(picture(4)), Err(MoqError::Closed))); + } + #[tokio::test] async fn finish_ends_the_track_after_a_rate_follower_panic() { let estimate = moq_net::bandwidth::Producer::new(); diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 1da51bf77fc..4647726e3ce 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -1389,4 +1389,193 @@ mod tests { import.finish().unwrap(); } + + const H264_IDR: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + const H265_IDR: &[u8] = &[0x26, 0x01, 0x80, 0xaa]; + + fn annexb(nals: &[&[u8]]) -> Vec { + nals.iter().flat_map(|nal| [&[0, 0, 0, 1], *nal].concat()).collect() + } + + fn length_prefixed(nal: &[u8]) -> Vec { + [&(nal.len() as u32).to_be_bytes()[..], nal].concat() + } + + /// `av1.mp4`'s av1C, and a temporal unit of its sequence header and a key frame. + fn av1_init_and_keyframe() -> (Vec, Vec) { + let mp4 = include_bytes!("../container/fmp4/test_data/av1.mp4"); + let mut cursor = std::io::Cursor::new(&mp4[..]); + let moov = loop { + if let mp4_atom::Any::Moov(moov) = ::decode(&mut cursor).unwrap() { + break moov; + } + }; + let mp4_atom::Codec::Av01(av01) = &moov.trak[0].mdia.minf.stbl.stsd.codecs[0] else { + panic!("expected av01"); + }; + let mut av1c = Vec::new(); + mp4_atom::Encode::encode(&av01.av1c, &mut av1c).unwrap(); + // Skip the box header: the importer takes the record itself. + let av1c = av1c[8..].to_vec(); + + let mut tu = vec![0x12, 0x00]; + tu.extend_from_slice(&av01.av1c.config_obus); + tu.extend_from_slice(&[0x32, 0x01, 0x10]); + (av1c, tu) + } + + /// Subscribe to `name` and read its first frame. + async fn first_frame(consumer: &moq_net::broadcast::Consumer, name: &str) -> crate::container::Frame { + let track = consumer.track(name).unwrap().subscribe(None).await.unwrap(); + let mut media = crate::container::Consumer::new( + track, + crate::catalog::hang::Container::Legacy(crate::container::Kind::Data), + ); + tokio::time::timeout(Duration::from_secs(1), media.read_frame()) + .await + .expect("a frame") + .unwrap() + .expect("the track ended without a frame") + } + + /// Every video format a binding can hand [`Track::video`] lands a keyframe on + /// its track. The facade dispatches per codec, and before this only Avc3, + /// Hvc1, and VP8 had ever decoded through it. + #[tokio::test(start_paused = true)] + async fn every_video_format_delivers_a_keyframe() { + use crate::codec::{h264, h265}; + + let sps = &h264_init()[4..29]; + let avcc = [ + &[1, sps[1], sps[2], sps[3], 0xff, 0xe1][..], + &(sps.len() as u16).to_be_bytes(), + sps, + &[1], + &(h264::fixtures::PPS.len() as u16).to_be_bytes(), + h264::fixtures::PPS, + ] + .concat(); + let (av1c, av1_keyframe) = av1_init_and_keyframe(); + let vp9_keyframe = vec![0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; + + let cases = [ + (VideoFormat::Avc1, avcc, length_prefixed(H264_IDR)), + ( + VideoFormat::Hev1, + Vec::new(), + annexb(&[h265::fixtures::VPS, h265::fixtures::SPS, h265::fixtures::PPS, H265_IDR]), + ), + (VideoFormat::Av01, av1c, av1_keyframe), + (VideoFormat::Vp9, Vec::new(), vp9_keyframe), + ]; + for (format, init, keyframe) in cases { + let (broadcast, catalog) = new_broadcast(); + let consumer = broadcast.consume(); + let request = broadcast.reserve_track("video").unwrap(); + let mut import = Track::video(request, catalog.reserve(), VideoInit::new(format, init)).unwrap(); + + import + .decode(keyframe, Some(Timestamp::from_micros(1_000).unwrap())) + .unwrap(); + let frame = first_frame(&consumer, "video").await; + assert!(frame.keyframe, "{format}: the first frame opens a group"); + assert_eq!(frame.timestamp, Timestamp::from_micros(1_000).unwrap(), "{format}"); + assert!( + catalog.snapshot().video.renditions.contains_key("video"), + "{format}: no rendition" + ); + import.finish().unwrap(); + } + } + + /// MP3 through [`Track::audio`]: the catalog comes from the frame header, and + /// a group spans frames until the caller cuts it. No test had published MP3. + #[tokio::test(start_paused = true)] + async fn mp3_groups_span_frames_until_a_cut() { + // MPEG-1 Layer III, 128 kbps, 44.1 kHz, joint stereo. + let header = [0xFF, 0xFB, 0x90, 0x44]; + let (broadcast, catalog) = new_broadcast(); + let consumer = broadcast.consume(); + let request = broadcast.reserve_track("audio").unwrap(); + let mut import = Track::audio( + request, + catalog.reserve(), + AudioInit::new(AudioFormat::Mp3, header.to_vec()), + ) + .unwrap(); + + let audio = catalog.snapshot().audio.renditions["audio"].clone(); + assert_eq!((audio.sample_rate, audio.channel_count), (44_100, 2)); + + // Delay-tolerant, so the first group is still delivered once the second exists. + let replay = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30)); + let mut track = consumer.track("audio").unwrap().subscribe(Some(replay)).await.unwrap(); + for i in 0..4u64 { + if i == 2 { + import.cut(None).unwrap(); + } + let frame = [&header[..], &[i as u8; 8]].concat(); + import + .decode(frame, Some(Timestamp::from_micros(i * 26_122).unwrap())) + .unwrap(); + } + import.finish().unwrap(); + + // Frames carrying audio per group, ignoring any empty end-of-group marker. + let mut groups = Vec::new(); + while let Some(mut group) = track.recv_group().await.unwrap() { + let mut frames = 0; + while let Some(frame) = group.read_frame().await.unwrap() { + frames += usize::from( + !hang::container::Frame::decode(frame.payload) + .unwrap() + .payload + .is_empty(), + ); + } + groups.push(frames); + } + assert_eq!(groups, [2, 2]); + } + + /// [`TrackStream`] finds the frames in a raw byte stream however it is chunked, + /// and `finish` flushes the last one. Nothing had decoded through it before. + #[tokio::test(start_paused = true)] + async fn track_stream_splits_a_chunked_byte_stream() { + use crate::codec::h265; + + let (_, av1_keyframe) = av1_init_and_keyframe(); + let cases = [ + (VideoFormat::Avc3, [h264_init(), annexb(&[H264_IDR])].concat(), H264_IDR), + ( + VideoFormat::Hev1, + annexb(&[h265::fixtures::VPS, h265::fixtures::SPS, h265::fixtures::PPS, H265_IDR]), + H265_IDR, + ), + ( + VideoFormat::Av01, + av1_keyframe.clone(), + &av1_keyframe[av1_keyframe.len() - 3..], + ), + ]; + for (format, stream, last) in cases { + let (broadcast, catalog) = new_broadcast(); + let consumer = broadcast.consume(); + let request = broadcast.reserve_track("video").unwrap(); + let mut import = + TrackStream::video(request, catalog.reserve(), VideoInit::new(format, Vec::new())).unwrap(); + + for chunk in stream.chunks(3) { + import.decode(chunk).unwrap(); + } + import.finish().unwrap(); + + let frame = first_frame(&consumer, "video").await; + assert!(frame.keyframe, "{format}: the first frame opens a group"); + assert!( + frame.payload.ends_with(last), + "{format}: the frame ends with its picture" + ); + } + } } diff --git a/sh/rs/gpu.sh b/sh/rs/gpu.sh index 72e8b956984..cf7899e1c4e 100755 --- a/sh/rs/gpu.sh +++ b/sh/rs/gpu.sh @@ -27,9 +27,23 @@ done } for vendor in "${!vendors[@]}"; do - MOQ_GPU_VENDOR="$vendor" just rs test -p moq-video --run-ignored only -E 'test(vulkan_gpu_device)' case "$vendor" in - 0x10de) sh/rs/vulkan-cuda.sh ;; - 0x1002 | 0x8086) echo "GPU $vendor: Vulkan driver verified; encoder hardware tests arrive with its backend" ;; + 0x10de) + MOQ_GPU_VENDOR="$vendor" just rs test -p moq-video --run-ignored only -E 'test(vulkan_gpu_device)' + sh/rs/vulkan-cuda.sh + ;; + 0x1002 | 0x8086) + # The Nix shell has no Vulkan loader, and the host's Mesa links the host's + # libc, so drive the GPU through the flake's own Mesa, as `just rs render-sw` does. + mesa=$(nix build --inputs-from . --no-link --print-out-paths nixpkgs#mesa) + loader=$(nix build --inputs-from . --no-link --print-out-paths nixpkgs#vulkan-loader) + case "$vendor" in + 0x1002) icd=("$mesa"/share/vulkan/icd.d/radeon_icd.*.json) ;; + 0x8086) icd=("$mesa"/share/vulkan/icd.d/intel_icd.*.json) ;; + esac + LD_LIBRARY_PATH="$loader/lib" VK_DRIVER_FILES="${icd[0]}" MOQ_GPU_VENDOR="$vendor" \ + just rs test -p moq-video --run-ignored only -E 'test(vulkan_gpu_device)' + echo "GPU $vendor: Vulkan driver verified; encoder hardware tests arrive with its backend" + ;; esac done diff --git a/swift/Sources/Moq/Media.swift b/swift/Sources/Moq/Media.swift index ca86c5b8fe6..a2390e3f693 100644 --- a/swift/Sources/Moq/Media.swift +++ b/swift/Sources/Moq/Media.swift @@ -335,8 +335,8 @@ public enum Media { try ffi.write(payload: payload) } - /// Finalize the track. A trailing access unit with no following delimiter is - /// not emitted (matches the moq-cli stdin path). + /// Finalize the track, publishing the last access unit still waiting for a + /// following start code. public func finish() throws { try ffi.finish() }