Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,27 @@ its first tagged release.
a rename, a routing change — deliberately leaves the sealed bytes alone
rather than destroying a key the right key file would have recovered.

### Added

- **RTMP egress can carry a second audio track, and it has been measured doing
it.** `ffmpeg.DestSpec.SecondAudioOutLabel` names a second finished mix from
the destination's filter graph; it is mapped and encoded as a second audio
track alongside the first. One track remains the default and every existing
destination emits byte-for-byte the command it emitted before — no caller sets
the field yet, because `routing.Compile` still describes one mix per
destination. What is new is that the capability is no longer a guess: FFmpeg
8.1 muxes two AAC tracks into FLV as Enhanced RTMP multitrack, this project's
own RTMP server carries them, and
`internal/ffmpeg.TestTwoDistinctMixesReachAnRTMPFarEnd` publishes the built
argv into that server and reads a 300 Hz tone off one received track and a
5000 Hz tone off the other. Tones rather than a track count, because two
tracks carrying the same audio is a failure a count cannot see — and the same
test proves it can tell, by publishing one mix twice and asserting the
difference is absent. Whether any PLATFORM accepts a second audio track is a
separate question this does not answer; the refusal of `-c:a copy` on an RTMP
destination, which rests on that question, is untouched.
([#141](https://github.com/rainmanjam/polyemesis/issues/141))

## [0.7.0] — 2026-08-12

### Security
Expand Down
12 changes: 12 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,18 @@ argv, log and artifact. See issue #141 for why the question it asks is
"does each destination receive ITS mix" rather than "does a platform accept two
audio tracks".

The *other* half of that question — can a destination SEND two audio tracks at
all — is measured in-process instead, by
`internal/ffmpeg.TestTwoDistinctMixesReachAnRTMPFarEnd`. It publishes the argv
`DestinationArgs` builds through a real FFmpeg into `internal/rtmpserver` (the
listener this product ships, not a permissive `ffmpeg -listen 1`), records what
arrives, and reads the 300 Hz / 5000 Hz tones off each received track — the same
bandpass idiom the multistream suite uses on each platform's far end. It needs
no credentials and runs in CI. What it establishes is mechanical: two distinct
mixes survive polyemesis's own RTMP egress and arrive as two different tracks.
Whether any PLATFORM accepts a second track is still the unanswered half, and
still needs a real account to answer.

Two more do **not** drive the built binary and need no `make build`. They drive
one package against a socket, which is the gap `docs/notes/live-test-coverage-
gaps.md` ranks: seventeen suites, and until these were written exactly one of
Expand Down
68 changes: 64 additions & 4 deletions internal/ffmpeg/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,34 @@ type DestSpec struct {
FilterComplex string
// AudioOutLabel is the graph's output label, normally "aout".
AudioOutLabel string
AudioBitrate int // kbps
SampleRate int
// SecondAudioOutLabel is a SECOND finished mix from the same filter graph,
// mapped and encoded as a second audio track on the same output.
//
// Empty for every destination that has not opted in, which is all of them
// today, and empty produces byte-for-byte the command it produced before this
// field existed. Egress was capped at one audio track until it was measured
// that two survive the wire -- see TestTwoDistinctMixesReachAnRTMPFarEnd,
// which publishes the argv built here through internal/rtmpserver and reads
// the tones back off both received tracks.
//
// TWO, NOT N. Two is what Enhanced Broadcasting needs (a live mix and a
// separate VOD mix) and two is what has been put on a wire and read back. A
// []string here would offer six and have measured none of them.
//
// THE CALLER OWNS THE GRAPH, and today no caller can build one: routing.Compile
// emits a single mix whose internal labels (a_t0, a_mix, aout) are fixed, so
// concatenating two compiled graphs collides on every one of them. Making a
// profile able to describe two mixes is the feature this field exists to be
// ready for, not a thing this field does.
//
// Ignored when it names the same label as AudioOutLabel: a filter output can
// be mapped once, FFmpeg refuses the second map outright, and "the same mix
// twice" is not a second track -- it is the exact failure mode a track COUNT
// cannot see. Ignored on DestAudio for the reason audio-only ignores video:
// an Icecast mount or an audio file is one stream.
SecondAudioOutLabel string
AudioBitrate int // kbps
SampleRate int
// CopyVideo is always true in v1 and is here to make the guarantee
// explicit and testable rather than implicit in the arg list.
CopyVideo bool
Expand Down Expand Up @@ -848,8 +874,12 @@ func StripExtraArgs(argv, in, out []string) []string {
//
// The central promise of polyemesis lives in two lines here: video is
// `-c:v copy` (never re-encoded, never degraded, near-zero CPU) while audio is
// decoded, re-mixed through the routing graph, and re-encoded to the single
// stereo track the platform will accept.
// decoded, re-mixed through the routing graph, and re-encoded to the stereo
// track the platform will accept.
//
// ONE such track, unless SecondAudioOutLabel names a second mix. One remains the
// default and the only shape any caller builds today; the second map is opt-in
// per destination and is what an Enhanced Broadcasting live-plus-VOD pair needs.
//
// CopyAudio is the one destination that opts out of the audio half of that, for
// the outputs where the platform is not the constraint: an SRT contribution feed
Expand Down Expand Up @@ -907,6 +937,7 @@ func DestinationArgs(s DestSpec) []string {
args = append(args, videoDelayArgs(s)...)
}
args = append(args, "-map", "["+s.AudioOutLabel+"]")
args = append(args, secondAudioMap(s)...)

if s.Kind == DestAudio {
return SpliceExtraArgs(append(args, audioOutputArgs(s)...),
Expand All @@ -927,6 +958,35 @@ func DestinationArgs(s DestSpec) []string {
return SpliceExtraArgs(args, s.ExtraInputArgs, s.ExtraOutputArgs)
}

// secondAudioMap renders the map for a destination carrying a second encoded
// audio track. Empty for every destination that has not asked for one.
//
// WHAT LIFTING THIS DID NOT CHANGE. The encoder settings still come from
// audioCodecArgs, which names no stream and therefore applies to BOTH tracks:
// two tracks at the destination's bitrate cost twice the destination's audio
// bitrate, and there is no per-track bitrate. That is a real limitation and it
// is stated rather than hidden behind a field nobody set.
//
// The refusal of `-c:a copy` on an RTMP destination is untouched (see
// db.AudioEncoding.copyProblems). That refusal is about forwarding the INGEST's
// tracks unmixed to a platform, and its reason -- no mainstream ingest documents
// accepting multitrack audio -- is a fact about platforms that this measured
// nothing about. What was measured is the mechanical half: two mixes encoded
// here do reach a real RTMP server as two distinct tracks.
func secondAudioMap(s DestSpec) []string {
if s.SecondAudioOutLabel == "" || s.Kind == DestAudio {
return nil
}
// Mapping one filter output twice is refused by FFmpeg outright, and the
// intent behind it -- "send the mix twice" -- is the failure this feature
// must not produce, so it is dropped here rather than turned into an error
// this function has no way to return.
if s.SecondAudioOutLabel == s.AudioOutLabel {
return nil
}
return []string{"-map", "[" + s.SecondAudioOutLabel + "]"}
}

// copyAudioArgs finishes the command for a destination that forwards its audio
// bit-for-bit: no filter graph, no decoder, no encoder, just maps and copies.
//
Expand Down
Loading
Loading