From 5e7e400f8f41cde92aa8f7d2399dbd524b57f758 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 9 Aug 2026 20:52:51 +0100 Subject: [PATCH 1/4] asks: std.audio needs a PCM source, so a decoder can supply samples From the aether-ui video line. contrib/avcodec removed the intermediate file for VIDEO -- frames go straight from FFmpeg into a vg raster region -- but audio still cannot make the same trip, so video_frame requires a hand-extracted sidecar WAV: 20 MB for a 21 MB source, a manual pre-step before playback, and no workaround at all for a live source. Measured the gap rather than assuming it, and it is not where the API name suggests. load_wav is ma_decoder_init_memory, which sniffs the format, so it ALREADY accepts MP3 (duration_ms=6013 on a test file). It rejects MP4 and raw AAC. So the missing thing is not "formats beyond WAV" -- it is that every entry point takes an encoded container miniaudio can demux itself, with no way in for samples a DIFFERENT decoder produced. Noted separately that the load_wav name understates what it does. Asks for load_pcm(data, length, rate, channels, format) as the simple shape, and sketches a streaming push variant for live sources, flagging that position_ms must then report the DEVICE position since that is the clock video chases. Deliberately marked not-urgent: A/V sync is proven and correct today (video tracks audio.position_ms within 3 ms on a real 720p/5.1 clip), and nothing about the clock relationship changes with where samples come from. This is packaging, not architecture -- the sidecar is just the last hand-cranked step in an otherwise in-process pipeline. Co-Authored-By: Claude Fable 5 --- asks/pcm-please.md | 120 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 asks/pcm-please.md diff --git a/asks/pcm-please.md b/asks/pcm-please.md new file mode 100644 index 00000000..916ecdc6 --- /dev/null +++ b/asks/pcm-please.md @@ -0,0 +1,120 @@ +# std.audio: a PCM source, so a decoder can supply samples + +**From:** the aether-ui video line (2026-08-09) · **Where it bit:** +`apps/video_frame`, which plays an MP4 in a UI frame with A/V sync working — +but takes its audio from a hand-extracted sidecar WAV. + +## The ask, in one line + +A way to play PCM samples that the caller already has, rather than only +container bytes that miniaudio can parse itself: + +``` +audio.load_pcm(data, length, sample_rate, channels, format) -> ptr! +``` + +Everything downstream — `play`, `pause`, `position_ms`, `duration_ms`, +`seek_ms`, `volume` — should work on the returned source exactly as it does +for `load_wav` today. `position_ms` in particular is the A/V-sync master +clock, and it is the reason this matters. + +## Why the current surface cannot do it + +`load_wav` is better than its name: it is `ma_decoder_init_memory`, which +sniffs the format, so it already accepts more than WAV. Measured on +0.510.0: + +| Input | Result | +| --- | --- | +| WAV | accepted | +| **MP3** | **accepted** — `duration_ms=6013` | +| MP4 (whole file) | rejected: "unsupported or malformed audio data" | +| raw AAC (`-c:a copy` out of the MP4) | rejected: same | + +So the gap is not "only WAV". It is that **every** entry point takes an +encoded container miniaudio can demux, and there is no way in for samples a +*different* decoder produced. (The `load_wav` name understates what it does +and is worth revisiting separately — a caller reading the API would not +guess MP3 works.) + +## What we are doing instead + +`contrib/avcodec` (in-process video decode, landed 0.510.0) removed the +intermediate file for video: frames come straight from FFmpeg into a vg +raster region. Audio still cannot make the same trip, so `video_frame` +requires a manual pre-step: + +``` +ffmpeg -i clip.mp4 -vn -ar 44100 -ac 2 clip.wav # 20 MB sidecar for a 21 MB source +``` + +That is the exact intermediate-file problem `contrib/avcodec` was written to +eliminate, reappearing on the audio side — and it is worse than it looks: + +- it roughly **doubles on-disk cost** (20 MB sidecar for a 21 MB clip; a + feature film would be gigabytes of PCM); +- it is a **manual step before playback**, so an app cannot just open a file + the user picked; +- for a **live source** — a camera, a network stream, a generator — there is + no file to extract from and no workaround at all. Same shape as the + `fd_read_into` ask (#1471), which fixed the equivalent hole on the read + side. + +## Why this is the natural seam + +FFmpeg is already demuxing the container. `contrib/avcodec` opens the file, +finds the video stream, and reads *past* the audio packets. Teaching it to +decode those packets to PCM is a small, contained addition on our side — it +already has the format context, and the frame-handoff pattern +(`try_/get_/release_` plus a zero-allocation `_into` variant) is written and +tested. + +What is missing is somewhere to put the samples. `std.audio` already owns +the device, the mixer and the clock; it needs a source constructed from +memory rather than from a decoded blob. In miniaudio terms that is +`ma_audio_buffer` (or a custom `ma_data_source`) instead of `ma_decoder`, +fed into the same `ma_sound_init_from_data_source` the shim already calls. + +## Two shapes, either would work + +**1. Whole-buffer PCM** (simpler; matches today's ownership model) + +``` +audio.load_pcm(data, length, sample_rate, channels, format) -> ptr! +``` + +The caller decodes fully, hands over the samples, and `std.audio` copies +them the way `load_wav` copies its encoded input. Good enough for a clip +that fits in memory, which covers the current demo and most app audio. + +**2. Streaming push** (the one that unblocks live sources) + +``` +audio.open_stream(sample_rate, channels, format) -> ptr! +audio.push_pcm(src, data, length) -> int! # bytes accepted; 0 = buffer full +audio.stream_end(src) +``` + +A ring buffer the caller tops up from a decode loop, so nothing needs to fit +in memory. This is what a camera, a network stream, or a two-hour film +actually wants. It also raises a question worth answering deliberately: +whether `position_ms` should then report the DEVICE's play position rather +than a decoder offset — for A/V sync it must, since that is the clock video +chases. + +Shape 1 alone would remove the sidecar for `video_frame`. Shape 2 is the +one that makes `std.audio` usable for anything live. + +## Not urgent, and not blocking + +A/V sync is proven and correct today — video chases `audio.position_ms` to +within 3 ms on a real 720p/5.1 clip. Nothing about the clock relationship +changes with where the samples come from; this is packaging, not +architecture. Filing it because the sidecar is the last hand-cranked step in +an otherwise in-process pipeline. + +## Environment + +aether 0.510.0 (`92619ba1`), Linux/CachyOS. `std.audio` is miniaudio-backed +with `MA_NO_ENCODING`; decoders are compiled in, which is why MP3 already +works. From 76feb82816ca25340570a928ce9abab24a640dc3 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 9 Aug 2026 22:29:18 +0100 Subject: [PATCH 2/4] =?UTF-8?q?contrib/avcodec:=20whole-track=20audio=20de?= =?UTF-8?q?code=20to=20PCM=20=E2=80=94=20load=5Fpcm's=20caller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of the pcm-please ask, now that std.audio's load_pcm (e0738b4a) landed. avc_audio_decode_raw demuxes and decodes a file's audio stream to interleaved s16 STEREO at the source rate in one shot -- libswresample converts whatever the source is (Big Buck Bunny's 5.1 float-planar AAC included) in the same pass. Exposed as avcodec.audio_pcm(url) -> (pcm, n, rate, ch, err), the whole-buffer shape matching load_pcm's. Proven C-only first (the video noise bug taught that order): 117.3s clip decodes to 22,523,904 bytes at 48 kHz stereo = 117.3s exactly, real samples mid-track. Then end-to-end in Aether: load_pcm reports duration_ms=117312 and position_ms advances in real time. A transient SIGKILL during first end-to-end testing was chased and did not reproduce -- co-resident make -j8 memory pressure, not a leak; the identical test passes with 18 GB free. Probe and contrib-check gain libswresample (all five FFmpeg libs required together; partial install stays a SKIP). Test gains the no-audio case (the video-only clip must report an error, not garbage) and a decoded-size range assertion whose LOWER bound is the real guard. Falsified via the downmix contract: reporting 6 channels instead of 2 gives "FAIL: channels 6 want 2"; restored, PASS. Co-Authored-By: Claude Fable 5 --- .github/scripts/contrib_check.sh | 2 +- contrib/avcodec/aether_avcodec.c | 136 +++++++++++++++++++++++++++++++ contrib/avcodec/module.ae | 33 +++++++- contrib/avcodec/test_avcodec.ae | 40 +++++++++ tests/scripts/contrib_build.sh | 4 +- 5 files changed, 211 insertions(+), 4 deletions(-) diff --git a/.github/scripts/contrib_check.sh b/.github/scripts/contrib_check.sh index 01188eb5..3d59917d 100755 --- a/.github/scripts/contrib_check.sh +++ b/.github/scripts/contrib_check.sh @@ -54,7 +54,7 @@ I18N="contrib/i18n" TESTS=( # avcodec: needs FFmpeg's dev libraries to LINK and the ffmpeg BINARY to # generate its clip; the test itself SKIPs cleanly without the latter. - "avcodec/decode|$AVC/test_avcodec.ae|$AVC/aether_avcodec.c|run|libavcodec libavformat libavutil libswscale" + "avcodec/decode|$AVC/test_avcodec.ae|$AVC/aether_avcodec.c|run|libavcodec libavformat libavutil libswscale libswresample" "tinyweb/spec|$TW/test_spec.ae||run|" "tinyweb/inventory|$TW/test_inventory.ae|$TW/ws_handshake.c|run|" "tinyweb/integration|$TW/test_integration.ae|$TW/ws_handshake.c|run|" diff --git a/contrib/avcodec/aether_avcodec.c b/contrib/avcodec/aether_avcodec.c index f359d576..79a203fb 100644 --- a/contrib/avcodec/aether_avcodec.c +++ b/contrib/avcodec/aether_avcodec.c @@ -35,6 +35,7 @@ #include #include #include +#include #include #include @@ -242,3 +243,138 @@ void avc_close_raw(void* h) { if (d->fmt) avformat_close_input(&d->fmt); free(d); } + +/* ---- audio: whole-track decode to interleaved s16 stereo PCM ---- + * + * The counterpart to std.audio's load_pcm (aether e0738b4a), which takes + * samples the caller already decoded. This is the caller. One shot: + * demux + decode the file's audio stream, converting whatever it is + * (Big Buck Bunny is 5.1 AAC float-planar) to interleaved s16 STEREO at + * the source sample rate via libswresample. Whole-track because load_pcm + * is whole-buffer: ~20 MB in memory for a 2-minute film, zero on disk, + * no manual step — the sidecar-WAV extraction this replaces doubled the + * on-disk cost and could not serve a live source at all. + * + * Same TLS handoff as the video frames and sqlite's blobs. */ + +static _Thread_local char* g_apcm = NULL; +static _Thread_local int g_apcm_len = 0; +static _Thread_local int g_apcm_rate = 0; +static _Thread_local int g_apcm_ch = 0; + +void avc_audio_release(void) { + if (g_apcm) { free(g_apcm); g_apcm = NULL; } + g_apcm_len = 0; g_apcm_rate = 0; g_apcm_ch = 0; +} + +const char* avc_audio_get_pcm(void) { return g_apcm ? g_apcm : ""; } +int avc_audio_get_pcm_length(void) { return g_apcm_len; } +int avc_audio_sample_rate(void) { return g_apcm_rate; } +int avc_audio_channels(void) { return g_apcm_ch; } + +int avc_audio_decode_raw(const char* url) { + avc_audio_release(); + if (!url) return 0; + + AVFormatContext* fmt = NULL; + AVCodecContext* dec = NULL; + SwrContext* swr = NULL; + AVFrame* frame = NULL; + AVPacket* pkt = NULL; + char* out = NULL; + size_t out_cap = 0, out_len = 0; + int ok = 0; + + if (avformat_open_input(&fmt, url, NULL, NULL) < 0) goto done; + if (avformat_find_stream_info(fmt, NULL) < 0) goto done; + const AVCodec* codec = NULL; + int si = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, &codec, 0); + if (si < 0 || !codec) goto done; + AVStream* st = fmt->streams[si]; + dec = avcodec_alloc_context3(codec); + if (!dec || avcodec_parameters_to_context(dec, st->codecpar) < 0 || + avcodec_open2(dec, codec, NULL) < 0) goto done; + + AVChannelLayout out_layout = AV_CHANNEL_LAYOUT_STEREO; + if (swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16, + dec->sample_rate, &dec->ch_layout, + dec->sample_fmt, dec->sample_rate, + 0, NULL) < 0) goto done; + if (swr_init(swr) < 0) goto done; + + frame = av_frame_alloc(); + pkt = av_packet_alloc(); + if (!frame || !pkt) goto done; + + while (av_read_frame(fmt, pkt) >= 0) { + if (pkt->stream_index == si && + avcodec_send_packet(dec, pkt) == 0) { + while (avcodec_receive_frame(dec, frame) == 0) { + int max_out = swr_get_out_samples(swr, frame->nb_samples); + size_t need = (size_t)max_out * 2 /*ch*/ * 2 /*s16*/; + if (out_len + need > out_cap) { + size_t nc = out_cap ? out_cap * 2 : 1 << 20; + while (nc < out_len + need) nc *= 2; + char* nb = (char*)realloc(out, nc); + if (!nb) goto done; + out = nb; out_cap = nc; + } + uint8_t* dst = (uint8_t*)(out + out_len); + int got = swr_convert(swr, &dst, max_out, + (const uint8_t**)frame->extended_data, + frame->nb_samples); + if (got > 0) out_len += (size_t)got * 2 * 2; + } + } + av_packet_unref(pkt); + } + /* Flush decoder, then the resampler's tail. */ + avcodec_send_packet(dec, NULL); + while (avcodec_receive_frame(dec, frame) == 0) { + int max_out = swr_get_out_samples(swr, frame->nb_samples); + size_t need = (size_t)max_out * 4; + if (out_len + need > out_cap) { + size_t nc = out_cap ? out_cap * 2 : 1 << 20; + while (nc < out_len + need) nc *= 2; + char* nb = (char*)realloc(out, nc); + if (!nb) goto done; + out = nb; out_cap = nc; + } + uint8_t* dst = (uint8_t*)(out + out_len); + int got = swr_convert(swr, &dst, max_out, + (const uint8_t**)frame->extended_data, + frame->nb_samples); + if (got > 0) out_len += (size_t)got * 4; + } + for (;;) { + size_t room = 4096 * 4; + if (out_len + room > out_cap) { + size_t nc = out_cap ? out_cap * 2 : 1 << 20; + while (nc < out_len + room) nc *= 2; + char* nb = (char*)realloc(out, nc); + if (!nb) goto done; + out = nb; out_cap = nc; + } + uint8_t* dst = (uint8_t*)(out + out_len); + int got = swr_convert(swr, &dst, 4096, NULL, 0); + if (got <= 0) break; + out_len += (size_t)got * 4; + } + + if (out_len == 0) goto done; + g_apcm = out; + g_apcm_len = (int)out_len; + g_apcm_rate = dec->sample_rate; + g_apcm_ch = 2; + out = NULL; /* ownership moved to the TLS slot */ + ok = 1; + +done: + if (out) free(out); + if (swr) swr_free(&swr); + if (frame) av_frame_free(&frame); + if (pkt) av_packet_free(&pkt); + if (dec) avcodec_free_context(&dec); + if (fmt) avformat_close_input(&fmt); + return ok; +} diff --git a/contrib/avcodec/module.ae b/contrib/avcodec/module.ae index 4f16f2bb..19c341df 100644 --- a/contrib/avcodec/module.ae +++ b/contrib/avcodec/module.ae @@ -38,7 +38,10 @@ exports( avc_try_next_frame, avc_get_frame_bytes, avc_get_frame_length, avc_release_frame, avc_copy_frame_into_raw, avc_error_raw, open, close, width, height, frame_bytes, fps, pts_ms, - next_frame, next_frame_into, errmsg + next_frame, next_frame_into, errmsg, + avc_audio_decode_raw, avc_audio_get_pcm, avc_audio_get_pcm_length, + avc_audio_sample_rate, avc_audio_channels, avc_audio_release, + audio_pcm ) extern avc_open_raw(url: string, want_w: int, want_h: int) -> ptr @@ -55,6 +58,12 @@ extern avc_get_frame_length() -> int extern avc_release_frame() extern avc_copy_frame_into_raw(dec: ptr, buf: ptr, cap: int) -> int extern avc_error_raw(dec: ptr) -> string +extern avc_audio_decode_raw(url: string) -> int +extern avc_audio_get_pcm() -> string +extern avc_audio_get_pcm_length() -> int +extern avc_audio_sample_rate() -> int +extern avc_audio_channels() -> int +extern avc_audio_release() extern string_new_with_length(data: string, length: int) -> ptr // Open a media source. `want_w`/`want_h` <= 0 means "source size"; anything @@ -102,3 +111,25 @@ next_frame_into(dec: ptr, buf: ptr, cap: int) -> { if n == 0 { return 0, "eof" } return n, "" } + +// Decode the file's ENTIRE audio stream to interleaved s16 stereo PCM at the +// source sample rate -- the whole-buffer counterpart to std.audio's +// load_pcm, which is where the result goes: +// +// pcm, n, rate, ch, err = avcodec.audio_pcm(url) +// src, aerr = audio.load_pcm(pcm, n, rate, ch, audio.FORMAT_S16) +// +// Whatever the source is (5.1 float-planar AAC included), libswresample +// downmixes and converts in one pass. ("", 0, 0, 0, err) when the file has +// no audio stream -- callers treat that as "no audio", not a failure. +audio_pcm(url: string) -> { + ok = avc_audio_decode_raw(url) + if ok == 0 { return "", 0, 0, 0, "no decodable audio stream in ${url}" } + raw = avc_audio_get_pcm() + n = avc_audio_get_pcm_length() + rate = avc_audio_sample_rate() + ch = avc_audio_channels() + owned = string_new_with_length(raw, n) + avc_audio_release() + return owned, n, rate, ch, "" +} diff --git a/contrib/avcodec/test_avcodec.ae b/contrib/avcodec/test_avcodec.ae index 9106e51b..86650a60 100644 --- a/contrib/avcodec/test_avcodec.ae +++ b/contrib/avcodec/test_avcodec.ae @@ -95,6 +95,46 @@ main() { avcodec.close(d2) pass("undersized buffer is refused, decoder still usable") + // ── audio_pcm ──────────────────────────────────────────────────── + // The test clip is testsrc-only, so it is ALSO the no-audio case: + // audio_pcm must report an error there, not hand back garbage. + _ap, _an, _ar, _ac, aerr = avcodec.audio_pcm(clip) + if string.length(aerr) == 0 { fail("no-audio clip decoded to something") } + pass("a clip without audio reports an error") + + // And a clip WITH audio: a 2s sine at 22050 Hz mono in the container. + aclip = "/tmp/_avc_test_av.mp4" + av2 = list.new() + _ = list.add(av2, "-y") + _ = list.add(av2, "-f") + _ = list.add(av2, "lavfi") + _ = list.add(av2, "-i") + _ = list.add(av2, "testsrc=size=64x48:rate=10:duration=2") + _ = list.add(av2, "-f") + _ = list.add(av2, "lavfi") + _ = list.add(av2, "-i") + _ = list.add(av2, "sine=frequency=440:sample_rate=22050:duration=2") + _ = list.add(av2, "-pix_fmt") + _ = list.add(av2, "yuv420p") + _ = list.add(av2, "-c:a") + _ = list.add(av2, "aac") + _ = list.add(av2, "-shortest") + _ = list.add(av2, aclip) + _o2, st2, _e9 = os.run_capture("ffmpeg", av2, null) + if st2 != 0 { println(" SKIP: ffmpeg cannot make the A/V clip"); return } + + pcm, pn, prate, pch, perr = avcodec.audio_pcm(aclip) + if string.length(perr) > 0 { fail("audio_pcm: ${perr}") } + if prate != 22050 { fail("rate ${prate} want 22050") } + if pch != 2 { fail("channels ${pch} want 2 (downmix contract)") } + // ~2s at 22050 Hz stereo s16 = ~176400 bytes; AAC padding varies, so a + // range. The LOWER bound is the real assertion -- an empty or truncated + // decode is the failure this guards. + if pn < 150000 { fail("pcm ${pn} bytes, want ~176400") } + if pn > 220000 { fail("pcm ${pn} bytes, want ~176400") } + pass("audio_pcm: ${pn} bytes @ ${prate} Hz x${pch}") + _e10 = fs.delete(aclip) + avcodec.close(d) _e = fs.delete(clip) println("=== test_avcodec passed ===") diff --git a/tests/scripts/contrib_build.sh b/tests/scripts/contrib_build.sh index a1d8c762..fa6c4ec4 100755 --- a/tests/scripts/contrib_build.sh +++ b/tests/scripts/contrib_build.sh @@ -164,8 +164,8 @@ probe_avcodec() { cross_dep_present libavcodec/avcodec.h avcodec return fi - if pkg-config --exists libavcodec libavformat libavutil libswscale 2>/dev/null; then - pkg-config --cflags-only-I libavcodec libavformat libavutil libswscale + if pkg-config --exists libavcodec libavformat libavutil libswscale libswresample 2>/dev/null; then + pkg-config --cflags-only-I libavcodec libavformat libavutil libswscale libswresample return 0 fi return 1 From fabc4855ed96b669b25824324cf47789c6c0f353 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Sun, 9 Aug 2026 22:58:11 +0100 Subject: [PATCH 3/4] docs(CHANGELOG): record avcodec.audio_pcm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The avcodec audio-decode commit landed with no CHANGELOG entry and there was no [current] section, so per this file's own workflow the next release would have tagged a version with whole-track audio decode unmentioned — the same way 0.506.0 through 0.509.0 shipped empty (backfilled in #1474, root cause in #1477). Written from the shipped code rather than the commit subject, and pairs the entry with load_pcm (0.512.0) since the two only make sense together: this is the producer, that is the consumer, and asks/pcm-please.md wanted both. Verified end-to-end here before writing it, rather than transcribing the commit's numbers: a 2s AAC clip decodes to 356352 bytes at 44100 Hz stereo and audio.load_pcm reports duration_ms=2020. (The ~1% over 352800 is AAC encoder padding, not a defect.) [skip actions] deliberately NOT used here, despite this being the docs commit: it is the branch HEAD, so the token would skip CI for the whole branch — including 244 lines of new C in contrib/avcodec that wants exercising on the Windows and macOS runners. The token is for branches that are docs-only end to end, not for the docs commit of a branch that ships code. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1baaa2d6..11f551df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. +## [current] + +### Added + +- **`avcodec.audio_pcm(url)`** — decode a file's whole audio track to PCM, the + producer side of the `load_pcm` consumer that landed in 0.512.0. Together + they close the loop `asks/pcm-please.md` described: an MP4's audio can now + reach the speakers without a hand-extracted sidecar WAV. + + Returns `(pcm, n, rate, channels, err)` — interleaved **s16 stereo at the + source rate**, in one shot. libswresample does the conversion in the same + pass, so a 5.1 float-planar AAC track (Big Buck Bunny's, for instance) comes + back as plain stereo s16 without the caller arranging anything. The + whole-buffer shape deliberately matches `audio.load_pcm`'s, so the two + compose directly: + + ```aether + pcm, n, rate, ch, err = avcodec.audio_pcm(path) + src, e = audio.load_pcm(pcm, n, rate, ch, audio.FORMAT_S16) + ``` + + Verified end to end: a 117.3s clip decodes to 22,523,904 bytes at 48 kHz + stereo — exactly 117.3s — and `audio.load_pcm` then reports + `duration_ms=117312` with `position_ms` advancing in real time. + + `contrib/avcodec` now requires **libswresample** alongside the other four + FFmpeg libraries. All five are required together; a partial install stays a + clean SKIP rather than a build failure. + ## [0.512.0] ### Added From c528671aa56d130b24babcfb455d872556af1a46 Mon Sep 17 00:00:00 2001 From: Paul Hammant Date: Mon, 10 Aug 2026 00:07:55 +0100 Subject: [PATCH 4/4] ci: retrigger checks for this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CHANGELOG commit carried the skip token in its subject and was the branch HEAD, so GitHub suppressed the workflow for the whole PR — including 244 lines of new C in contrib/avcodec that wants exercising on Windows and macOS. Amending the token away did not retrigger, because the push event had already been skipped; an empty commit produces the fresh event that does. The subject here deliberately avoids the token text itself — an earlier attempt explained it in the subject line and GitHub matched THAT, skipping again. Co-Authored-By: Claude Opus 4.8