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) # manualSynthesize 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.