Skip to content

libretro-common: rmp3_stream f32 output; audio_transfer MP3 arm moved onto the stream - #19318

Merged
LibretroAdmin merged 2 commits into
masterfrom
more-audio-streaming-improvements
Aug 1, 2026
Merged

libretro-common: rmp3_stream f32 output; audio_transfer MP3 arm moved onto the stream#19318
LibretroAdmin merged 2 commits into
masterfrom
more-audio-streaming-improvements

Conversation

@LibretroAdmin

@LibretroAdmin LibretroAdmin commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #19316 and #19317. Two commits: the rmp3 streaming API gains the float pipeline, and audio_transfer's plain-MP3 arm — the last arm holding a whole-file pull decoder over its buffer — moves onto the stream.

rmp3: add f32 output to the streaming API

The stream decoded to s16 only, which left a caller serving a float pipeline (audio_transfer's f32 read, and through it the mixer's float voices) unable to move off the resident pull API without a quantisation it never had before. The decoder core already runs both pipelines; what the stream lacked was a way to select one.

  • rmp3_stream_set_out_f32, shaped like its s16 partner. Selecting either converts the decoder's persistent filter state and any undrained frame in place — the same operation the pull API's reads perform, now factored into shared helpers (rmp3d_convert_state, rmp3d_convert_frames) rather than restated. The frame buffer becomes the same s16/f32 union the pull API holds, named (rmp3_frame_buf) so both sides convert through one function with union-member access and no aliasing games.
  • A parse-only call selects nothing: a walk in the middle of a decode must not disturb the pipeline the decode is in. rmp3_stream_info's doc now says what the code always did — a located frame fills the channel count and rate in, so a parse-only walk answers it without anything having been decoded.
  • A mid-stream channel change now adapts instead of erroring: mono duplicated, stereo averaged into the stream's own layout, the same arithmetic the pull API's reads apply; a rate change decodes through at the stream's stated rate, which is all the pull API ever did with one. The stream refusing what the pull API played would have turned concatenated-rip joins from a wrong-but-playing tail into a truncation, and the caller this API exists to migrate plays such files today.

audio_transfer: move the plain-MP3 arm onto rmp3_stream

rmp3's pull API borrows the buffer pointer and cursors over all of it, so the frontier buffer_tell reported (readPos) named bytes merely read, not bytes releasable, and windowing an MP3 the way the Ogg arm windows a Vorbis file had nothing to stand on. The stream consumes its input — what is behind the cursor is genuinely done with, a frame straddling the boundary held in the stream's own 4 KB reassembly hold — so the cursor kept here is a real frontier, leading the decode position by at most that hold, the safe side for a feeder.

  • Open walks the stream parse-only to the first frame, which is where the channel count and rate come from and decodes nothing, then rewinds for the reads. The Xing/LAME/VBRI parse and the header-walk length fallback are untouched.
  • Seeking adopts the Opus arm's shape: recorded at seek(), performed at the following read as a reset and decode forward in that read's own pipeline, refused up front where it lies past a known length.

That closes two seams the resident decoder carried. Its rewind kept the synthesis filter's state — QMF and overlap history from wherever playback had been — so the first frames after any seek, and after every loop back to the start, deviated from a linear decode; and it walked forward in f32 whatever the caller's format, converting the filter state through a float round-trip on the way. Measured against linear ground truth, the old arm deviated on every fixture at every seek target and on every loop pass (1–342 bytes per 16 K comparison window, the worst on a rate-change join); the new arm is byte-identical to the playthrough everywhere, in both formats.

Behavioural divergences

Beyond the seek correctness above, three, all deliberate:

  • buffer_tell reports the consumption frontier rather than the pull cursor's scan position.
  • A seek moves nothing until the next read, so a tell between the two still names the old position.
  • A stream the length walk could not measure (free format) fails a past-the-end seek at the read that performs it rather than at seek() itself.

Verification

Seven fixtures: LAME CBR 128/320, VBR -q0, MPEG-2 mono 22.05 kHz, Layer II, and stereo+mono / 44.1k+22.05k concatenations.

  • Stream vs pull API as oracle, bit-exact: f32 and s16 each across 25 combinations of input window (512 bytes to whole file) and output chunk (4 frames to 256 K), plus 32-segment alternating s16/f32 sequences at frame counts that land the switches mid-frame with samples pending.
  • New arm vs old arm linked beside it, byte-exact: linear decode in both formats across chunk sizes 1 to 64 K frames, mixed-format segment sequences, info().
  • New arm vs linear ground truth, byte-exact: every seek target and every loop pass, both formats; the old arm measured against the same truth and its deviation reported above.
  • ASan, UBSan and LeakSan clean over open/decode/seek/loop/teardown, short and truncated files, and a garbage buffer whose open is refused without leaking the stream. TSan clean with eight concurrent streams/contexts mixing formats and seeking.
  • C89 (-std=c89 -pedantic) and gnu99 -Wall -Wextra clean.

The stream decoded to s16 only, which left a caller serving a float
pipeline - audio_transfer's f32 read, and through it the mixer's float
voices - unable to move off the resident pull API without a
quantisation it never had before.

The decoder core already runs both pipelines; what the stream lacked
was a way to select one.  rmp3_stream_set_out_f32 is that, shaped like
its s16 partner, and selecting either converts the decoder's persistent
filter state and any undrained frame in place - the same operation the
pull API's reads perform, now factored into shared helpers
(rmp3d_convert_state, rmp3d_convert_frames) rather than restated.  The
frame buffer becomes the same s16/f32 union the pull API holds, named
(rmp3_frame_buf) so both sides convert through one function with
union-member access and no aliasing games.

A parse-only call selects nothing: a walk in the middle of a decode
must not disturb the pipeline the decode is in.  And rmp3_stream_info's
doc now says what the code always did - a located frame is what fills
the channel count and rate in, so a parse-only walk answers it without
anything having been decoded.

A mid-stream channel change now adapts instead of erroring: mono
duplicated, stereo averaged into the stream's own layout, the same
arithmetic the pull API's reads apply, and a rate change decodes
through at the stream's stated rate, which is all the pull API ever
did with one.  The stream refusing what the pull API played would have
turned concatenated-rip joins from a wrong-but-playing tail into a
truncation, and the caller this API exists to migrate plays such
files today.

Verified bit-exact against the pull API as oracle on seven fixtures
(LAME CBR 128/320, VBR -q0, MPEG-2 mono 22.05k, Layer II, and
stereo+mono / 44.1k+22.05k concatenations): f32 and s16 each across 25
combinations of input window (512 bytes to whole file) and output
chunk (4 frames to 256K), and 32-segment alternating s16/f32 sequences
at frame counts that land the switches mid-frame with samples pending.
ASan, UBSan and LeakSan clean; TSan clean with eight concurrent
streams in mixed and switching modes.
The last arm holding a whole-file decoder over its buffer.  rmp3's
pull API borrows the pointer and cursors over all of it, so the
frontier buffer_tell reported (readPos) named bytes merely read, not
bytes releasable, and windowing an MP3 the way the Ogg arm windows a
Vorbis file had nothing to stand on.  The stream consumes its input -
what is behind the cursor is genuinely done with, a frame possibly
straddling the boundary held in the stream's own 4 KB reassembly hold
- so the cursor kept here is a real frontier, leading the decode
position by at most that hold, the safe side for a feeder.

Open walks the stream parse-only to the first frame, which is where
the channel count and rate come from and decodes nothing, then rewinds
for the reads; the Xing/LAME/VBRI parse and the header-walk length
fallback are untouched.  Reads drain through the stream in either
pipeline, freely mixed as before.

Seeking adopts the Opus arm's shape: recorded at seek(), performed at
the following read as a reset and decode forward in that read's own
pipeline, and refused up front where it lies past a known length.
That closes two seams the resident decoder carried.  Its rewind kept
the synthesis filter's state - QMF and overlap history from wherever
playback had been - so the first frames after any seek, and after
every loop back to the start, deviated from a linear decode; and it
walked forward in f32 whatever the caller's format, converting the
filter state through a float round-trip on the way.  Measured against
linear ground truth, the old arm deviated on every fixture at every
seek target and on every loop pass (1-342 bytes per 16 K window, the
worst on a rate-change join); the new arm is byte-identical to the
playthrough everywhere, in both formats.

Behaviour otherwise intended to be identical, and verified so against
the old arm linked beside the new one: linear decode byte-exact in
s16 and f32 across chunk sizes 1 to 64 K frames, mixed-format segment
sequences byte-exact, info() identical, on seven fixtures including
stereo+mono and 44.1k+22.05k concatenations (which the stream now
adapts as the pull API did).  What is not identical, beyond the seek
correctness above: buffer_tell reports the consumption frontier
rather than the pull cursor's scan position; a seek moves nothing
until the next read, so a tell between the two still names the old
position; and a stream the length walk could not measure (free
format) fails a past-the-end seek at the read that performs it rather
than at seek() itself.

ASan, UBSan and LeakSan clean over open/decode/seek/loop/teardown,
short and truncated files, and a garbage buffer whose open is refused
without leaking the stream; TSan clean with eight concurrent contexts
mixing formats and seeking.
@LibretroAdmin LibretroAdmin changed the title More audio streaming improvements libretro-common: rmp3_stream f32 output; audio_transfer MP3 arm moved onto the stream Aug 1, 2026
@LibretroAdmin
LibretroAdmin merged commit 3037f11 into master Aug 1, 2026
86 checks passed
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.

1 participant