spatialaudio / python-sounddevice

:sound: Play and Record Sound with Python :snake:
https://python-sounddevice.readthedocs.io/
MIT License
980 stars 145 forks source link

Playback cannot be interrupted on Windows #469

Closed arkrow closed 1 year ago

arkrow commented 1 year ago

I'm currently trying to use the sounddevice library for A-B repeat style looping through the terminal. As such, playback is infinite until the user interrupts, usually with a Ctrl+C KeyboardInterrupt, which then should stop playback. This works as expected on Linux, however on Windows, this is not the case. The only way to interrupt playback on Windows is by terminating the python process in task manager.

The wait() function of threading.Event() may be the culprit here. Once event.wait() is triggered in the code example below (for playback), all signals and interrupts (including the critical KeyboardInterrupt for my use case) are blocked until the thread event is complete. This can be observed if a timeout is set in event.wait(); any KeyboardInterrupts invoked will be processed after the thread is 'unblocked'. Any attempted workaround relating to signal handling did not work, as KeyboardInterrupt is not raised until the event timeouts or its internal flag is set (normally, by completing playback, which in this case, is not possible).

Is this a bug in the python interpreter (a similar issue has been raised before: https://bugs.python.org/issue8844), or am I overlooking something? Is there a possible workaround that would work here?

Here's a minimal example based on the play_file.py example provided in the docs. Code changes are highlighted using comments and additional line breaks. Strangely, the problem is also present in the original play_file.py example but not in play_long_file.py, despite both using event.wait().

#!/usr/bin/env python3

import argparse
import threading

import sounddevice as sd
import soundfile as sf

parser = argparse.ArgumentParser(add_help=False)
args, remaining = parser.parse_known_args()
parser.add_argument(
    'filename', metavar='FILENAME',
    help='audio file to be played back')

## Loop point args
parser.add_argument(
    '-A', type=int,
    help='loop point A (trigger point, unit: sample point)')
parser.add_argument(
    '-B', type=int,
    help='loop point B (to loop back to, unit: sample point)')

args = parser.parse_args(remaining)

event = threading.Event()

try:
    data, fs = sf.read(args.filename, always_2d=True)

    current_frame = 0

    ## Correcting loop point order, just in case
    loop_end = max(args.A, args.B)
    loop_start = min(args.A, args.B)

    def callback(outdata, frames, time, status):
        global current_frame
        if status:
            print(status)
        chunksize = min(len(data) - current_frame, frames)

        ## Loop logic start
        if current_frame + chunksize > loop_end:
            current_frame = loop_start
        ## Loop logic end

        outdata[:chunksize] = data[current_frame:current_frame + chunksize]
        if chunksize < frames:
            outdata[chunksize:] = 0
            raise sd.CallbackStop()
        current_frame += chunksize

    stream = sd.OutputStream(
        samplerate=fs, channels=data.shape[1],
        callback=callback, finished_callback=event.set)
    with stream:
        print(f'Now playing {args.filename}. Ctrl+C to stop playback.')
        event.wait()  # Wait until playback is finished
except KeyboardInterrupt:
    parser.exit('\nInterrupted by user')
except Exception as e:
    parser.exit(type(e).__name__ + ': ' + str(e))

Environment details:

mgeier commented 1 year ago

I would consider this a bug.

IIRC, this was a problem on Linux with Python 2, but with the transition to Python 3, this was solved. I wasn't aware that this is still a problem with Windows.

I'm wondering that this didn't come up earlier though, because threading.Event is also used in sounddevice.wait(), which should be in common use, I guess.

arkrow commented 1 year ago

Thanks for replying. Some additional investigation shows that it is indeed an issue with current python versions (as per the current open issue: https://bugs.python.org/issue35935). Thankfully, the workaround mentioned by a user in the thread works, and can be used until the issue is officially addressed in a later python release.

In the code example above, adding a signal handler for SIGINT before the event.wait() call fixes this issue:

    with stream:
        import signal
        signal.signal(signal.SIGINT, signal.SIG_DFL)
        print(f'Now playing {args.filename}. Ctrl+C to stop playback.')
        event.wait()  # Wait until playback is finished

Edit: signal.signal(signal.SIGINT, signal.SIG_DFL) will exit the program instead of raising a desired KeyboardInterrupt event to handle. An alternative workaround I discovered is by setting a small timeout to the wait function in a loop, e.g.

    with stream:
        print(f'Now playing {args.filename}. Ctrl+C to stop playback.')
        while not event.wait(0.5): # 0.5 second timeout to handle interrupts in-between
            pass

This workaround does not seem to affect playback from my testing and raises a KeyboardInterrupt when triggered. Although I'm sure there are better implementations, (perhaps one that avoids blocking the main thread), this workaround seems to be the simplest effective solution--provided that there are no unintended side-effects.