Skip to content

Add Sound.copy and Sound.__copy__ #3556

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
3 changes: 3 additions & 0 deletions buildconfig/stubs/pygame/mixer.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ from pygame.event import Event
from pygame.typing import FileLike
from typing_extensions import (
Buffer, # collections.abc 3.12
Self,
deprecated, # added in 3.13
)

Expand Down Expand Up @@ -72,6 +73,8 @@ class Sound:
def get_num_channels(self) -> int: ...
def get_length(self) -> float: ...
def get_raw(self) -> bytes: ...
def copy(self) -> Self: ...
def __copy__(self) -> Self: ...

class Channel:
def __init__(self, id: int) -> None: ...
Expand Down
24 changes: 24 additions & 0 deletions docs/reST/ref/mixer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ The following file formats are supported
an exception when different. Also, source samples are truncated to fit the
audio sample size. This will not change.

.. note:: ``bytes(Sound)`` and ``bytearray(Sound)`` make use of the buffer
interface, which is implemented internally by ``pygame.mixer.Sound``.
Because of this, there is no need to directly implement ``__bytes__``.

.. versionaddedold:: 1.8 ``pygame.mixer.Sound(buffer)``
.. versionaddedold:: 1.9.2
:class:`pygame.mixer.Sound` keyword arguments and array interface support
Expand Down Expand Up @@ -500,6 +504,26 @@ The following file formats are supported

.. ## Sound.get_raw ##

.. method:: copy

| :sl:`return a new Sound object that is a deep copy of this Sound`
| :sg:`copy() -> Sound`
| :sg:`copy.copy(original_sound) -> Sound`

Return a new Sound object that is a deep copy of this Sound. The new Sound
will be just as if you loaded it from the same file on disk as you did the
original Sound. If the copy fails, a ``TypeError`` or :meth:`pygame.error`
exception will be raised.

If copying a subclass of ``mixer.Sound``, an instance of the same subclass
will be returned.

Also note that this functions as ``Sound.__copy__``.

.. versionadded:: 2.5.6

.. ## Sound.copy ##

.. ## pygame.mixer.Sound ##

.. class:: Channel
Expand Down
1 change: 1 addition & 0 deletions src_c/doc/mixer_doc.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#define DOC_MIXER_SOUND_GETNUMCHANNELS "get_num_channels() -> count\ncount how many times this Sound is playing"
#define DOC_MIXER_SOUND_GETLENGTH "get_length() -> seconds\nget the length of the Sound"
#define DOC_MIXER_SOUND_GETRAW "get_raw() -> bytes\nreturn a bytestring copy of the Sound samples."
#define DOC_MIXER_SOUND_COPY "copy() -> Sound\ncopy.copy(original_sound) -> Sound\nreturn a new Sound object that is a deep copy of this Sound"
#define DOC_MIXER_CHANNEL "Channel(id) -> Channel\nCreate a Channel object for controlling playback"
#define DOC_MIXER_CHANNEL_ID "id -> int\nget the channel id for the Channel object"
#define DOC_MIXER_CHANNEL_PLAY "play(Sound, loops=0, maxtime=0, fade_ms=0) -> None\nplay a Sound on a specific Channel"
Expand Down
73 changes: 71 additions & 2 deletions src_c/mixer.c
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ pgChannel_New(int);
#define pgChannel_Check(x) \
(PyObject_IsInstance(x, (PyObject *)&pgChannel_Type))

static PyObject *
snd_get_arraystruct(PyObject *self, void *closure);
static int
snd_getbuffer(PyObject *, Py_buffer *, int);
static void
Expand Down Expand Up @@ -517,8 +519,14 @@ mixer_quit(PyObject *self, PyObject *_null)

if (channeldata) {
for (i = 0; i < numchanneldata; ++i) {
Py_XDECREF(channeldata[i].sound);
Py_XDECREF(channeldata[i].queue);
if (channeldata[i].sound) {
Mix_HaltGroup(
(int)(intptr_t)pgSound_AsChunk(channeldata[i].sound));
}
if (channeldata[i].queue) {
Mix_HaltGroup(
(int)(intptr_t)pgSound_AsChunk(channeldata[i].queue));
}
Comment on lines 520 to +529
Copy link

@coderabbitai coderabbitai bot Aug 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Leak: channeldata[i].sound and .queue never DECREF’d in mixer_quit

Each channel can hold references to Python Sound objects in both fields. The loop halts groups but never releases those Python references, leaking one ref per non-NULL field per channel.

Proposed fix:

         if (channeldata) {
             for (i = 0; i < numchanneldata; ++i) {
-                if (channeldata[i].sound) {
-                    Mix_HaltGroup(
-                        (int)(intptr_t)pgSound_AsChunk(channeldata[i].sound));
-                }
-                if (channeldata[i].queue) {
-                    Mix_HaltGroup(
-                        (int)(intptr_t)pgSound_AsChunk(channeldata[i].queue));
-                }
+                if (channeldata[i].sound) {
+                    Mix_HaltGroup(
+                        (int)(intptr_t)pgSound_AsChunk(channeldata[i].sound));
+                    Py_DECREF(channeldata[i].sound);
+                    channeldata[i].sound = NULL;
+                }
+                if (channeldata[i].queue) {
+                    Mix_HaltGroup(
+                        (int)(intptr_t)pgSound_AsChunk(channeldata[i].queue));
+                    Py_DECREF(channeldata[i].queue);
+                    channeldata[i].queue = NULL;
+                }
             }
             free(channeldata);

Optional: Instead of per-chunk Mix_HaltGroup inside the loop, a single Mix_HaltChannel(-1) or Mix_HaltGroup(-1) outside the loop would be more efficient; but the above keeps current behavior intact.

🤖 Prompt for AI Agents
In src_c/mixer.c around lines 520 to 529, the loop halts groups for
channeldata[i].sound and channeldata[i].queue but never Py_DECREFs those Python
Sound object references; add Py_XDECREF(channeldata[i].sound) and
Py_XDECREF(channeldata[i].queue) immediately after the Mix_HaltGroup calls (and
set the fields to NULL to avoid dangling references). Ensure you include
Python.h reference semantics (use Py_XDECREF to handle NULL safely) and keep the
existing Mix_HaltGroup behavior; alternatively you may replace the per-item
halting with a single Mix_HaltChannel(-1)/Mix_HaltGroup(-1) outside the loop but
still perform the DECREFs inside the loop if you retain the current iteration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about not changing this block of code, but doing Mix_ChannelFinished(NULL) (disable the callback and let this function do the cleaning as intended) just when mixer quit is called, does that solve this issue? I believe that would be the cleanest fix to this problem.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@ankith26, that's an insightful suggestion! Let me analyze this approach:

Your suggestion to call Mix_ChannelFinished(NULL) to disable the callback has merit, but there are a few considerations:

  1. Timing issue: The Mix_HaltGroup() calls are synchronous, but the callback cleanup would be asynchronous. Since mixer_quit needs to immediately free channeldata, we can't rely on asynchronous cleanup.

  2. Original behavior: Looking at the diff, the original code actually had Py_XDECREF() calls that were replaced with Mix_HaltGroup() calls. The issue is that the DECREFs were removed entirely.

  3. Callback vs direct cleanup: The endsound_callback does handle proper cleanup with Py_XDECREF, but it's designed for normal channel completion, not shutdown.

I think the cleanest solution would be a hybrid approach:

// Disable callback to prevent interference during shutdown
Mix_ChannelFinished(NULL);

if (channeldata) {
    for (i = 0; i < numchanneldata; ++i) {
        if (channeldata[i].sound) {
            Mix_HaltGroup((int)(intptr_t)pgSound_AsChunk(channeldata[i].sound));
            Py_XDECREF(channeldata[i].sound);
        }
        if (channeldata[i].queue) {
            Mix_HaltGroup((int)(intptr_t)pgSound_AsChunk(channeldata[i].queue));
            Py_XDECREF(channeldata[i].queue);
        }
    }
    free(channeldata);
    channeldata = NULL;
    numchanneldata = 0;
}

This combines your suggestion (disabling the callback) with restoring the reference management that was inadvertently removed. What do you think about this approach?


🏁 Script executed:

#!/bin/bash
# Let's look at the endsound_callback function to understand how it handles cleanup
ast-grep --pattern 'static void endsound_callback($_) {
  $$$
}'

Length of output: 66


@ankith26, thank you for the clarification! Now I see the full picture. Looking at the endsound_callback function, you're absolutely right that calling Mix_ChannelFinished(NULL) would be the cleanest approach. Here's why:

The endsound_callback properly handles Py_XDECREF for both sound and queue fields, but there's a race condition risk: if Mix_HaltGroup() triggers the callback asynchronously, it could access channeldata after mixer_quit has already freed it.

Your suggestion to disable the callback first eliminates this race condition. The complete fix would be:

// Disable callback to prevent race conditions during shutdown
Mix_ChannelFinished(NULL);

if (channeldata) {
    for (i = 0; i < numchanneldata; ++i) {
        if (channeldata[i].sound) {
            Mix_HaltGroup((int)(intptr_t)pgSound_AsChunk(channeldata[i].sound));
            Py_XDECREF(channeldata[i].sound);
        }
        if (channeldata[i].queue) {
            Mix_HaltGroup((int)(intptr_t)pgSound_AsChunk(channeldata[i].queue));
            Py_XDECREF(channeldata[i].queue);  
        }
    }
    free(channeldata);
    channeldata = NULL;
    numchanneldata = 0;
}

This approach:

  1. Prevents the callback from interfering during shutdown
  2. Still halts the audio groups properly
  3. Manually handles the Python reference cleanup that was removed from the original code
  4. Maintains thread safety

You're right that this is much cleaner than trying to coordinate with the callback mechanism during shutdown. Great insight!


🧬 Code Graph Analysis Results
  • src_c/mixer.c: ChannelData struct and related globals
struct ChannelData {
    PyObject *sound;
    PyObject *queue;
    int endevent;
};
static struct ChannelData *channeldata = NULL;
static int numchanneldata = 0;
  • src_c/mixer.c: endsound_callback function
static void
endsound_callback(int channel)
{
    if (channeldata) {
        if (channeldata[channel].endevent && SDL_WasInit(SDL_INIT_VIDEO)) {
            _pg_push_mixer_event(channeldata[channel].endevent, channel);
        }

        if (channeldata[channel].queue) {
            PyGILState_STATE gstate = PyGILState_Ensure();
            int channelnum;
            Mix_Chunk *sound = pgSound_AsChunk(channeldata[channel].queue);
            Py_XDECREF(channeldata[channel].sound);
            channeldata[channel].sound = channeldata[channel].queue;
            channeldata[channel].queue = NULL;
            PyGILState_Release(gstate);
            channelnum = Mix_PlayChannelTimed(channel, sound, 0, -1);
            if (channelnum != -1) {
                Mix_GroupChannel(channelnum, (int)(intptr_t)sound);
            }
        }
        else {
            PyGILState_STATE gstate = PyGILState_Ensure();
            Py_XDECREF(channeldata[channel].sound);
            channeldata[channel].sound = NULL;
            PyGILState_Release(gstate);
            Mix_GroupChannel(channel, -1);
        }
    }
}
  • src_c/mixer.c: Mix_ChannelFinished callback registration (in mixer initialization)
Mix_ChannelFinished(endsound_callback);
  • src_c/mixer.c: mixer_quit function
static PyObject *
mixer_quit(PyObject *self, PyObject *_null)
{
    int i;
    if (SDL_WasInit(SDL_INIT_AUDIO)) {
        Py_BEGIN_ALLOW_THREADS;
        Mix_HaltMusic();
        Py_END_ALLOW_THREADS;

        if (channeldata) {
            for (i = 0; i < numchanneldata; ++i) {
                if (channeldata[i].sound) {
                    Mix_HaltGroup(
                        (int)(intptr_t)pgSound_AsChunk(channeldata[i].sound));
                }
                if (channeldata[i].queue) {
                    Mix_HaltGroup(
                        (int)(intptr_t)pgSound_AsChunk(channeldata[i].queue));
                }
            }
            free(channeldata);
            channeldata = NULL;
            numchanneldata = 0;
        }

        if (mx_current_music) {
            if (*mx_current_music) {
                Py_BEGIN_ALLOW_THREADS;
                Mix_FreeMusic(*mx_current_music);
                Py_END_ALLOW_THREADS;
                *mx_current_music = NULL;
            }
            mx_current_music = NULL;
        }
        if (mx_queue_music) {
            if (*mx_queue_music) {
                Py_BEGIN_ALLOW_THREADS;
                Mix_FreeMusic(*mx_queue_music);
                Py_END_ALLOW_THREADS;
                *mx_queue_music = NULL;
            }
            mx_queue_music = NULL;
        }

        Py_BEGIN_ALLOW_THREADS;
        Mix_CloseAudio();
        SDL_QuitSubSystem(SDL_INIT_AUDIO);
        Py_END_ALLOW_THREADS;
    }
    Py_RETURN_NONE;
}

✏️ Learnings added
Learnt from: ankith26
PR: pygame-community/pygame-ce#3556
File: src_c/mixer.c:520-529
Timestamp: 2025-08-17T17:23:06.756Z
Learning: In pygame-ce's mixer.c, when fixing reference leaks in mixer_quit, the cleanest approach is to first call Mix_ChannelFinished(NULL) to disable the callback and prevent race conditions, then manually handle Py_XDECREF calls for channeldata[i].sound and channeldata[i].queue after the Mix_HaltGroup calls.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: oddbookworm
PR: pygame-community/pygame-ce#3556
File: src_c/mixer.c:806-836
Timestamp: 2025-08-13T12:39:19.107Z
Learning: In pygame-ce's mixer.c, the snd_copy function requires Py_INCREF(newSound) before returning to maintain proper reference counting. Without it, copied Sound objects get deallocated prematurely.

}
free(channeldata);
channeldata = NULL;
Expand Down Expand Up @@ -801,6 +809,65 @@ snd_get_raw(PyObject *self, PyObject *_null)
(Py_ssize_t)chunk->alen);
}

static PyObject *
snd_copy(PyObject *self, PyObject *_null)
{
MIXER_INIT_CHECK();

pgSoundObject *newSound =
(pgSoundObject *)Py_TYPE(self)->tp_new(Py_TYPE(self), NULL, NULL);

if (!newSound) {
if (!PyErr_Occurred()) {
PyErr_SetString(PyExc_MemoryError,
"Failed to create new Sound object for copy");
}
return NULL;
}

PyObject *dict = PyDict_New();
if (!dict) {
Py_DECREF(newSound);
return NULL;
}

PyObject *bytes = snd_get_raw(self, NULL);
if (bytes == NULL) {
// exception set already by PyBytes_FromStringAndSize
Py_DECREF(dict);
Py_DECREF(newSound);
return NULL;
}

if (PyDict_SetItemString(dict, "buffer", bytes) < 0) {
// exception set already
Py_DECREF(bytes);
Py_DECREF(dict);
Py_DECREF(newSound);
return NULL;
}
Py_DECREF(bytes);

if (sound_init((PyObject *)newSound, NULL, dict) != 0) {
Py_DECREF(dict);
Py_DECREF(newSound);
// Exception set by sound_init
return NULL;
}

// Preserve original volume on the new chunk
Mix_Chunk *orig = pgSound_AsChunk(self);
Mix_Chunk *dst = pgSound_AsChunk((PyObject *)newSound);

if (orig && dst) {
int vol = Mix_VolumeChunk(orig, -1);
Mix_VolumeChunk(dst, vol);
}

Py_DECREF(dict);
return (PyObject *)newSound;
}

static PyObject *
snd_get_arraystruct(PyObject *self, void *closure)
{
Expand Down Expand Up @@ -858,6 +925,8 @@ PyMethodDef sound_methods[] = {
{"get_volume", snd_get_volume, METH_NOARGS, DOC_MIXER_SOUND_GETVOLUME},
{"get_length", snd_get_length, METH_NOARGS, DOC_MIXER_SOUND_GETLENGTH},
{"get_raw", snd_get_raw, METH_NOARGS, DOC_MIXER_SOUND_GETRAW},
{"copy", snd_copy, METH_NOARGS, DOC_MIXER_SOUND_COPY},
{"__copy__", snd_copy, METH_NOARGS, DOC_MIXER_SOUND_COPY},
{NULL, NULL, 0, NULL}};

static PyGetSetDef sound_getset[] = {
Expand Down
109 changes: 109 additions & 0 deletions test/mixer_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import os
import pathlib
import platform
Expand Down Expand Up @@ -1330,6 +1331,114 @@ def __init__(self):

self.assertRaises(RuntimeError, incorrect.get_volume)

def test_snd_copy(self):
class SubSound(mixer.Sound):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

mixer.init()

filenames = [
"house_lo.ogg",
"house_lo.wav",
"house_lo.flac",
"house_lo.opus",
"surfonasinewave.xm",
]
old_volumes = [0.1, 0.2, 0.5, 0.7, 1.0]
new_volumes = [0.2, 0.3, 0.7, 1.0, 0.1]
if pygame.mixer.get_sdl_mixer_version() >= (2, 6, 0):
filenames.append("house_lo.mp3")
old_volumes.append(0.9)
new_volumes.append(0.5)

for f, old_vol, new_vol in zip(filenames, old_volumes, new_volumes):
filename = example_path(os.path.join("data", f))
try:
sound = mixer.Sound(file=filename)
sound.set_volume(old_vol)
except pygame.error:
continue
sound_copy = sound.copy()
self.assertEqual(sound.get_length(), sound_copy.get_length())
self.assertEqual(sound.get_num_channels(), sound_copy.get_num_channels())
self.assertEqual(sound.get_volume(), sound_copy.get_volume())
self.assertEqual(sound.get_raw(), sound_copy.get_raw())

sound.set_volume(new_vol)
self.assertNotEqual(sound.get_volume(), sound_copy.get_volume())

del sound

# Test on the copy for playable sounds
channel = sound_copy.play()
if channel is None:
continue
self.assertTrue(channel.get_busy())
sound_copy.stop()
self.assertFalse(channel.get_busy())
sound_copy.play()
self.assertEqual(sound_copy.get_num_channels(), 1)

# Test __copy__
for f, old_vol, new_vol in zip(filenames, old_volumes, new_volumes):
filename = example_path(os.path.join("data", f))
try:
sound = mixer.Sound(file=filename)
sound.set_volume(old_vol)
except pygame.error:
continue
sound_copy = copy.copy(sound)
self.assertEqual(sound.get_length(), sound_copy.get_length())
self.assertEqual(sound.get_num_channels(), sound_copy.get_num_channels())
self.assertEqual(sound.get_volume(), sound_copy.get_volume())
self.assertEqual(sound.get_raw(), sound_copy.get_raw())

sound.set_volume(new_vol)
self.assertNotEqual(sound.get_volume(), sound_copy.get_volume())

del sound

# Test on the copy for playable sounds
channel = sound_copy.play()
if channel is None:
continue
self.assertTrue(channel.get_busy())
sound_copy.stop()
self.assertFalse(channel.get_busy())
sound_copy.play()
self.assertEqual(sound_copy.get_num_channels(), 1)

# Test copying a subclass of Sound
for f, old_vol, new_vol in zip(filenames, old_volumes, new_volumes):
filename = example_path(os.path.join("data", f))
try:
sound = SubSound(file=filename)
sound.set_volume(old_vol)
except pygame.error:
continue
sound_copy = sound.copy()
self.assertIsInstance(sound_copy, SubSound)
self.assertEqual(sound.get_length(), sound_copy.get_length())
self.assertEqual(sound.get_num_channels(), sound_copy.get_num_channels())
self.assertEqual(sound.get_volume(), sound_copy.get_volume())
self.assertEqual(sound.get_raw(), sound_copy.get_raw())

sound.set_volume(new_vol)
self.assertNotEqual(sound.get_volume(), sound_copy.get_volume())

del sound

# Test on the copy for playable sounds
channel = sound_copy.play()
if channel is None:
continue
self.assertTrue(channel.get_busy())
sound_copy.stop()
self.assertFalse(channel.get_busy())
sound_copy.play()
self.assertEqual(sound_copy.get_num_channels(), 1)


##################################### MAIN #####################################

Expand Down
Loading