From 69037b1445172f71744f84ae4077d62af1d4b35f Mon Sep 17 00:00:00 2001 From: HalbFettKaese Date: Wed, 17 Dec 2025 16:40:19 +0100 Subject: [PATCH 1/5] Terminate backend when quitting server --- pya/aserver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pya/aserver.py b/pya/aserver.py index 5a04e74..0fb209e 100644 --- a/pya/aserver.py +++ b/pya/aserver.py @@ -226,6 +226,7 @@ def quit(self): except AttributeError: _LOGGER.info("No stream found...") self.stream = None + self.backend.terminate() def play(self, asig, onset: Union[int, float] = 0, out: int = 0, **kwargs): """Dispatch asigs or arrays for given onset. @@ -407,14 +408,12 @@ def __exit__(self, exc_type, exc_value, traceback): """Context manager exit""" _LOGGER.info("Exiting context manager. Cleaning up stream and backend") self.quit() - self.backend.terminate() def __del__(self): """Backup cleanup, only if context manager wasn't used""" if hasattr(self, 'stream') and self.stream is not None: try: self.quit() - self.backend.terminate() except: pass # Ignore cleanup errors during shutdown From f85223b3cd6bce9a8754a9cdd462e5b783ca310b Mon Sep 17 00:00:00 2001 From: HalbFettKaese Date: Fri, 19 Dec 2025 12:14:52 +0100 Subject: [PATCH 2/5] Create SoundDevice backend --- pya/backend/SoundDevice.py | 86 ++++++++++++++++++++++++++++++++++++ pya/helper/backend.py | 11 ++++- requirements_sounddevice.txt | 1 + 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 pya/backend/SoundDevice.py create mode 100644 requirements_sounddevice.txt diff --git a/pya/backend/SoundDevice.py b/pya/backend/SoundDevice.py new file mode 100644 index 0000000..6875ddb --- /dev/null +++ b/pya/backend/SoundDevice.py @@ -0,0 +1,86 @@ +import sounddevice as sd +from .base import BackendBase, StreamBase +import numpy as np +import time + +class SoundDeviceBackend(BackendBase): + _boot_delay = 0.5 # a short delay to prevent PyAudio racing conditions + bs = 512 + + def __init__(self, format=np.float32): + if not sd._initialized: + sd._initialize() + self.format = format + self.dtype = format + if format in [np.int16, "int16"]: + self.range = 32767 + elif format in [np.float32, "float32"]: + self.range = 1.0 + else: + raise AttributeError(f"Aserver: currently unsupported pyaudio format {self.format}") + + def get_device_count(self): + return len(sd.query_devices()) + + def get_device_info_by_index(self, idx): + return sd.query_devices(idx) + + def get_default_input_device_info(self): + in_idx, _ = sd.default.device + return sd.query_devices(in_idx) + + def get_default_output_device_info(self): + _, out_idx = sd.default.device + return sd.query_devices(out_idx) + + def open(self, rate, channels, input_flag, output_flag, frames_per_buffer, + input_device_index=None, output_device_index=None, start=True, + input_host_api_specific_stream_info=None, output_host_api_specific_stream_info=None, + stream_callback=None): + kwargs = dict( + samplerate=rate, + blocksize=frames_per_buffer, + device=(input_device_index, output_device_index), + channels=channels, + dtype=self.dtype, + extra_settings=( + input_host_api_specific_stream_info, + output_host_api_specific_stream_info + ), + callback=stream_callback + ) + if input_flag and output_flag: + stream = sd.Stream(**kwargs) + elif input_flag: + stream = sd.InputStream(**kwargs) + elif output_flag: + stream = sd.OutputStream(**kwargs) + else: + raise ValueError("Both input flag and output flag were set to 0.") + if start: + stream.start() + time.sleep(self._boot_delay) # give stream some time to be opened completely + return SoundDeviceStream(stream) + + def process_buffer(self, buffer): + return buffer + + def terminate(self): + sd._terminate() + + + +class SoundDeviceStream(StreamBase): + def __init__(self, sd_stream: sd.Stream): + self.sd_stream = sd_stream + def is_active(self): + return self.sd_stream.active + + def start_stream(self): + self.sd_stream.start() + + def stop_stream(self): + self.sd_stream.stop() + + def close(self): + self.sd_stream.close() diff --git a/pya/helper/backend.py b/pya/helper/backend.py index 47162f2..9e9fa49 100644 --- a/pya/helper/backend.py +++ b/pya/helper/backend.py @@ -4,6 +4,7 @@ from pya.backend.base import BackendBase from pya.backend.PyAudio import PyAudioBackend from pya.backend.Jupyter import JupyterBackend + from pya.backend.SoundDevice import SoundDeviceBackend def get_server_info(): @@ -28,6 +29,14 @@ def get_server_info(): return None +def try_sounddevice_backend(**kwargs) -> Optional["SoundDeviceBackend"]: + try: + from pya.backend.SoundDevice import SoundDeviceBackend + return SoundDeviceBackend(**kwargs) + except ImportError: + return None + + def try_pyaudio_backend(**kwargs) -> Optional["PyAudioBackend"]: try: from pya.backend.PyAudio import PyAudioBackend @@ -70,7 +79,7 @@ def determine_backend(force_webaudio=False, port=8765, **kwargs) -> "BackendBase RuntimeError if no Backend is available """ - backend = None if force_webaudio else try_pyaudio_backend(**kwargs) + backend = None if force_webaudio else try_sounddevice_backend(**kwargs) or try_pyaudio_backend(**kwargs) if backend is None: backend = try_jupyter_backend(port=port, **kwargs) if backend is None: diff --git a/requirements_sounddevice.txt b/requirements_sounddevice.txt new file mode 100644 index 0000000..af5e3a6 --- /dev/null +++ b/requirements_sounddevice.txt @@ -0,0 +1 @@ +sounddevice \ No newline at end of file From 9e8a04457131651c342d830cdd212d3213939197 Mon Sep 17 00:00:00 2001 From: HalbFettKaese Date: Mon, 22 Dec 2025 02:36:26 +0100 Subject: [PATCH 3/5] Implement SoundDevice playback and improve AserverGUI AserverGUI was rewritten to fix the bugs that came from the now changing device list. --- pya/backend/SoundDevice.py | 47 ++++++++++++++--- pya/backend/base.py | 7 ++- pya/gui/aservergui.py | 101 ++++++++++++++++++++----------------- 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/pya/backend/SoundDevice.py b/pya/backend/SoundDevice.py index 6875ddb..6e3f66c 100644 --- a/pya/backend/SoundDevice.py +++ b/pya/backend/SoundDevice.py @@ -3,9 +3,27 @@ import numpy as np import time +def translate_dict_keys(input_dict: dict, translate_dict: dict): + # Translates keys in input_dict to be replaced by the values that they are mapped to by translate_dict + return { + translate_dict.get(key, key): value + for key, value in input_dict.items() + } + class SoundDeviceBackend(BackendBase): _boot_delay = 0.5 # a short delay to prevent PyAudio racing conditions bs = 512 + + translate_dict = { + 'hostapi': 'hostApi', + 'max_input_channels': 'maxInputChannels', + 'max_output_channels': 'maxOutputChannels', + 'default_low_input_latency': 'defaultLowInputLatency', + 'default_low_output_latency': 'defaultLowOutputLatency', + 'default_high_input_latency': 'defaultHighInputLatency', + 'default_high_output_latency': 'defaultHighOutputLatency', + 'default_samplerate': 'defaultSampleRate', + } def __init__(self, format=np.float32): if not sd._initialized: @@ -23,20 +41,22 @@ def get_device_count(self): return len(sd.query_devices()) def get_device_info_by_index(self, idx): - return sd.query_devices(idx) + return translate_dict_keys(sd.query_devices(idx), self.translate_dict) def get_default_input_device_info(self): in_idx, _ = sd.default.device - return sd.query_devices(in_idx) + return self.get_device_info_by_index(in_idx) def get_default_output_device_info(self): _, out_idx = sd.default.device - return sd.query_devices(out_idx) + return self.get_device_info_by_index(out_idx) def open(self, rate, channels, input_flag, output_flag, frames_per_buffer, input_device_index=None, output_device_index=None, start=True, input_host_api_specific_stream_info=None, output_host_api_specific_stream_info=None, stream_callback=None): + if not input_flag and not output_flag: + raise ValueError("Input flag and output flag were both False!") kwargs = dict( samplerate=rate, blocksize=frames_per_buffer, @@ -47,16 +67,14 @@ def open(self, rate, channels, input_flag, output_flag, frames_per_buffer, input_host_api_specific_stream_info, output_host_api_specific_stream_info ), - callback=stream_callback + callback=self.make_callback(stream_callback, input_flag, output_flag) ) if input_flag and output_flag: stream = sd.Stream(**kwargs) elif input_flag: stream = sd.InputStream(**kwargs) - elif output_flag: - stream = sd.OutputStream(**kwargs) else: - raise ValueError("Both input flag and output flag were set to 0.") + stream = sd.OutputStream(**kwargs) if start: stream.start() time.sleep(self._boot_delay) # give stream some time to be opened completely @@ -67,6 +85,21 @@ def process_buffer(self, buffer): def terminate(self): sd._terminate() + + def make_callback(self, server_callback, input_flag, output_flag): + if input_flag and output_flag: + def new_callback(indata, outdata, frames, time, status): + data = server_callback(indata, frames, time, status) + outdata[:len(data)] = data + return new_callback + if input_flag and not output_flag: # output flag false + return server_callback + # input flag false, output flag true + indata = np.array([]) + def new_callback(outdata, frames, time, status): + data = server_callback(indata, frames, time, status) + outdata[:len(data)] = data + return new_callback diff --git a/pya/backend/base.py b/pya/backend/base.py index c773b8a..f62df88 100644 --- a/pya/backend/base.py +++ b/pya/backend/base.py @@ -30,7 +30,12 @@ def terminate(self): @abstractmethod def process_buffer(self, *args, **kwargs): raise NotImplementedError - + + def get_devices(self) -> list[dict]: + return [ + self.get_device_info_by_index(i) + for i in range(self.get_device_count()) + ] class StreamBase(ABC): diff --git a/pya/gui/aservergui.py b/pya/gui/aservergui.py index c7b6d04..fb4fd43 100644 --- a/pya/gui/aservergui.py +++ b/pya/gui/aservergui.py @@ -7,21 +7,23 @@ class AserverGUI: def __init__(self): - self.audio_devices = device_info(verbose=False) + self.index = None + self.input_flag = False self.blocksize = 1024 self.sr = 44100 - self.index = determine_backend().get_default_output_device_info()["index"] + self._init_views() + self._update_values() + self._update_views() + + self.show() # render GUI + + def _init_views(self): # audio device selector dropdown self.device_selector = widgets.Dropdown( - options=[ - d["name"] for d in self.audio_devices if d["maxOutputChannels"] > 0 - ], description="AOut Device:", - disabled=False, - value=self.audio_devices[self.index]["name"], + disabled=False ) - def on_pyagui_device_selection_change(change): self.index = [ a["index"] @@ -29,100 +31,83 @@ def on_pyagui_device_selection_change(change): if a["name"] == change["new"] and a["maxOutputChannels"] > 0 ][0] self.sr = int(self.audio_devices[self.index]["defaultSampleRate"]) - self.sr_wdg.set_state({"value": self.sr}) - + self.sr_wdg.value = self.sr self.device_selector.observe(on_pyagui_device_selection_change, names="value") # input_flag selector self.input_flag_wdg = widgets.Checkbox( - value=False, # Initialzustand description="AudioIn", indent=False, # Entfernt den zusätzlichen Einzug layout=widgets.Layout(width="70px"), ) - self.input_flag = self.input_flag_wdg.value - def on_input_flag_wdg_change(change): self.input_flag = change["new"] - self.input_flag_wdg.observe(on_input_flag_wdg_change, names="value") # sr selector self.sr_wdg = widgets.IntText( description="sr:", - value=self.sr, layout=widgets.Layout(width="160px"), ) - def on_sr_wdg_value_change(change): self.sr = change["new"] - self.sr_wdg.observe(on_sr_wdg_value_change, names="value") # blocksize selector self.blocksize_wdg = widgets.IntText( description="bs:", - value=self.blocksize, layout=widgets.Layout(width="160px"), ) - def on_bs_wdg_value_change(change): self.blocksize = change["new"] - self.blocksize_wdg.observe(on_bs_wdg_value_change, names="value") # reboot button self.reboot_button = widgets.Button( description="(re)boot", layout={"width": "75px"} ) - def on_reboot_button_click(change): - # TODO: change to Aserver.default - global s - if "s" in globals() and isinstance(s, Aserver): - s.shutdown_default_server() - s = startup( - sr=int(self.sr), - device=self.index, - input_flag=self.input_flag, - bs=self.blocksize, - ) - + # Save selected and default names before reload + prev_name = self.backend.get_device_info_by_index(self.index)["name"] + prev_default_name = self.backend.get_default_output_device_info()["name"] + + Aserver.shutdown_default_server() + self.backend = determine_backend() + new_default_name = self.backend.get_default_output_device_info()["name"] + device_names = [d["name"] for d in self.backend.get_devices()] + + # Reset device choice if previous device disconnected or default device changed + if prev_name not in device_names or prev_default_name != new_default_name: + self.index = None + else: + # Previously selected device might have changed its index after reload + self.index = device_names.index(prev_name) + # Launch new server + self._update_values() + self._update_views() self.reboot_button.on_click(on_reboot_button_click) # test tone button self.test_tone_button = widgets.Button( description="test tone", layout={"width": "70px"} ) - def on_test_tone_button_click(change): from pya.agen.lib import SinOsc, Line (SinOsc(800) * Line(0.1, 0, 0.2)).dup(2).play() - self.test_tone_button.on_click(on_test_tone_button_click) # scope button self.scope_button = widgets.Button( description="ScopeGUI", layout={"width": "80px"} ) - def on_scope_button_click(change): - # TODO: change to Aserver.default - global s - if "s" in globals() and isinstance(s, Aserver): - s.scope_gui() - + Aserver.default.scope_gui() self.scope_button.on_click(on_scope_button_click) # stop button def on_pyagui_stop_button_click(b): - # TODO: change to Aserver.default - if "s" in globals(): - s.stop() - else: - print("no pya server.") - + Aserver.default.stop() self.stop_button = widgets.Button( description="Stop", tooltip="Stop all scheduled events on AServer", @@ -148,7 +133,29 @@ def on_pyagui_stop_button_click(b): width="100%", ), ) - self.show() # render GUI + + def _update_views(self): + # audio device selector dropdown + self.device_selector.options = [ + d["name"] for d in self.audio_devices if d["maxOutputChannels"] > 0 + ] + self.device_selector.value = self.audio_devices[self.index]["name"] + # Input flag checkbox + self.input_flag_wdg.value = self.input_flag + # Sample rate input + self.sr_wdg.value = self.sr + # Block size input + self.blocksize_wdg.value = self.blocksize + def _update_values(self): + self.backend = startup( + sr=int(self.sr), + device=self.index, + input_flag=self.input_flag, + bs=self.blocksize + ).backend + self.audio_devices = self.backend.get_devices() + if self.index is None: + self.index = self.backend.get_default_output_device_info()["index"] def show(self): display(self.all_widgets) From ea2b1de38502eafdaba239285f567dbd1f3b5e77 Mon Sep 17 00:00:00 2001 From: HalbFettKaese Date: Mon, 22 Dec 2025 02:46:42 +0100 Subject: [PATCH 4/5] Move sounddevice to requirements.txt --- requirements.txt | 1 + requirements_sounddevice.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 requirements_sounddevice.txt diff --git a/requirements.txt b/requirements.txt index 3f59cf9..4a04fc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ pyamapping scipy>=1.7.3 matplotlib>=3.5.3 soundfile>=0.13.0 +sounddevice diff --git a/requirements_sounddevice.txt b/requirements_sounddevice.txt deleted file mode 100644 index af5e3a6..0000000 --- a/requirements_sounddevice.txt +++ /dev/null @@ -1 +0,0 @@ -sounddevice \ No newline at end of file From 7b56f2f8e40f5e17428a2df0418dd2d9418f7c16 Mon Sep 17 00:00:00 2001 From: HalbFettKaese Date: Mon, 22 Dec 2025 03:02:08 +0100 Subject: [PATCH 5/5] Revert "Terminate backend when quitting server" This reverts commit 69037b1445172f71744f84ae4077d62af1d4b35f. --- pya/aserver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pya/aserver.py b/pya/aserver.py index 0fb209e..5a04e74 100644 --- a/pya/aserver.py +++ b/pya/aserver.py @@ -226,7 +226,6 @@ def quit(self): except AttributeError: _LOGGER.info("No stream found...") self.stream = None - self.backend.terminate() def play(self, asig, onset: Union[int, float] = 0, out: int = 0, **kwargs): """Dispatch asigs or arrays for given onset. @@ -408,12 +407,14 @@ def __exit__(self, exc_type, exc_value, traceback): """Context manager exit""" _LOGGER.info("Exiting context manager. Cleaning up stream and backend") self.quit() + self.backend.terminate() def __del__(self): """Backup cleanup, only if context manager wasn't used""" if hasattr(self, 'stream') and self.stream is not None: try: self.quit() + self.backend.terminate() except: pass # Ignore cleanup errors during shutdown