pygame-community / pygame-ce

🐍🎮 pygame - Community Edition is a FOSS Python library for multimedia applications (like games). Built on top of the excellent SDL library.
https://pyga.me
910 stars 148 forks source link

set_source_location resets to default on channel when queued sound plays #2322

Open qwertyquerty opened 1 year ago

qwertyquerty commented 1 year ago

Environment:

You can get some of this info from the text that pops up in the console when you run a pygame program.

Current behavior:

Any location of a channel set by set_source_location is reset to 0, 0 whenever a queued sound plays on a channel

Expected behavior:

The source location should remain the same

oddbookworm commented 1 year ago

Can you provide a minimal code example?

qwertyquerty commented 1 year ago
import pygame as pg

pg.mixer.init()
screen = pg.display.set_mode((100, 100))

sfx = pg.mixer.Sound("sound.wav")
channel = pg.mixer.Channel(1)

channel.set_source_location(90, 200)
channel.play(sfx)
channel.queue(sfx)

while True:
    pass
qwertyquerty commented 1 year ago

https://github.com/pygame-community/pygame-ce/assets/24400628/84ca49d3-b760-445c-aebc-79bfbbfb6072

Starbuck5 commented 1 year ago

Here's the workaround I was thinking about from discord discussion:

import pygame

pygame.init()

sound = pygame.mixer.Sound(r"C:\Users\charl\Desktop\pygame-ce\examples\data\boom.wav")
sound2 = pygame.mixer.Sound(r"C:\Users\charl\Desktop\pygame-ce\examples\data\surfonasinewave.xm")

posteffect = pygame.mixer.Channel(-2)
posteffect.set_source_location(90,0)

sound.play()

while pygame.mixer.get_busy():
    pass

sound2.play()

while pygame.mixer.get_busy():
    pass

I had to edit the source to allow to me to make that negative channel, so here's a wheel for your system that will allow you to run it: pygame_ce-2.3.1.dev1-cp310-cp310-win_amd64.zip

zoldalma999 commented 1 year ago

This is because SDL_Mixer clears all effects on a channel when a sound finishes playing, and since Mix_SetPosition uses the same API as applying any old effect, it gets cleared too. You don't even need to queue anything, just playing two sounds one after the other on the same channel has the same effect. We have two options here:

test code:

import pygame
import time

pygame.mixer.init()
sfx = pygame.mixer.Sound("examples/data/car_door.wav")
channel = pygame.mixer.Channel(1)
channel.set_source_location(90, 200)
channel.play(sfx)
time.sleep(1)
channel.play(sfx)  # plays normally, without position or distance
time.sleep(1)
DaFluffyPotato commented 1 year ago

Having the same issue. This bug makes it impossible to smoothly queue sounds with custom source locations. The bug doesn't affect .play(-1), which means that it can be used as a workaround for now in cases where a sound is just supposed to loop.

zoldalma999 commented 12 months ago

Had a look at this, and while I fixed it for .play, there is no easy way to fix it for queued sounds (that I could find anyway). Changes here (I'd clean this up a bit before pr, but pushed it if anyone wants to see).

The issue comes from the fact that after a channel stops playing, it clears all effects applied to the channel, this includes Mix_SetPosition. My workaround was to apply it every time it started playing, which worked nicely, for calling .play one after another.

The reason this does not work for queued sounds is because a queued sounds starts playing from the Mix_ChannelFinished callback. This gets called when a channel finishes playback, but before clearing effects, meaning whatever effects we set in this callback is instantly cleared (this is literally the two things mixer does when a channel stops: callback then clear effects).

There are some solutions, but all of them probably need some more effort than I'd like it to, and/or more hacky than I'd like it to.

I think the best solution here would be the first, but I'd like some opinion on which one we should go with, or if anyone has a better solution.

Starbuck5 commented 4 months ago

Here's a demonstration of a Python class (using channel subclassing and endevents) to create the desired behavior.

"""
Example program showing the use of Channel subclassing to make effects
(in this case, source location) persist between plays and queued plays
of sound effects.

Press 1: channel.play() the sound
Press 2: channel.queue() the sound
"""

import os

import pygame

pygame.init()

# Change the path to work with sound on your system
os.chdir(r"C:\Users\charl\Desktop\pygame-ce\examples\data")
sfx = pygame.mixer.Sound("boom.wav")

screen = pygame.display.set_mode((800, 600))

class LocationChannel(pygame.Channel):
    END_EVENT_TYPE = pygame.event.custom_type()

    def __init__(self, id: int):
        self._angle_dist = None
        self._queued = None
        super().__init__(id)
        super().set_endevent(self.END_EVENT_TYPE)

    def set_source_location(self, angle: float, distance: float) -> None:
        self._angle_dist = (angle, distance)
        super().set_source_location(angle, distance)

    def queue(self, sound: pygame.mixer.Sound) -> None:
        if not self.get_busy():
            self.play(sound)
        else:
            self._queued = sound

    def play(
        self,
        sound: pygame.mixer.Sound,
        loops: int = 0,
        maxtime: int = 0,
        fade_ms: int = 0,
    ) -> None:
        if self._angle_dist:
            super().set_source_location(*self._angle_dist)
        super().play(sound, loops, maxtime, fade_ms)

    def handle_events(self, event: pygame.Event) -> None:
        if event.type == self.END_EVENT_TYPE:
            if self._queued:
                self.play(self._queued)
                self._queued = None

channel = LocationChannel(1)
channel.set_source_location(90, 200)

clock = pygame.time.Clock()

while True:
    screen.fill("purple")

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            raise SystemExit

        if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
            pygame.quit()
            raise SystemExit

        if event.type == pygame.KEYDOWN and event.key == pygame.K_1:
            print("channel.play(sfx)")
            channel.play(sfx)

        if event.type == pygame.KEYDOWN and event.key == pygame.K_2:
            print("channel.queue(sfx)")
            channel.queue(sfx)

        channel.handle_events(event)

    pygame.display.flip()
    clock.tick(144)
Starbuck5 commented 4 months ago

I'm taking a look at this again because of the upcoming 2.5 release.

Thanks for the research @zoldalma999, I couldn't find an easy way around it either.

I do have a new idea thought, we could do an internal event and have a special case in event.c to catch it?

However, I'm leaning towards calling this a wontfix. If this is how they expect SDL mixer effects to behave, let's not put in a bunch of effort to fight it. This is how channel.set_volume works too, it's documented as "This only affects the current sound."

Although channel.set_volume does seem to be persistent if you only set 1 number (not doing panning), ugh...

So I think we should resolve this by adding a note to the docs for set_source_location that it only impacts the current sound.