From cf9f2c36cf0bfa71bf3379a0b190798ed73342c0 Mon Sep 17 00:00:00 2001 From: Irmen de Jong Date: Thu, 26 Jul 2018 21:42:30 +0200 Subject: [PATCH] new synthesizer API --- bouldercaves/synthplayer/__init__.py | 2 +- bouldercaves/synthplayer/params.py | 6 +- bouldercaves/synthplayer/playback.py | 81 +++++++++++++++++++-------- bouldercaves/synthplayer/streaming.py | 8 ++- 4 files changed, 68 insertions(+), 29 deletions(-) diff --git a/bouldercaves/synthplayer/__init__.py b/bouldercaves/synthplayer/__init__.py index 03cee03..4a98d97 100644 --- a/bouldercaves/synthplayer/__init__.py +++ b/bouldercaves/synthplayer/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.1" +__version__ = "1.2" __author__ = "Irmen de Jong" diff --git a/bouldercaves/synthplayer/params.py b/bouldercaves/synthplayer/params.py index 0bcfd82..c6a5a52 100644 --- a/bouldercaves/synthplayer/params.py +++ b/bouldercaves/synthplayer/params.py @@ -4,7 +4,7 @@ Written by Irmen de Jong (irmen@razorvine.net) - License: GNU LGPL 3. """ -norm_samplerate = 44100 -norm_nchannels = 2 -norm_samplewidth = 2 +norm_samplerate = 44100 # CD quality +norm_nchannels = 2 # two channel stereo +norm_samplewidth = 2 # samples are 2 bytes = 16 bits norm_frames_per_chunk = norm_samplerate // 30 # about 6kb worth of data, 1/30 sec diff --git a/bouldercaves/synthplayer/playback.py b/bouldercaves/synthplayer/playback.py index 308760c..73346c7 100644 --- a/bouldercaves/synthplayer/playback.py +++ b/bouldercaves/synthplayer/playback.py @@ -14,9 +14,8 @@ import audioop # type: ignore import queue import threading -import tempfile import time -import os +import io from collections import defaultdict from typing import Generator, Union, Dict, Tuple, Any, Type, List, Callable, Iterable, Optional from .import params @@ -374,12 +373,15 @@ def audio_thread(): try: while True: data = b"" + repeat = False + command = None try: command = self.command_queue.get(timeout=0.2) if command is None or command["action"] == "stop": break elif command["action"] == "play": data = command["sample"].view_frame_data() or b"" + repeat = command["repeat"] except queue.Empty: self.all_played.set() data = b"" @@ -388,6 +390,21 @@ def audio_thread(): if self.playing_callback: sample = Sample.from_raw_frames(data, self.samplewidth, self.samplerate, self.nchannels) self.playing_callback(sample) + if repeat: + # remove all other samples from the queue and reschedule this one + commands_to_keep = [] + while True: + try: + c2 = self.command_queue.get(block=False) + if c2["action"] == "play": + continue + commands_to_keep.append(c2) + except queue.Empty: + break + for cmd in commands_to_keep: + self.command_queue.put(cmd) + if command: + self.command_queue.put(command) finally: self.all_played.set() stream.stop() @@ -399,7 +416,7 @@ def audio_thread(): def play(self, sample: Sample, repeat: bool=False, delay: float=0.0) -> int: self.all_played.clear() - self.command_queue.put({"action": "play", "sample": sample}) + self.command_queue.put({"action": "play", "sample": sample, "repeat": repeat}) return 0 def silence(self) -> None: @@ -516,12 +533,15 @@ def audio_thread(): try: while True: data = b"" + repeat = False + command = None try: command = self.command_queue.get(timeout=0.2) if command is None or command["action"] == "stop": break elif command["action"] == "play": data = command["sample"].view_frame_data() or b"" + repeat = command["repeat"] except queue.Empty: self.all_played.set() data = b"" @@ -532,6 +552,21 @@ def audio_thread(): if self.playing_callback: sample = Sample.from_raw_frames(data, self.samplewidth, self.samplerate, self.nchannels) self.playing_callback(sample) + if repeat: + # remove all other samples from the queue and reschedule this one + commands_to_keep = [] + while True: + try: + c2 = self.command_queue.get(block=False) + if c2["action"] == "play": + continue + commands_to_keep.append(c2) + except queue.Empty: + break + for cmd in commands_to_keep: + self.command_queue.put(cmd) + if command: + self.command_queue.put(command) finally: self.all_played.set() stream.close() @@ -544,7 +579,7 @@ def audio_thread(): def play(self, sample: Sample, repeat: bool=False, delay: float=0.0) -> int: self.all_played.clear() - self.command_queue.put({"action": "play", "sample": sample}) + self.command_queue.put({"action": "play", "sample": sample, "repeat": repeat}) return 0 def silence(self) -> None: @@ -596,8 +631,9 @@ def __init__(self, samplerate: int=0, samplewidth: int=0, nchannels: int=0, queu import winsound as _winsound global winsound winsound = _winsound # type: ignore - self.threads = [] # type: List[threading.Thread] self.played_callback = None + self.sample_queue = queue.Queue(maxsize=queue_size) # type: queue.Queue[Sample] + threading.Thread(target=self._play, daemon=True).start() def play(self, sample: Sample, repeat: bool=False, delay: float=0.0) -> int: # plays the sample in a background thread so that we can continue while the sound plays. @@ -606,35 +642,32 @@ def play(self, sample: Sample, repeat: bool=False, delay: float=0.0) -> int: raise ValueError("winsound player doesn't support repeating samples") if delay != 0.0: raise ValueError("winsound player doesn't support delayed playing") - self.wait_all_played() - t = threading.Thread(target=self._play, args=(sample,), daemon=True) - self.threads.append(t) - t.start() - time.sleep(0.0005) + self.sample_queue.put(sample) return 0 - def _play(self, sample: Sample) -> None: - with tempfile.NamedTemporaryFile(delete=False, mode="wb") as sample_file: - sample.write_wav(sample_file) # type: ignore - sample_file.flush() - winsound.PlaySound(sample_file.name, winsound.SND_FILENAME) # type: ignore - if self.played_callback: - self.played_callback(sample) - os.unlink(sample_file.name) + def _play(self) -> None: + # this runs in a background thread so a sample playback doesn't block the program + # (can't use winsound async because we're playing from a memory buffer) + while True: + sample = self.sample_queue.get() + with io.BytesIO() as sample_data: + sample.write_wav(sample_data) # type: ignore + winsound.PlaySound(sample_data.getbuffer(), winsound.SND_MEMORY) # type: ignore + if self.played_callback: + self.played_callback(sample) def stop(self, sid_or_name: Union[int, str]) -> None: - raise NotImplementedError("sequential play mode doesn't support stopping individual samples") + raise NotImplementedError("winsound sequential play mode doesn't support stopping individual samples") def set_sample_play_limit(self, samplename: str, max_simultaneously: int) -> None: - raise NotImplementedError("sequential play mode doesn't support setting sample limits") + raise NotImplementedError("winsound sequential play mode doesn't support setting sample limits") def wait_all_played(self) -> None: - while self.threads: - t = self.threads.pop() - t.join() + while not self.sample_queue.empty(): + time.sleep(0.2) def still_playing(self) -> bool: - return bool(self.threads) + return not self.sample_queue.empty() class Output: diff --git a/bouldercaves/synthplayer/streaming.py b/bouldercaves/synthplayer/streaming.py index 198e438..3feb0a5 100644 --- a/bouldercaves/synthplayer/streaming.py +++ b/bouldercaves/synthplayer/streaming.py @@ -226,6 +226,9 @@ class StreamingSample(Sample): def __init__(self, wave_file: Union[str, BinaryIO]=None, name: str="") -> None: super().__init__(wave_file, name) + def view_frame_data(self): + raise NotImplementedError("a streaming sample doesn't have a frame data buffer to view") + def load_wav(self, file_or_stream: Union[str, BinaryIO]) -> 'Sample': self.wave_stream = wave.open(file_or_stream, "rb") if not 2 <= self.wave_stream.getsampwidth() <= 4: @@ -245,7 +248,10 @@ def chunked_frame_data(self, chunksize: int, repeat: bool=False, while True: audiodata = self.wave_stream.readframes(chunksize // self.samplewidth // self.nchannels) if not audiodata: - break # source stream exhausted + if repeat: + self.wave_stream.rewind() + else: + break # non-repeating source stream exhausted if len(audiodata) < chunksize: audiodata += silence[len(audiodata):] yield memoryview(audiodata)