Skip to content

feat(ffmpeg): RTMP egress can carry a second audio track, measured arriving as two distinct tracks (#141) - #331

Merged
rainmanjam merged 5 commits into
mainfrom
feat/rtmp-second-audio-track
Aug 14, 2026
Merged

feat(ffmpeg): RTMP egress can carry a second audio track, measured arriving as two distinct tracks (#141)#331
rainmanjam merged 5 commits into
mainfrom
feat/rtmp-second-audio-track

Conversation

@rainmanjam

@rainmanjam rainmanjam commented Aug 13, 2026

Copy link
Copy Markdown
Owner

RTMP egress could carry exactly one audio track. This lifts that to two, opt-in
per destination, and measures two distinct tracks arriving at a real far end
before claiming anything.

The restriction, and which half of it I did not lift

The #141 comment the work started from is not in internal/ffmpeg — it is in
internal/db/destinations.go, on AudioEncoding.copyProblems, and it refuses
-c:a copy on an RTMP destination because "platform ingests expect one encoded
stereo track, so a copied multitrack stream would upload cleanly and be
rejected". That refusal is untouched. Its reason is a fact about platforms —
no mainstream ingest documents accepting multitrack audio, which
scripts/acceptance-multistream.sh states at length — and nothing here measured
a platform.

What was in internal/ffmpeg was the other half, and it was never a stated
refusal at all: DestinationArgs emitted exactly one -map [aout], and the doc
comment described "the single stereo track the platform will accept" as though
the muxer and the wire were the constraint. Nobody had measured whether they
are. They are not.

The change

DestSpec.SecondAudioOutLabel names a second finished mix from the same filter
graph. When set, it is mapped as a second audio track after the first.

  • Empty for every destination that exists — no caller sets it, so every existing
    command is byte-for-byte what it was.
  • Ignored on an audio-only destination (an Icecast mount is one stream) and on
    the copy path (which has no graph).
  • Ignored when it names the same label as AudioOutLabel. FFmpeg refuses to map
    one filter output twice (Output with label 'aout' … was already used elsewhere, exit 234, nothing published), and "the mix twice" is not a second
    track anyway.
  • Two, not N. Two is what Enhanced Broadcasting needs and two is what has been
    put on a wire and read back.

What I measured

internal/ffmpeg.TestTwoDistinctMixesReachAnRTMPFarEnd. No credentials, no
platform, runs in CI.

  1. A two-tone ingest is built: video plus two stereo AAC tracks, 300 Hz and
    5000 Hz — the multistream suite's tones, for its reasons.
  2. The exact argv DestinationArgs returns is run against a real FFmpeg,
    with the relay input swapped for that file and -re added so there is a live
    stream to attach to. Nothing else about the command is touched.
  3. The far end is internal/rtmpserver — the RTMP server this product
    ships, not an ffmpeg -listen 1 sink. It performs the handshake, addresses
    the stream by key, and its subscriber side has to carry the E-RTMP
    multitrack messages a second audio track arrives as.
  4. A real subscriber records what arrives with -map 0 -c copy, so a track that
    arrived is recorded whether or not the test expected it.
  5. Each received track is measured by bandpass + astats, the multistream
    suite's idiom.

Result, reproducible on FFmpeg 8.1.2 (CI pins n8.1):

received track 0: 300 Hz -24.1 dB, 5000 Hz -70.8 dB (balance  46.7)
received track 1: 300 Hz -58.4 dB, 5000 Hz -24.1 dB (balance -34.3)

How I know it is two tracks and not one duplicated

A track count cannot tell those apart: two streams, two codecs, two plausible
bitrates, and the second one worthless. So:

  • The assertion is on which tone each received track carries — each track
    must have its own band above -45 dBFS and the other band at least 20 dB below
    it — plus a band-balance spread of at least 30 dB between the two tracks.
    The measured spread is 81 dB.
  • And the spread check is proven able to fail: a second subtest publishes
    the same mix into both labels and asserts the spread is absent. It measures
    46.7 / 46.7 — two real tracks (both are checked against the presence floor
    first, so this is not a measurement of silence), zero apart. Without that
    subtest, "the tracks differ by 30 dB" would be a sentence nobody had watched
    fail.

Mutation testing

Every test here was broken against the committed tree and watched to fail by
name, then restored from a file backup with git diff --stat confirmed clean.
Each mutation is recorded in its test's doc comment. The wire test was killed
from both ends:

  • sending: secondAudioMap returning nil → both subtests fail with
    the built command exited (exit status 234) without ever publishing, FFmpeg
    naming the now-unmapped vodout.
  • receiving: case *message.AudioExMultitrack in internal/rtmpserver's
    isSetup returning false, so the second track's decoder configuration is
    never replayed → both subtests fail with the subscriber never finished identifying the stream and was killed after 30s. This is what makes the test
    a measurement of the far end rather than of an argv.

The argv tests were killed by, respectively: returning a second map
unconditionally; returning nil; deleting the same-label guard; deleting the
DestAudio guard; and — for the copy-path arm, which first claimed to be
unmutatable — appending secondAudioMap to copyAudioArgs, which is exactly
how somebody would extend this feature to the copy path and puts a filter label
on a command that has no filter graph.

Two failure paths were made louder while doing this, because both first
appeared as slow, misleading failures: a publisher that exits without ever
publishing is now reported immediately with FFmpeg's own message instead of
after a 25 s handshake deadline, and a subscriber killed by its context now says
that a track whose configuration the far end did not replay looks exactly like
that.

No new t.Skip — deliberately, including no testing.Short() guard, which
would be a new site on the #161 ratchet for a pass nobody in this repo takes.
internal/testenv/testdata/skips.json is unchanged.

What this does NOT establish

  • That any platform accepts a second audio track. Untested and unchanged;
    the copyProblems refusal that rests on it stands.
  • That a profile can describe two mixes. routing.Compile emits one mix
    with fixed internal labels (a_t0, a_mix, aout), so two compiled graphs
    cannot simply be concatenated — they collide on every label. No DB column, no
    API field, no UI. That is Implement Enhanced Broadcasting (IVS Multitrack Video): negotiated config, global-contribute ingest, and the VOD audio track #326's work; this is the capability it needs, with
    the wire question answered first.
  • Per-track encoding. audioCodecArgs names no stream, so both tracks take
    the destination's bitrate: two tracks cost twice the audio bitrate, and there
    is no way to ask for less on the second.
  • Track identity on the wire. FFmpeg assigns E-RTMP track IDs by stream
    index. Nothing here labels one track "live" and the other "VOD", and nothing
    was measured about whether a receiver could tell which is which beyond order.
  • Anything about SRT or file destinations. The field is honoured on every
    video destination because the argv shape is identical, but only the RTMP/FLV
    path was put on a wire.

Gates: gofmt -l, go vet ./..., go build ./..., go test ./..., and
shellcheck -S warning scripts/acceptance-multistream.sh (comment-only edit;
the same 9 pre-existing warnings before and after).

https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

…riving as two distinct tracks (#141)

DestSpec.SecondAudioOutLabel names a second finished mix from the
destination's filter graph and maps it as a second encoded audio track.
Empty for every destination that exists, so every existing command is
byte-for-byte what it was.

The cap it lifts was never a stated refusal in this package -- it was one
`-map [aout]` and a doc comment describing "the single stereo track the
platform will accept" as if the muxer and the wire were the constraint.
Nobody had measured whether they are. They are not.

The #141 refusal that IS stated -- `-c:a copy` on an RTMP destination, in
db.AudioEncoding.copyProblems -- is untouched. Its reason is about
platforms accepting multitrack audio, and nothing here measured a
platform.

TestTwoDistinctMixesReachAnRTMPFarEnd publishes the exact argv
DestinationArgs builds, through a real FFmpeg, into internal/rtmpserver
-- this product's own RTMP server, not a permissive listener -- records
what arrives and reads the tones off each received track: 300 Hz on one,
5000 Hz on the other, 81 dB of band-balance apart. Tones rather than a
track count, because two tracks carrying the same audio is a failure a
count cannot see; a second subtest publishes one mix twice and asserts
the spread is absent, so the distinctness check has been watched to fail.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Copilot AI lite review requested due to automatic review settings August 13, 2026 23:41
…lled by

The doc comment claimed the copy arm was structural and unmutatable. It is
not: appending secondAudioMap to copyAudioArgs -- the obvious way somebody
would extend the feature to the copy path -- puts a filter label on a
command that has no filter graph, and the arm fails naming it.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
…an use

Fifteen seconds of source for a three-second recording. The publisher is
paced with -re and killed as soon as the recording is in hand, so the
extra length costs nothing and removes the one shape this test could fail
in on a slow machine: the source running out while the subscriber is
still starting, which fails as a 30s timeout that reads like a defect.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in support for mapping a second finished audio mix into RTMP (and other video-capable) egress commands, and introduces an end-to-end test that verifies two distinct audio tracks survive polyemesis’s RTMP server path.

Changes:

  • Extend ffmpeg.DestSpec with SecondAudioOutLabel and map it as a second -map [...] audio stream when applicable.
  • Add a full wire test that publishes two distinct mixes via FFmpeg into internal/rtmpserver, records the far end, and validates distinctness via bandpass + astats.
  • Update documentation/changelog and acceptance-suite commentary to clarify what is measured (wire capability) vs. what remains unproven (platform ingest acceptance).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
scripts/acceptance-multistream.sh Clarifies that “can polyemesis send two tracks” is now measured separately from platform acceptance.
internal/ffmpeg/second_audio_track_test.go New argv + wire-level test suite proving two distinct audio tracks reach an RTMP far end.
internal/ffmpeg/build.go Adds SecondAudioOutLabel and conditionally appends a second audio -map to destination args.
docs/TESTING.md Documents the new in-process wire measurement test and its scope/limitations.
CHANGELOG.md Records the new capability and the fact it’s measured end-to-end (mechanically, not platform acceptance).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +340 to +345
res := testenv.ReserveTCP(t)
addr := "127.0.0.1:" + strconv.Itoa(res.Port())
res.Release()
srv := rtmpserver.New(slog.New(slog.NewTextHandler(io.Discard, nil)), addr,
rtmpserver.ConstantTimeLookup(map[string]rtmpserver.Target{key: tg}))
if err := srv.Start(); err != nil {
@sonarqubecloud

Copy link
Copy Markdown

@rainmanjam
rainmanjam merged commit 8caf7f1 into main Aug 14, 2026
27 checks passed
rainmanjam added a commit that referenced this pull request Aug 14, 2026
…egotiation that falls back quietly (#326) (#333)

* feat(routing): a profile can describe two mixes, measured as two distinct tracks (#326)

routing.Compile emitted one mix whose internal labels were fixed constants
-- a_t0, a_mix, aout -- so concatenating two compiled graphs collided on
every one of them. That is why nothing could set
ffmpeg.DestSpec.SecondAudioOutLabel, which has been able to map and encode
a second audio track since #331 and has had no way to be asked for one.

Every label site now goes through a namespace. The empty namespace is
byte-for-byte what the package emitted before, so a destination that gains
a VOD track does not have its live mix rewritten, and a destination that
never asks for one produces the argv it always did. CompilePair returns
both mixes in one filter_complex plus the label to map for the second.

A secondary that will not compile is a WARNING, not an error: an optional
VOD track must never veto a working broadcast. A primary that will not
compile is still an error -- there is no stream without it.

TWO TAPS OF ONE INGEST TRACK NEED NO asplit. Both halves emit [0:a:N] for
a track they share, and the obvious reading is that a filter pad feeds one
input so a shared tap needs an explicit split. An input STREAM is not a
filter pad and FFmpeg inserts the split itself -- measured on 6.0.1 (Alpine
3.18, the floor internal/ffmpeg/detect.go enforces) and 8.1.2. An asplit
here would have been dead weight carried on a guess.

TestAPairedGraphReachesFFmpegAsTwoDistinctMixes hands the real graph to the
real binary and reads the tones back off both tracks, because the failure
this ships with otherwise is TWO TRACKS THAT ARE THE SAME MIX -- which a
track count sees as success. Mutating the VOD mix to carry the live
content fails it at -18.1 dB on a tone that must be absent.

It needs no skip: PCM in NUT with no video uses only codecs built into
every FFmpeg, and the binary lookup goes through testenv.FFmpegBinary,
whose skip lives inside internal/testenv and which fails rather than skips
under POLYEMESIS_REQUIRE_FFMPEG. The skip census is unchanged at 94.

All six tests mutation-verified; each mutation recorded in its test's doc
comment with the observed failure.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

* feat(destinations): a destination can carry a second (VOD) audio mix (#326)

routing.CompilePair landed with nothing able to ask for it. This is the
path from a stored setting to the wire: two columns, the engine's compile
step, and the one place a DestSpec is built.

db.Destination gains Multitrack -- opt in to Twitch Enhanced Broadcasting
-- and VODProfile, the second mix. Both default to off, and off produces
byte for byte the filter graph and the argv the destination produced
before the columns existed.

VODProfile IS A POINTER because "no second mix" and "a second mix that
happens to be the zero profile" are different things, and the zero profile
fails Validate. The column stores '' rather than '{}' for the same reason
and marshalVODProfile enforces the one spelling of absence at the write
end: json.Marshal of a nil pointer produces `null`, which is not empty,
takes the decode branch, and arrives as a nil profile by a route the
reader cannot tell from a corrupt value. Had either end disagreed, every
row written before this column existed would have come back carrying a
second audio track that cannot compile.

NOT ON THE PROVISIONAL PATH. A provisional compile already runs on a
guessed layout and says so; a second guessed mix on top doubles what is
approximate while the operator is being told the first one is unreliable.
The VOD track returns on the first reconcile after a probe succeeds.

The second label rides on routing.Result rather than only on Pair, so the
engine's one description of an output carries a VOD track without a single
signature changing type -- and the backup feed picks it up through the
same struct, rather than silently publishing one track where the primary
publishes two.

THREE DRIFT GUARDS, all satisfied rather than worked around:
  - TestUITypesCanNameEveryDestinationField wanted a types.ts entry.
  - TestEveryStoredLeafIsClassified wanted all 22 new stored JSON leaves
    classified. Every one is sPublic: a routing profile is mix settings,
    not a credential. This is the #310/#324 guard and it fired correctly.
  - TestReadSafeViewsScrubEverySecretLeaf follows from that classification.

NO CAPABILITY-MATRIX COLUMN, deliberately. Support is yes/manual/no/
unknown and Enhanced Broadcasting's honest answer is "depends whether your
server has a supported GPU" -- `yes` would be false for the majority
GPU-less VPS install, which is the normal case here, and the matrix
answers a per-ACCOUNT question while this is a per-destination toggle.

The UI toggle is gated to Twitch, worded so that not getting Enhanced
Broadcasting reads as ordinary rather than as a fault.

Both new db tests mutation-verified, each mutation and its observed
failure recorded in the test's doc comment. A third mutation was discarded
rather than counted: it failed to BUILD, which is not the named test
failing.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

* feat(multitrack): negotiate at go-live, or fall back quietly (#326)

multitrack could describe a negotiation and could not make one. Negotiate
is the decision: ask Twitch, read the verdict, resolve the endpoint, and
answer where to publish.

IT RETURNS NO ERROR, deliberately. Twitch refuses any client without a
supported GPU, and polyemesis is built to be installed on the operator's
own server -- a rented VPS has none. On most installs the fallback IS the
path, every time, for ever. An error return would make the ordinary case
look broken: logged at error level, counted as a fault, retried by
somebody who assumed non-nil meant something had gone wrong. The caller
publishes to Target or to the destination's own URL, and both are correct.

A NO-GPU ASK DOES NOT MAKE THE CALL. Twitch was measured refusing, by
name, a request with no GPU information, an Intel iGPU, an unrecognised
vendor and an out-of-date driver -- so the answer is known, and spending a
round trip at go-live to be told it would delay every broadcast on every
GPU-less install to learn nothing. The test asserts the call is not made,
not merely that the outcome is a fallback: the same answer arrived at
slowly is the failure a Use==false assertion cannot see.

A LEAK FOUND AND CLOSED. The package doc says status.html_en_us "is
scrubbed before it is shown anywhere". It was not. Config.explain returns
the field verbatim, and that field is Twitch QUOTING THE REQUEST BACK --
the request that carries the stream key. Only the errors Client.Fetch
builds were ever scrubbed, so the transport and decode paths were safe and
the verdict path was not. Every note now leaves Negotiate through one
scrubbing closure. TestNoOutcomeEverCarriesTheStreamKeyInItsNote sweeps
all four paths, including a server that echoes the key back the way the
live endpoint does; it FAILED before this change, which is how the gap was
found. Same shape as #310 and #324.

The minted-key test is the important one and it is about a failure that
WORKS: publishing with the operator's own key does not fail loudly, it
CONNECTS and sends a ladder the ingest never agreed to. So the assertion
is that the signed v1_ prefix specifically survives -- the minted key ENDS
with the operator's own, so "contains the operator key" would pass on the
wrong value.

Its fixture is SYNTHETIC and says so. The captured fixture ships with
`authentication` emptied because a real minted key is a live credential;
committing one would put a working stream key in the repository, which is
the thing the leak above is about. The shape is reproduced from the
measurement in IngestEndpoint.Authentication.

All five tests mutation-verified against the real code paths they name --
Resolve's minted-key branch, the short circuit, Verdict's StatusError
case, the scrubber, and the VOD preference -- each recorded with its
observed failure.

VERIFIED AGAINST FIXTURES, NOT THE LIVE API. Both fixtures were captured
from the live endpoint by earlier work; nothing here re-derives them and I
hold no Twitch token. The success path has never been exercised against
Twitch by this change.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

* fix(multitrack): correct why html_en_us is scrubbed, and cover the minted key (#326)

Two corrections to 007e915, one to a comment and one to a real gap the
comment's wrong reasoning was hiding.

THE COMMENT WAS WRONG ABOUT WHICH FIELD CARRIES THE KEY. 007e915 said
status.html_en_us is "Twitch quoting the request back -- the request
carrying the stream key", and called the scrub a fix for an observed leak.
Measured against the live endpoint with a distinctive canary sent as
`authentication`: html_en_us echoes client.name, not the key. A refusal
for missing canvases named the broadcast software and did not contain the
canary, and no refusal that could be produced quoted the key. The key that
does come back is in ingest_endpoints[].authentication -- the 312-character
minted key on the success path.

The scrub stays, rejustified: html_en_us is attacker-influenced text from a
third party that polyemesis renders to an operator, and it is built by
quoting request fields back. Scrubbing text we do not control is cheap.
That is defence, and it is now described as defence. A comment naming the
wrong field is how the next person scrubs the wrong field.

THE GAP THE WRONG REASONING HID. Chasing the right field surfaced one that
matters more. destSecrets registered row.StreamKey -- the ORIGINAL -- and
alerts.SecretSet.Scrub is a substring replace. The minted key ENDS WITH the
original, so registering only the original masks its last segment and
leaves

	v1_<64 hex signature>_<8 hex>_<hex manifest>_<MASK>

standing in process.log, on the monitoring page's argv, and in every error
the supervisor renders. A partially redacted live credential, which reads
as protection to anyone glancing at the file. Same class as #310 and #324.

destSecrets now takes the credentials that did not exist until go-live.
Variadic rather than a row field: a minted key is a fact about one run of
one process, and storing it would store a credential stale by the next
broadcast.

The test asserts the SIGNATURE PREFIX is gone, not that the original key is
absent -- the latter passes on the broken version, which is exactly why the
gap was easy to miss. A negative control pins that the gap is real, so the
first test cannot pass because alerts.Redact's residual pass happened to
catch it; if that control ever fails, the protection has moved and the
comment needs rewriting rather than the test deleting.

ALSO RECORDED: the live endpoint returned a SUCCESSFUL negotiation, with a
full ladder and a minted key, for a plainly invalid stream key. Validation
happens at publish, not at negotiation, so Outcome.Use is not evidence the
credential works. Written on the field a caller reads.

Three mutations, each with the named test confirmed running. The third
asserts a PASS: dropping wireSpellings leaves the test green, confirming it
does not lean on truncation expansion, which is a separate concern.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
@rainmanjam
rainmanjam deleted the feat/rtmp-second-audio-track branch August 14, 2026 04:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants