Quick answer: End events in Pygame are attached to channels, not sounds. Sound.set_endevent is effectively a no-op in modern builds — use Channel.set_endevent on the channel returned by Sound.play. Register a proper event type above pygame.USEREVENT, capture the channel when you play, and remember that a Channel.pause does not trigger the end event but stop, fadeout, and natural completion all do.
You want a callback when a sound effect finishes so you can queue the next phrase of VO or trigger a gameplay event. You reach for Sound.set_endevent, read the docs, write the code, and… nothing happens. The event never appears in the queue. You add print statements, switch to a fresh .wav, restart Python, and the sound clearly plays to the end — it is just the event that refuses to fire. Here is what is actually going on.
Why Sound.set_endevent Fails Silently
Historically, Pygame wrapped SDL_mixer, which ties end-of-playback callbacks to the channel that played the audio, not to the sound object itself. A single sound can be played on any of the mixer’s 8 default channels (or more if you called pygame.mixer.set_num_channels), and each channel plays whatever sound was most recently routed to it. There is no way for the underlying layer to associate an event with a sound object independently of the channel.
Pygame exposes Sound.set_endevent for API compatibility, but on modern builds it either silently does nothing or only works when the sound is played on a newly allocated channel and never reused. The reliable path is Channel.set_endevent.
Correct Pattern: Capture the Channel
import pygame
pygame.init()
pygame.mixer.init()
# Event type above USEREVENT, unique per semantic event
SOUND_FINISHED = pygame.USEREVENT + 1
laser = pygame.mixer.Sound("laser.wav")
# Sound.play returns the Channel it used.
channel = laser.play()
if channel is not None:
channel.set_endevent(SOUND_FINISHED)
When playback finishes — whether naturally, via Channel.stop, or at the end of a fadeout — Pygame posts SOUND_FINISHED to the event queue. Your main loop processes it like any other event:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == SOUND_FINISHED:
print("Sound finished on channel", event.dict)
queue_next_phrase()
Note that Sound.play can return None if all channels are busy and none can be stolen. Always guard against that — otherwise the set_endevent call raises AttributeError and you only discover the crash when the mixer saturates.
Channel vs Sound Semantics
Because the endevent is per-channel, a sound played on two different channels produces two independent endevents. Conversely, if you Sound.play again on a channel that already had an endevent set, the new play keeps the old endevent — channels remember their event until you overwrite it with another set_endevent call or disable it with set_endevent(0).
This matters for pooled audio. If you allocate a dedicated channel for UI feedback and reuse it:
UI_CH = pygame.mixer.Channel(7)
UI_CH.set_endevent(SOUND_FINISHED)
# Every sound played on this channel will post SOUND_FINISHED
def play_ui(sound):
UI_CH.play(sound) # endevent carries over
If you have semantically different sounds on the same channel and want different events, route them through a dispatcher that calls set_endevent right before each play with the appropriate event type.
Event Type Registration
Pygame events are ints. Anything below pygame.USEREVENT is reserved for built-in events. Anything at or above it is yours, up to pygame.NUMEVENTS - 1 (typically 31 slots on older versions, 32 on newer).
Define one constant per semantic event and stick to it. A common bug is to pass a bare integer like 24 to set_endevent that happens to collide with a built-in event on the current Pygame version — the event fires but the main loop cannot distinguish it from, say, a joystick event.
MUSIC_FINISHED = pygame.USEREVENT + 1
VOICE_FINISHED = pygame.USEREVENT + 2
SFX_FINISHED = pygame.USEREVENT + 3
# Attach different events to different channels
pygame.mixer.Channel(0).set_endevent(MUSIC_FINISHED)
pygame.mixer.Channel(1).set_endevent(VOICE_FINISHED)
pygame.mixer.Channel(2).set_endevent(SFX_FINISHED)
Fadeouts, Stops, and Pauses
The conditions under which an endevent posts are subtle:
- Natural completion: sound plays to the end of its buffer. Fires the event.
- Channel.stop(): explicit stop. Fires the event.
- Channel.fadeout(ms): gradual silence. Fires the event when the fade completes.
- Sound.stop(): stops every channel currently playing this sound. Each channel fires its own event.
- Channel.pause(): does not fire the event (playback has not ended, just suspended).
- Channel.unpause() followed by completion: fires normally when the resumed playback completes.
If you rely on the event to queue the next clip and your code also calls pause for other reasons, guard the dispatch logic against spurious triggers after unpause. Similarly, do not assume the event’s dispatch order maps to a specific channel — if two sounds finish in the same frame, both events land in the queue with no guaranteed order.
“Sound objects don’t have lifetimes. Channels do. Always wire the endevent to the channel.”
Related Issues
If audio sometimes cuts off mid-playback instead of firing the endevent, see Mixer Channel Cutting Out for channel-stealing behavior when the pool is saturated. If background music loops but never fires an endevent regardless of setup, check Music Not Looping Correctly — pygame.mixer.music uses a completely separate event system (pygame.mixer.music.set_endevent) from Sounds.