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