diff --git a/buildconfig/stubs/pygame/mixer.pyi b/buildconfig/stubs/pygame/mixer.pyi index 84646b419b..fc04a0ef02 100644 --- a/buildconfig/stubs/pygame/mixer.pyi +++ b/buildconfig/stubs/pygame/mixer.pyi @@ -70,6 +70,8 @@ class Sound: def get_num_channels(self) -> int: ... def get_length(self) -> float: ... def get_raw(self) -> bytes: ... + def copy(self) -> Sound: ... + def __copy__(self) -> Sound: ... class Channel: diff --git a/docs/reST/ref/mixer.rst b/docs/reST/ref/mixer.rst index 5a441c7f81..c5041c8eb3 100644 --- a/docs/reST/ref/mixer.rst +++ b/docs/reST/ref/mixer.rst @@ -479,6 +479,18 @@ The following file formats are supported .. ## Sound.get_raw ## + .. method:: copy + + | :sl:`return a new Sound object that is a deep copy of this one` + | :sg:`copy() -> Sound` + + Return a new Sound object that is a deep copy of this one. The new Sound will + be playable just like the original. If the copy fails, a ``MemoryError`` or a + :meth:`pygame.error` exception will be raised. + + .. ## Sound.copy ## + + .. ## pygame.mixer.Sound ## .. class:: Channel diff --git a/src_c/doc/mixer_doc.h b/src_c/doc/mixer_doc.h index 92da671ab3..e3ed8f9144 100644 --- a/src_c/doc/mixer_doc.h +++ b/src_c/doc/mixer_doc.h @@ -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\nreturn a new Sound object that is a deep copy of this one" #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" diff --git a/src_c/mixer.c b/src_c/mixer.c index 0d3fa8f393..72d5ba8d7f 100644 --- a/src_c/mixer.c +++ b/src_c/mixer.c @@ -831,6 +831,72 @@ snd_get_samples_address(PyObject *self, PyObject *closure) #endif } +static PyObject * +snd_copy(PyObject *self, PyObject *_null) +{ + Mix_Chunk *chunk = pgSound_AsChunk(self); + pgSoundObject *new_sound; + Mix_Chunk *new_chunk; + + // Validate the input chunk + CHECK_CHUNK_VALID(chunk, NULL); + + // Create a new sound object + new_sound = + (pgSoundObject *)pgSound_Type.tp_new(Py_TYPE(self), NULL, NULL); + if (!new_sound) { + PyErr_SetString(PyExc_MemoryError, + "Failed to allocate memory for new sound object"); + return NULL; + } + + // Handle chunk allocation type + if (chunk->allocated) { + // Create a deep copy of the audio buffer for allocated chunks + Uint8 *buffer_copy = (Uint8 *)malloc(chunk->alen); + if (!buffer_copy) { + Py_DECREF(new_sound); + PyErr_SetString(PyExc_MemoryError, + "Failed to allocate memory for sound buffer"); + return NULL; + } + memcpy(buffer_copy, chunk->abuf, chunk->alen); + + // Create a new Mix_Chunk + new_chunk = Mix_QuickLoad_RAW(buffer_copy, chunk->alen); + if (!new_chunk) { + free(buffer_copy); + Py_DECREF(new_sound); + PyErr_SetString(pgExc_SDLError, + "Failed to create new sound chunk"); + return NULL; + } + new_chunk->volume = chunk->volume; + new_sound->chunk = new_chunk; + } + else { + // For non-allocated chunks (e.g., formats like .xm), create a full + // copy + new_chunk = (Mix_Chunk *)malloc(sizeof(Mix_Chunk)); + if (!new_chunk) { + Py_DECREF(new_sound); + PyErr_SetString(PyExc_MemoryError, + "Failed to allocate memory for sound chunk"); + return NULL; + } + *new_chunk = *chunk; // Copy the entire structure + + // For safety, ensure the copied chunk doesn't share pointers + new_chunk->abuf = + NULL; // Prevent double-free if original gets deallocated + new_chunk->allocated = 0; + + new_sound->chunk = new_chunk; + } + + return (PyObject *)new_sound; +} + PyMethodDef sound_methods[] = { {"play", (PyCFunction)pgSound_Play, METH_VARARGS | METH_KEYWORDS, DOC_MIXER_SOUND_PLAY}, @@ -842,6 +908,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[] = { diff --git a/test/mixer_test.py b/test/mixer_test.py index 9b3551b834..7c1c4794b6 100644 --- a/test/mixer_test.py +++ b/test/mixer_test.py @@ -711,6 +711,46 @@ def test_get_sdl_mixer_version__linked_equals_compiled(self): self.assertTupleEqual(linked_version, compiled_version) + def test_snd_copy(self): + mixer.init() + + filenames = [ + "house_lo.ogg", + "house_lo.wav", + "house_lo.flac", + "house_lo.opus", + "surfonasinewave.xm", + ] + if pygame.mixer.get_sdl_mixer_version() >= (2, 6, 0): + filenames.append("house_lo.mp3") + + for f in filenames: + filename = example_path(os.path.join("data", f)) + try: + sound = mixer.Sound(file=filename) + except pygame.error as e: + 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(0.5) + 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) + ############################## CHANNEL CLASS TESTS #############################