Skip to content

avm2: Generate sound from sampleData listeners - #25055

Open
einsz wants to merge 11 commits into
ruffle-rs:masterfrom
einsz:avm2-sound-sampledata
Open

einsz wants to merge 11 commits into
ruffle-rs:masterfrom
einsz:avm2-sound-sampledata

Conversation

@einsz

@einsz einsz commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Description

A Sound with no data plays what its sampleData listeners write into SampleDataEvent.data. Ruffle never dispatched the event, so play() queued forever, and synths, trackers and engines that mix their own audio were silent.

play() on such a sound now starts a channel that asks the listeners for samples as it plays. The samples go into a SampleQueue shared with the audio backend, which a new mixer stream plays as they arrive. The stream plays silence while it waits, and counts only the samples it played, so SoundChannel.position works through the existing get_sound_position. AudioBackend gets one new method, start_sample_queue, implemented in the mixer macro and the null backend.

I checked what Flash Player 32 does with probe SWFs in the release projector, with its audio going to a PulseAudio null sink, which runs in real time:

  • The first sampleData event is dispatched inside play(), at position 0. Flash keeps asking while the listeners write blocks of 8192 samples, up to five, before play() returns.
  • After that it asks again as its buffer drains, about once per 2048 samples played. position counts the samples generated so far on that channel. Every channel of a sound asks separately, starting from 0.
  • Writing fewer than 2048 samples (including none) ends the sound once they have played, and soundComplete reports channel.position at the end of the samples. Writing more than 8192 plays none of them and ends the sound.
  • The samples counted are those before data.position, not data.length: data is the same ByteArray for every request of a sound, rewound to 0 but not cleared.
  • Floats are read with the ByteArray's endianness; a little-endian sine plays the same as a big-endian one.
  • play() on a sound with no data and no sampleData listener throws ArgumentError SWF calculator does not work in 2020-12-22 #2068. A sound that has called load() never asks its listeners.

Flash asks from its audio thread. Ruffle asks once per Player::tick, after the frames have run, keeping the queue ahead of playback by 4096 samples plus two ticks, or two mixing buffers on backends that report one (position_resolution, i.e. web), capped at a second. That is more latency than Flash's, but avoids gaps between ticks.

Testing

Two new tests, with output checked against Flash Player 32:

  • avm2/sound_sampledata: event positions, the reused ByteArray, ending on short, empty, rewound and oversized blocks, replaying, several channels, error SWF calculator does not work in 2020-12-22 #2068, and load().
  • avm2/sound_sampledata_audio: audio assertions for little-endian samples and a SoundTransform. Recorded in Flash, the same SWF peaks at the same levels.

Both use num_ticks: with num_frames the harness calls run_frame without Player::tick, so nothing asks for more samples (NetStream audio has the same limitation there).

Real-world content: three animations that play AHX and ProTracker modules through sampleData were silent (amplitude 0 over 240 ticks in a local audio_assertions run) and now play, audible on every tick. One of them is the loop from #7879, so this should fix its missing sound; I haven't looked at the slow start reported there. #14815, #14156 and #22912 look like the same gap, but I haven't tested them.

Sound.extract() is still a stub, so content that generates sound from other sounds (e.g. gapless MP3 loops) stays silent; that is a separate PR. Such content now calls the stub from its sampleData listener, though, and the stub allocated a buffer of the requested length, with lengths computed from the sound. That panicked with a capacity overflow on the web build, so the stub now extracts nothing. It still returns undefined: a loop like while (len > 0) len -= sound.extract(bytes, len) then ends, where 0 would make it spin until the script timeout.

Checklist

  • I, a human, have self-reviewed this PR and fully understand the changes within.
  • I have made or updated tests where possible.
  • All of my commits are properly scoped, compile successfully, and pass all tests.
  • This PR does not make sense to split up into smaller PRs.
  • An LLM was involved in the authoring of this code.

@einsz einsz mentioned this pull request Oct 31, 2026
5 tasks done
@torokati44

Copy link
Copy Markdown
Member

#14203

@einsz
einsz force-pushed the avm2-sound-sampledata branch from 89d7cc9 to cf5123f Compare November 1, 2026 10:35
@danielhjacobs danielhjacobs added A-avm2 Area: AVM2 (ActionScript 3) T-fix Type: Bug fix (in something that's supposed to work already) labels Nov 2, 2026
@Lord-McSweeney Lord-McSweeney added the llm The PR contains mostly LLM-generated code label Nov 2, 2026
@torokati44

Copy link
Copy Markdown
Member

Could you please cherry-pick the 5 commits from the PR linked above that just add tests, and see whether they also pass on this branch?

@torokati44

Copy link
Copy Markdown
Member

Or even vice versa, whether your tests show something wrong in that other PR - but you don't have to check that, it's just my curiosity.

Also, it's possible that of those 5 tests, some test entirely the same thing as one of your tests, and there's no need to introduce redundancy in the final form.
Additionally, it's possible that some of those tests check something that's platform- or system dependent in FP, and if so, those should be removed.

@torokati44

Copy link
Copy Markdown
Member

And num_ticks should always be preferred instead of num_frames (right, @kjarosh?).
I know, some of my old tests also do this wrong, and the test harness drives tests differently because of this, which is not right.

@torokati44

Copy link
Copy Markdown
Member

Purely for inspiration, don't take it over as is, it's a mess: https://github.com/torokati44/ruffle/tree/einsz-avm2-sound-sampledata

@einsz

einsz commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! Your tests found three differences, which I checked in Flash Player 32 (Linux projector). Your branch matches my measurements for load() and the start time. I've pushed my versions, with your _load() idea:

  • load() throws 2037 once play() has started generating samples, even if play() returned null because no channel was free.
  • The start time is added to SoundChannel.position and to the positions of events after play() returns, but not to playback: play(1000) takes as long to complete as play(0).
  • More events before play() returns than either of our branches had: 1 for 2048-sample blocks, 2 for 2049–2800, 3 for 3000–4300, 4 for 4700–6500, 5 for 6800–8192, and more when smaller blocks follow a larger one. It isn't timing-based. I couldn't find Flash's rule; the formula fits all 61 sequences I measured. This is what makes sample_data_event_data_nulled pass.

New test: avm2/sound_sampledata_play (output from Flash). I took your tests that cover something mine don't (too_many_generated_sounds, generated_sound_play_twice, sample_data_event_data_nulled, sound_sampledata_bytearray, and the num_ticks commit). The other three overlap with mine but pass too.

@danielhjacobs danielhjacobs added the waiting-on-review Waiting on review from a Ruffle team member label Nov 4, 2026
@torokati44
torokati44 force-pushed the avm2-sound-sampledata branch 3 times, most recently from 7a97046 to c5d5a22 Compare November 7, 2026 18:56
sampleData listeners commonly call it, and it used to allocate a buffer
of the requested length, which overflowed on the web build for lengths
computed from the sound. It still returns undefined, so that loops such
as while (len > 0) len -= sound.extract(bytes, len) end.
@torokati44

torokati44 commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

FYI @einsz in addition to rebasing this PR onto latest master, I've also squashed some of the num_frames -> num_ticks commits into the ones that add the affected tests in the first place, and removed some LLM references from the commit message of another added test...
Also I'm considering splitting out the stubbification of Sound.extract into its own commit, I hope you don't mind.

einsz and others added 10 commits November 7, 2026 21:19
A SampleQueue holds sample frames shared between the player, which
queues them, and the audio backend, which plays them as they arrive,
after AudioBackend::start_sample_queue. The mixer plays them with a new
stream that plays silence while it waits for more and counts only the
samples it played, and that ends once the queue has been ended and
played out.

This is for AVM2 sounds with no data, which ask their sampleData
listeners for samples as they play.
A Sound with no data plays what its sampleData listeners write into
SampleDataEvent.data. Ruffle never dispatched the event, so play()
queued forever and such sounds were silent.

play() on such a sound now starts a channel that asks the listeners for
samples as it plays, following Flash Player 32: requests come inside
play() until a few blocks are queued (one of 2048 samples, three of
4096, five of 8192), and a block of fewer than 2048 samples, or more
than 8192, ends the sound. Samples are counted up to data.position,
data is one ByteArray per sound, rewound but not cleared, and play()
without a listener throws ArgumentError ruffle-rs#2068. Once play() has
generated samples, load() throws Error ruffle-rs#2037, even if play() returned
null because no channel was free.

The samples go into a SampleQueue, which counts only played samples, so
SoundChannel.position needs no special case while playing. Later
requests happen once per Player::tick, keeping the queue a tick and a
mixing buffer ahead of playback.
Output checked against Flash Player 32. The audio test runs by ticks:
with num_frames the harness calls run_frame without Player::tick, so
nothing would ask for more samples.
For a sound without data, Flash Player ignores play()'s start time for
the samples (a generated sound takes as long to complete with play(1000)
as with play(0)), but adds it to SoundChannel.position and to the
position of every sampleData event dispatched after play() returns. The
events before that report positions from 0.
Flash Player's Sound.load calls a native _load, which shows in stack
traces, such as that of error 2037 when loading into a sound that
generated samples.

Co-authored-by: TÖRÖK Attila <torokati44@gmail.com>
Checks that every channel playing a `Sound` asks its `sampleData`
listeners to write into the same `ByteArray`, not cleared between
channels, and that another `Sound` has its own.
@torokati44
torokati44 force-pushed the avm2-sound-sampledata branch from c5d5a22 to eb86716 Compare November 7, 2026 20:22
@torokati44

Copy link
Copy Markdown
Member

(pinging those who have reviewed #14203 in the past)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-avm2 Area: AVM2 (ActionScript 3) llm The PR contains mostly LLM-generated code T-fix Type: Bug fix (in something that's supposed to work already) waiting-on-review Waiting on review from a Ruffle team member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants