Quick answer: Pygame mixer channel set_endevent firing only on natural end, not on stop()? End event is specifically end-of-playback - track stop calls separately.

Crossfade implementation expects the end event to chain. Manual stop never chains.

Track stop calls

def stop_sound(ch):
    ch.stop()
    on_ended(ch)  # manual

Synthesize the end-of-sound notification. Caller doesn't care if natural or forced.

Or use fadeout

ch.fadeout(100) ends naturally after fade. End event fires correctly.

Avoid stop in chain logic

If you stop, don't expect end event. Different signal; different handling.

“Audio events have specific triggers. End-of-playback isn't the same as stopped.”

Wrap audio playback in a small Sound class that abstracts both natural-end and manual-stop. Single API; clear semantics.

Related reading