Skip to content

Speedup/slowdown audio filter - #2738

Open
Davey-Hughes wants to merge 15 commits into
melonDS-emu:masterfrom
Davey-Hughes:speedup-audio-filter
Open

Speedup/slowdown audio filter#2738
Davey-Hughes wants to merge 15 commits into
melonDS-emu:masterfrom
Davey-Hughes:speedup-audio-filter

Conversation

@Davey-Hughes

@Davey-Hughes Davey-Hughes commented Sep 2, 2026

Copy link
Copy Markdown

After working on this other PR for sound settings, I realized there were probably better audio enhancements that could be made when doing ff and slow-mo, so I worked on a two-part audio filter that applies when the game is fast-forwarded (or unbound FPS) and slo-mo'd. The PR is split into 3 commits, with the last 2 able to be dropped if not desired.

image

The first commit is the audio filter itself, and should work properly at up to 8x speedup. There's both a WSOLA stretcher to preserve pitch when stretching, and a Butterworth low pass filter to help cut some of the crunchier noises at fast speeds. This also adds a menu item so you can toggle the feature on/off and decide the frequency cutoff for the low-pass filter.

The second commit is some threading code to make this actually work "correctly" at higher than 8x speed. At more than 8x speedup, the audio output actually skips, though in practice this is kind of hard to hear. I tested this using the tsan build though the emulator runs very slow under these conditions, so it's hard to exactly make sure everything is correct, though I think the theory is right. It deserves some review though.

The last commit is some benchmarking which helped me figure out some of the original implementation details and why the emu-thread implementation was actually needed at all for correctness, though this can be dropped or wired into the CMake build if desired.

Overall I think even just the first commit which is basically pure algorithm implementations help a lot when playing games at speedup, especially the common case of playing pokemon games fast for grinding or whatnot. I'm happy to make adjustments where needed to improve this.

License Provenance

I'm working on this same feature in RetroArch as well. It's more involved however the core is the same WSOLA and butterworth filters.

@Davey-Hughes Davey-Hughes changed the title Speedup audio filter Speedup/slowdown audio filter Sep 2, 2026
@Davey-Hughes
Davey-Hughes force-pushed the speedup-audio-filter branch 4 times, most recently from a2c9755 to 4d2e7d6 Compare September 3, 2026 23:36
the SPU keeps emitting at its real-time rate when the emulator runs off-speed,
so at 3x it produces three times more audio than SDL drains. BufferAudio throws
the surplus away by shoving the read pointer forward, and nothing throttles the
producer because audioSync is skipped off-speed. every discard is a step
discontinuity, which is what the crunch is.

fit the audio to the output with WSOLA instead, which keeps the pitch.
resampling would also remove the clicks but shifts everything by the speed
multiplier, +19 semitones at 3x.

adds two settings under Audio settings: a toggle, on by default, and a low-pass
cutoff that defaults to off. that dialog applies live and doesn't pause the
emulator, so the cutoff can be set by ear while fast-forward is running. with
the toggle off the audio is untreated at any speed. normal speed takes its own
branch in the callback so it can't regress, and slow-mo now expands through the
same path instead of replaying stale frames off the end of the buffer.

draining from the callback caps usable speed near 4x; the next commit lifts it.
the audio callback runs at a fixed rate, so draining from it could only ever
move one ring's worth per period. past roughly 4x the ring overflowed between
callbacks and BufferAudio discarded the rest - at 16.7x only a quarter of the
generated audio ever reached the stretcher. the emu thread keeps up by
construction.

that makes AudioTimeStretch single-producer/single-consumer. only WritePos and
a generation counter cross the boundary, and the consumer publishes the oldest
frame it may still touch so the producer stops short rather than overwriting
it. that floor has to include NaturalPos, which trails the nominal position and
at a large hop sits below the search window - tsan caught that one.

SetOutputSkew moves off the callback too. it only depends on targetFPS, so
calling it per callback was pointless, and it raced SPU::Reset through
blip_set_rates.
standalone, not wired into the build - the build line is at the top of the file.
reports where audio is lost at each speed, what the stretcher costs, and runs
two threads across the SPSC ring with a tearing detector. build it with
-fsanitize=thread for the race check.
the accumulator only carries half a window after the first hop following a
reposition, so the first hop after every BeginSession or short write emitted
the rising half of a Hann window: a fade-in from silence at every engage.
charge the accumulator with one hop whose output is dropped instead, so the
first frames handed out are already at full amplitude.

the flag is named output rather than emit, which Qt defines to nothing.
the arrival average was seeded from the requested speed at every engage. the
request is a ceiling: on a Steam Deck a 3x cap reaches 1.7 to 2.1x, so every
hold started up to half wrong and, at the 0.05 gain, spent most of a short
tap walking the estimate down to the truth. uncapped is worse - curFPS is
1000 there, nowhere near what the machine does.

bank the measured speed at release, before the average is discarded, and
seed the next engage from it, still capped by the request when there is one.
the first hold after a load is unchanged; every hold after it starts from
what the last one reached.
the biquads were redesigned once per block and the whole block filtered with
the new set. a transposed direct-form biquad's state encodes its past under
the coefficients that produced it, so replacing them in one go leaves the two
inconsistent and the filter rings, once per block while the cutoff moves.
on a 200 Hz tone stepped from wide-open to 2 kHz the largest sample-to-sample
jump was 7.9x the steady-state one; interpolating from the old set to the new
across the block brings it to 1.0x.

the block lands exactly on the designed set afterwards, so rounding in the
interpolation cannot accumulate.
the reference is divided by the emulation speed, so the top of the range
bounds how gentle the filter can be: at 24 kHz the mildest setting was a
12 kHz cutoff at 2x on a 48 kHz device. run the range to 48 kHz so the
reference can sit above Nyquist, as RetroArch does. wide-open still clamps
the applied cutoff, so nothing above the device's Nyquist is ever designed.
zero reads as off too, rather than clamping to the strongest filter.

a config saved with the old sentinel, 24000, now reads as a 24 kHz
reference rather than off.
at release the stretcher's unread reserve, about 85 ms, is dropped and the
raw FIFO takes over wherever it stands; at engage the stretcher starts behind
the raw path by the same reserve. either way the output jumps mid-waveform,
and the two sides cannot be joined seamlessly: they are the same material at
different points.

cross-fade 256 frames at equal power from the level the stream stopped at
into the new path, smoothstepped so the gain leaves and arrives with zero
slope, with a shallow dip at the midpoint that attenuates whatever
discontinuity the handover carries. a dip is less audible than a click.

the edge is seen in audioFinishBuffer, on the first buffer after the engaged
flag flips, so both paths share one site.
pausing stopped the SDL device wherever the waveform was and brought it back
at full level: a step at both ends, heard as a click. an underrun in the
stretched path faded the last sample out as a constant, which is a decaying
DC offset rather than a decaying sound, and reads as a thump; in the direct
path it cut straight to silence.

keep a copy of the last frames played. when the stream goes down, find the
period that best matches the last few milliseconds - scored by distance,
since a correlation is blind to level - and repeat it under a raised cosine,
as a packet-loss concealer does, with a join correction so the tail opens on
the value the stream stopped at. when it comes back, ramp the first frames
in, after dropping what the pipeline was still holding. RetroArch measured
the pause step going from 654 to 7 on a tone with the same tail.

the tail is played by the callback, so audioDisable asks for it and waits,
bounded at four callback periods, for the callback to report it spent before
pausing the device. an empty or short read is a stream end without notice
and gets the same tail; slow-mo with the stretcher off keeps its hold, since
that path is untreated by design. audioEnable arms the ramp in.
a load splices the game's own audio, and marking the discontinuity for the
stretcher only stopped its search from spanning the jump: the splice itself
was still played, at normal speed straight from the SPU ring. an undo and a
reset are the same jump.

take the stream down on the pause tail before the jump and bring it back on
the resume ramp after, so the splice and whatever gap the load leaves both
land in silence. a stream already down - the jump was made while paused - is
left alone, so the ramp back up stays with whatever took it down.

the SPU output ring is not part of a savestate, so what it still held from
before the load is drained rather than played. drained whether or not the
bracket ramped: a load made while paused would otherwise bring up to a
frame of pre-load audio back behind the resume ramp and splice at full
level. reset needs no drain: SPU::Reset re-inits the ring, and the
audioEnable that follows it brings the stream back.
audioSync() waits for the SPU ring to drain below the device buffer, but it
only checks that a device exists, not that it is running. A paused device
drains nothing, and the two places that signal audioSyncCond - the audio
callback and audioDrainSPU - are both silent while it is paused, so a frame
run against a paused device spends the whole 500 ms timeout with whatever
is waiting on the emu thread blocked behind it.

Frame stepping pays this now; anything that pauses the device while emulation
still runs would pay it too.
Booting a ROM or the firmware rebuilds the console and resets it while the
audio device is still playing, cutting the previous game off mid-waveform.
Worse, the callback dereferences nds across the delete in updateConsole.

Ending the stream the way a reset or a state jump does is not enough here:
the tail leaves the device playing, so the callback still reads the SPU.
Pause the device instead, which closes that window; the following msg_EmuRun
brings it back. A failed boot sends no msg_EmuRun and leaves emulation
running, so re-enable there.
A frame step runs a frame with the device paused and the emu thread only
drains the SPU ring while off-speed, so every step leaves roughly 800
frames behind. Three of them fill the ring, and the resume plays 43 ms of
stepped audio straight through the mute and the ramp.

Drain in audioEnable rather than only after a step: the tail already
discards source frames across the boundary, so nothing in the ring is a
continuation of what was heard, and this covers every path the stream comes
back on. The same drain audioJumpEnd was doing, now shared.
The similarity search re-read the natural-continuation window through the
ring mask for every candidate, though NaturalPos is fixed for the whole
search: around 519 candidates by 128 samples of redundant masked loads per
hop, on the audio callback thread. Copy it once and index it linearly.

Same values in the same order, so the offset chosen is unchanged.
The search called HistAt twice per sample, and each call is a modulo, for
around 119k iterations on the audio callback thread. It runs on any
sustained short read, not only at a pause.

Sum the reference window's channels once, since it is the same for every
candidate, and step each candidate's index backwards with a wrap instead of
recomputing it. Same values in the same order, so the period chosen is
unchanged.
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