Search code examples
pythonplayback

How to "continue" a for loop from a callback function with pynput and winsound?


I need to have my program play wav files from a directory and skip to the next one if the user presses F9. I have found I need to set the winsound to Async mode to get it to skip to the next file while the previous file is still playing, but at the same time i need to run a background process like time.sleep(5) or else the program will exit immediately.

I have a for loop to run through the wav files, but I can't directly call continue from the on_press() function so I tried using a conditional global variable to sleep or not sleep. Thus, I can stop the current playing wav file with PlaySound(None, winsound.Purge) but I can't continue to the next iteration of the for loop or get my time.sleep(5) to conditionally run - it will always run and I will always have to wait those 5 seconds to start the next iteration. Is there a problem with the way I'm using the callback or should I use a different input lib or something?

from pynput import keyboard
import winsound
import os

mySkip = 0
currentIdx = 0

def clamp(n, minn, maxn):
    return max(min(maxn, n), minn)

def on_press(key):
    global mySkip
    global currentIdx
    if key == keyboard.Key.f9:
        winsound.Beep(5500, 200)
        mySkip = 1

        currentIdx = clamp(currentIdx + 1, 0, 3)
        winsound.PlaySound(None, winsound.SND_PURGE)

listener = keyboard.Listener(on_press=on_press)
listener.start()

WAVDir = "C:/Users/me/PycharmProjects/projectName/WAV"
wavList = os.listdir(WAVDir) #have 4 files in here, "test0.wav" to "test4.wav"

for idx, i in enumerate(wavList):
    soundPath = WAVDir + "/test" + str(currentIdx) + ".wav"

    print(soundPath)
    winsound.PlaySound(soundPath, winsound.SND_ASYNC)

    if mySkip == 0:
        time.sleep(5)

    currentIdx += 1
    mySkip = 0

Solution

  • Here's a solution I came up with using polling - which is never my favorite thing to do, but winsound is notoriously uncooperative when it comes to threading, and I believe that threading is the way to go here:

    def main():
    
        from pathlib import Path
        from threading import Event, Thread
        from pynput import keyboard
        from collections import namedtuple
        import wave
    
        skip_sound = Event()
    
        def play_sound_thread(path, max_duration_seconds):
            from winsound import PlaySound, SND_ASYNC
            PlaySound(path, SND_ASYNC)
            elapsed_seconds = 0
            poll_interval = 1 / 8
            while not skip_sound.is_set() and elapsed_seconds < max_duration_seconds:
                skip_sound.wait(poll_interval)
                elapsed_seconds += poll_interval
    
        def on_press(key):
            if key is keyboard.Key.f9:
                nonlocal skip_sound
                skip_sound.set()
    
        Wave = namedtuple("Wave", ["path", "file_name", "duration_seconds"])
    
        waves = []
    
        wav_dir = Path("./wavs")
    
        for wav_path in wav_dir.glob("*.wav"):
            wav_path_as_str = str(wav_path)
            file_name = wav_path.name
            with wave.open(wav_path_as_str, "rb") as wave_read:
                sample_rate = wave_read.getframerate()
                number_of_samples = wave_read.getnframes()
            duration_seconds = number_of_samples / sample_rate
    
            wav = Wave(
                path=wav_path_as_str,
                file_name=file_name,
                duration_seconds=duration_seconds
            )
    
            waves.append(wav)
    
        listener = keyboard.Listener(on_press=on_press)
        listener.start()
    
        for wav in waves:
            print(f"Playing {wav.file_name}")
    
            thread = Thread(
                target=play_sound_thread,
                args=(wav.path, wav.duration_seconds),
                daemon=True
            )
            thread.start()
            thread.join()
    
            if skip_sound.is_set():
                print(f"Skipping {wav.file_name}")
                skip_sound.clear()
    
        return 0
    
    
    if __name__ == "__main__":
        import sys
        sys.exit(main())
    

    How it works:

    Every time a sound needs to be played, a new thread is spawned and the winsound.PlaySound stuff is done in the thread. PlaySound receives the SND_ASYNC argument, so that the sound is played asynchronously, and this operation by itself does not block execution in the thread. The thread's execution is then blocked, however, by a loop, which terminates when either a certain amount of time has elapsed (the sound has had enough time to finish playing) or when the skip_sound event is set.

    The on_press callback sets the skip_sound event when the f9 key is pressed.

    I've created a namedtuple called Wave, which is just meant to act like a simple data bucket. All the relevant information we need for any single wave file is contained in one of these.

    The main for-loop (the second one, the first one just compiles a list of Wave objects), we iterate through our Wave objects. We then spawn a new thread for the current Wave object. We then start the thread to start playing the sound, and thread.join it so that the main thread waits for this sub-thread to finish - basically blocking until the sub-thread is finished. When the on_press callback is triggered, it terminates the sub-thread prematurely, and as a result the join operation will no longer be blocking, allowing us to reset our skip_sound event if necessary and move on to the next iteration.

    Things I like about this solution:

    • It works

    Things I don't like about this solution:

    • Like I said, the play_sound_thread function plays the sound asynchronously, and then polls until either the skip_sound event is triggered, or a certain amount of time has elapsed. Polling is gross. I just sort of arbitrarily decided to poll in 1/8th of a second intervals.
    • In order to determine the maximum duration for which to poll, you need to open the wave file, parse it, and divide the number of samples by the sample rate. It feels dirty to basically open the same file twice - once in wave.open, and again in winsound.PlaySound.