From c0da5461d065a61c286a55e79aa517cd59b650aa Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Fri, 24 May 2024 20:35:16 +0200 Subject: [PATCH 01/24] Added means to listen to the recorded noise samples --- pyproject.toml | 3 +- src/python_spectrometer/core.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f67e215..d114309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,8 @@ dependencies = [ "scipy", "matplotlib >= 3.7", "dill", - "typing_extensions >= 4.5.0" + "typing_extensions >= 4.5.0", + "pyaudio", ] [project.optional-dependencies] diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 601c50b..b4a53c4 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -20,6 +20,12 @@ from qutil.itertools import count from qutil.signal_processing.real_space import Id, welch from qutil.typecheck import check_literals from qutil.ui import progressbar +from scipy import integrate, signal +try: + import pyaudio +except ImportError: + pyaudio = None +import time from ._plot_manager import PlotManager from .daq import settings as daq_settings @@ -254,6 +260,7 @@ class Spectrometer: plot_style: _styleT = 'fast', plot_update_mode: Literal['fast', 'always', 'never'] = 'fast', plot_dB_scale: bool = False, prop_cycle=None, + play_sound:bool = False, purge_raw_data: bool = False, savepath: _pathT = None, compress: bool = True, raw_unit: str = 'V', processed_unit: str = 'V', figure_kw: Optional[Mapping] = None, subplot_kw: Optional[Mapping] = None, @@ -289,6 +296,8 @@ class Spectrometer: prop_cycle, raw_unit, processed_unit, uses_windowed_estimator, figure_kw, subplot_kw, gridspec_kw, legend_kw) + self.audio_stream = None + self.play_sound = play_sound # Expose plot properties from plot manager _to_expose = ('fig', 'ax', 'ax_raw', 'leg', 'plot_raw', 'plot_timetrace', 'plot_cumulative', @@ -526,6 +535,9 @@ class Spectrometer: self.set_reference_spectrum(self.reference_spectrum) self.show(key) + if self.play_sound and pyaudio is not None: + self.play(key) + self._data[key].update(measurement_metadata=measurement_metadata) if self.purge_raw_data: del self._data[key]['timetrace_raw'] @@ -715,6 +727,57 @@ class Spectrometer: with self._plot_manager.plot_context: self._plot_manager.update_figure() + def play(self, *comment_or_index: _keyT, use_processed_timetrace:bool=False, min_duration:Union[None, float]=5.0): + """Plays the noise out loud + + Parameters + ---------- + use_processed_timetrace : bool + If true, then the 'timetrace_processed' data is used for the playback. If False is given, then 'timetrace_raw' is used. (default=False) + min_duration : Union[None, float] + The minimum duration that the noise is to be played. The sample will be repeated until the overall duration is equal to or larger than the min_duration. + + """ + + # Need to unravel 'all' or slice for colors below + comment_or_index = self._unravel_coi(*comment_or_index)[0] + + BITRATE = 44100 + + fs = self._data[comment_or_index]['settings']['fs'] + dt = 1/fs + + if use_processed_timetrace: + data = self._data[comment_or_index]['timetrace_processed'][-1] + else: + data = self._data[comment_or_index]['timetrace_raw'][-1] + + original_duration = dt*len(data) # in s + + # repeat the wave to go up to the min_duration + if min_duration is not None: + repetitions = np.ceil(min_duration/original_duration) + if repetitions > 1: + data = np.repeat(data[None, :], repetitions, axis=0).flatten() + data = data[:int(np.ceil(min_duration/dt))] + + duration = dt*len(data) + num = int(np.floor(BITRATE*duration)) + + # sample data to match the BITRATE + wavedata = signal.resample(data, num) + wavedata /= np.max(np.abs(wavedata)) + + if self.audio_stream is None: + p = pyaudio.PyAudio() + self.audio_stream = p.open( + format=pyaudio.paFloat32, + channels = 1, + rate = BITRATE, + output = True) + + self.audio_stream.write(wavedata.flatten().astype("float32")) + def reprocess_data(self, *comment_or_index: _keyT, save: Literal[False, True, 'overwrite'] = False, -- GitLab From bbefe8a8f94a34ae6703acb73be800a2b81eebae Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Fri, 24 May 2024 20:57:12 +0200 Subject: [PATCH 02/24] added documentation and a attribute for fixed normalizations --- src/python_spectrometer/core.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index b4a53c4..b2e90b2 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -176,6 +176,10 @@ class Spectrometer: acquired, but can be set using :meth:`set_reference_spectrum`. prop_cycle : cycler.Cycler A property cycler for styling the plotted lines. + play_sound:bool + Play the recorded noise sample out loud. + audio_normalization: Union[Literal["single_max"], float] + The factor with with which the waveform is divided by to normalize the waveform. This can be used to set the volume. The default "single_max" normalized each sample depending on only that sample, thus the volume might not carry significant information. savepath : str or Path Directory where the data is saved. All relative paths, for example those given to :meth:`serialize_to_disk`, will be @@ -260,7 +264,7 @@ class Spectrometer: plot_style: _styleT = 'fast', plot_update_mode: Literal['fast', 'always', 'never'] = 'fast', plot_dB_scale: bool = False, prop_cycle=None, - play_sound:bool = False, + play_sound:bool = False, audio_normalization: Union[Literal["single_max"], float] = "single_max", purge_raw_data: bool = False, savepath: _pathT = None, compress: bool = True, raw_unit: str = 'V', processed_unit: str = 'V', figure_kw: Optional[Mapping] = None, subplot_kw: Optional[Mapping] = None, @@ -298,6 +302,7 @@ class Spectrometer: gridspec_kw, legend_kw) self.audio_stream = None self.play_sound = play_sound + self.audio_normalization = audio_normalization # Expose plot properties from plot manager _to_expose = ('fig', 'ax', 'ax_raw', 'leg', 'plot_raw', 'plot_timetrace', 'plot_cumulative', @@ -728,7 +733,7 @@ class Spectrometer: self._plot_manager.update_figure() def play(self, *comment_or_index: _keyT, use_processed_timetrace:bool=False, min_duration:Union[None, float]=5.0): - """Plays the noise out loud + """Plays the noise out loud to allow the scientist to use their auditory input. Parameters ---------- @@ -766,7 +771,11 @@ class Spectrometer: # sample data to match the BITRATE wavedata = signal.resample(data, num) - wavedata /= np.max(np.abs(wavedata)) + + if self.audio_normalization == "single_max": + wavedata /= np.max(np.abs(wavedata)) + elif isinstance(self.audio_normalization, float): + wavedata /= np.abs(self.audio_normalization) if self.audio_stream is None: p = pyaudio.PyAudio() -- GitLab From 8e3d5b311e18eb64168d807eb3f63941dcdf34df Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Fri, 24 May 2024 23:29:15 +0200 Subject: [PATCH 03/24] Moved the playback into a separate thread --- src/python_spectrometer/core.py | 124 +++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index b2e90b2..9dd8bfd 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -25,7 +25,11 @@ try: import pyaudio except ImportError: pyaudio = None +import asyncio import time +import threading +import queue + from ._plot_manager import PlotManager from .daq import settings as daq_settings @@ -300,8 +304,11 @@ class Spectrometer: prop_cycle, raw_unit, processed_unit, uses_windowed_estimator, figure_kw, subplot_kw, gridspec_kw, legend_kw) - self.audio_stream = None self.play_sound = play_sound + if play_sound: + self.audio_stream = AuditoryManager() + else: + self.audio_stream = None self.audio_normalization = audio_normalization # Expose plot properties from plot manager @@ -732,7 +739,7 @@ class Spectrometer: with self._plot_manager.plot_context: self._plot_manager.update_figure() - def play(self, *comment_or_index: _keyT, use_processed_timetrace:bool=False, min_duration:Union[None, float]=5.0): + def play(self, *comment_or_index: _keyT, use_processed_timetrace:bool=False, min_duration:Union[None, float]=None): """Plays the noise out loud to allow the scientist to use their auditory input. Parameters @@ -777,15 +784,8 @@ class Spectrometer: elif isinstance(self.audio_normalization, float): wavedata /= np.abs(self.audio_normalization) - if self.audio_stream is None: - p = pyaudio.PyAudio() - self.audio_stream = p.open( - format=pyaudio.paFloat32, - channels = 1, - rate = BITRATE, - output = True) - - self.audio_stream.write(wavedata.flatten().astype("float32")) + if self.audio_stream is not None: + self.audio_stream.notify(wavedata.flatten().astype("float32")) def reprocess_data(self, *comment_or_index: _keyT, @@ -1155,6 +1155,108 @@ class Spectrometer: return [(key, value) for key, value in sorted(self._data.items())] +def _audio_thread(waveform_queue:queue.Queue, stop_flag:threading.Event, max_playbacks:Union[int, float]): + + import time + import pyaudio + + # Create a PyAudio object + p = pyaudio.PyAudio() + + # Open a PyAudio stream + stream = p.open(format=pyaudio.paFloat32, + channels=1, + rate=44100, + output=True) + + # waiting for the first sample + while waveform_queue.empty() and not stop_flag.is_set(): + time.sleep(0.01) + + last_waveform = None + repeats = 0 + + # run the playback look until the stop flag is set + while not stop_flag.is_set(): + + if not waveform_queue.empty(): + last_waveform = waveform_queue.get() + repeats = 0 + + if last_waveform is not None: + stream.write(last_waveform) + repeats += 1 + + if repeats >= max_playbacks: + last_waveform = None + + time.sleep(0.001) + + # the stop_flag has been raised, thus thing will be closed. + stream.close() + p.terminate() + + +class AuditoryManager: + """ Manages a thread used to play back the recorded noise samples. + This class has been written with the help of ChatGPT 4o. + + Parameter + --------- + max_playbacks : Union[int, float] + How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) + """ + + def __init__(self, max_playbacks=10): + + self.max_playbacks = max_playbacks + + self.waveform_queue = queue.Queue() + self.stop_flag = threading.Event() + self.playback_thread = None + + def start(self): + """Starts the thread. The thread then waits until a samples is given via the notify method. + """ + + # empty the queue + while not self.waveform_queue.empty(): + self.waveform_queue.get() + + # unset the termination flag + self.stop_flag.clear() + + # start the thread + self.playback_thread = threading.Thread(target=_audio_thread, args=(self.waveform_queue, self.stop_flag, self.max_playbacks)) + # playback_thread.daemon = True # Ensure the thread exits when the main program does + self.playback_thread.start() + + def notify(self, waveform): + """ Sends a waveform of a noise sample to the playback thread. The thread is started if the thread is not running. + """ + + # if the thread is not running, start it + if self.playback_thread is None or not self.playback_thread.is_alive(): + self.start() + + # put the waveform into the queue + self.waveform_queue.put(waveform) + + def stop(self): + """ Stops the playback and the thread. + """ + + # notify the thread + self.stop_flag.set() + + # wait until the thread has terminated + if self.playback_thread is not None and self.playback_thread.is_alive(): + self.playback_thread.join() + + def __del__(self): + self.stop() + + def _load_spectrum(file: _pathT) -> Dict[str, Any]: """Loads data from a spectrometer run.""" class monkey_patched_io: -- GitLab From f7c1ae3a0d0c0c3524ce77c29959b387e5811c0f Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Fri, 24 May 2024 23:41:27 +0200 Subject: [PATCH 04/24] play back only the latest element. If multiple elements have been loaded into the queue while one sample is still playing, then only the last sample will be played back in the next cycle. --- src/python_spectrometer/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 9dd8bfd..ad15d09 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -1179,7 +1179,7 @@ def _audio_thread(waveform_queue:queue.Queue, stop_flag:threading.Event, max_pla # run the playback look until the stop flag is set while not stop_flag.is_set(): - if not waveform_queue.empty(): + while not waveform_queue.empty(): last_waveform = waveform_queue.get() repeats = 0 -- GitLab From 3ce8549d9a0eadf73aae8c7f864f53d51125ae08 Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Fri, 24 May 2024 23:49:45 +0200 Subject: [PATCH 05/24] moved a time.sleep() around to make the playback smoother --- src/python_spectrometer/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index ad15d09..3a76b03 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -1179,7 +1179,10 @@ def _audio_thread(waveform_queue:queue.Queue, stop_flag:threading.Event, max_pla # run the playback look until the stop flag is set while not stop_flag.is_set(): - while not waveform_queue.empty(): + while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): + time.sleep(0.01) + + while not waveform_queue.empty() and not stop_flag.is_set(): last_waveform = waveform_queue.get() repeats = 0 @@ -1190,8 +1193,6 @@ def _audio_thread(waveform_queue:queue.Queue, stop_flag:threading.Event, max_pla if repeats >= max_playbacks: last_waveform = None - time.sleep(0.001) - # the stop_flag has been raised, thus thing will be closed. stream.close() p.terminate() -- GitLab From ed77ee00ded49dfb4ebbf826405be1a28f5688f0 Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Thu, 13 Jun 2024 10:23:27 +0200 Subject: [PATCH 06/24] small changes related to the review of !21 --- pyproject.toml | 5 +- src/python_spectrometer/core.py | 87 ++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d114309..9447a3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "matplotlib >= 3.7", "dill", "typing_extensions >= 4.5.0", - "pyaudio", ] [project.optional-dependencies] @@ -51,6 +50,9 @@ qcodes = [ "qcodes", "qcodes_contrib_drivers", ] +audio_playback = [ + "pyaudio >= 0.2.14", +] doc = [ "sphinx", "pydata-sphinx-theme", @@ -64,6 +66,7 @@ complete = [ "python-spectrometer[qcodes]", "python-spectrometer[simulator]", "python-spectrometer[zurich_instruments]", + "python-spectrometer[audio_playback]", "python-spectrometer[doc]", "python-spectrometer[tests]", ] diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 3a76b03..82112be 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -3,6 +3,8 @@ import inspect import os import shelve import warnings +import threading +import queue from datetime import datetime from pathlib import Path from pprint import pprint @@ -21,14 +23,11 @@ from qutil.signal_processing.real_space import Id, welch from qutil.typecheck import check_literals from qutil.ui import progressbar from scipy import integrate, signal + try: import pyaudio except ImportError: pyaudio = None -import asyncio -import time -import threading -import queue from ._plot_manager import PlotManager @@ -180,10 +179,14 @@ class Spectrometer: acquired, but can be set using :meth:`set_reference_spectrum`. prop_cycle : cycler.Cycler A property cycler for styling the plotted lines. - play_sound:bool + play_sound : bool, default False Play the recorded noise sample out loud. - audio_normalization: Union[Literal["single_max"], float] - The factor with with which the waveform is divided by to normalize the waveform. This can be used to set the volume. The default "single_max" normalized each sample depending on only that sample, thus the volume might not carry significant information. + audio_normalization : Union[Literal["single_max"], float], default "single_max" + The factor with with which the waveform is divided by to + normalize the waveform. This can be used to set the volume. + The default "single_max" normalized each sample depending on + only that sample, thus the volume might not carry significant + information. savepath : str or Path Directory where the data is saved. All relative paths, for example those given to :meth:`serialize_to_disk`, will be @@ -268,7 +271,7 @@ class Spectrometer: plot_style: _styleT = 'fast', plot_update_mode: Literal['fast', 'always', 'never'] = 'fast', plot_dB_scale: bool = False, prop_cycle=None, - play_sound:bool = False, audio_normalization: Union[Literal["single_max"], float] = "single_max", + play_sound: bool = False, audio_normalization: Union[Literal["single_max"], float] = "single_max", purge_raw_data: bool = False, savepath: _pathT = None, compress: bool = True, raw_unit: str = 'V', processed_unit: str = 'V', figure_kw: Optional[Mapping] = None, subplot_kw: Optional[Mapping] = None, @@ -305,10 +308,7 @@ class Spectrometer: uses_windowed_estimator, figure_kw, subplot_kw, gridspec_kw, legend_kw) self.play_sound = play_sound - if play_sound: - self.audio_stream = AuditoryManager() - else: - self.audio_stream = None + self._audio_stream = None self.audio_normalization = audio_normalization # Expose plot properties from plot manager @@ -372,6 +372,27 @@ class Spectrometer: def savepath(self, path): self._savepath = io.to_global_path(path) + @property + def audio_stream(self): + if self.play_sound and self._audio_stream is None: + self._audio_stream = AuditoryManager() + elif not self.play_sound: + if self._audio_stream is not None: + self._audio_stream.stop() + del self._audio_stream + self._audio_stream = None + return self._audio_stream + + @property + def play_sound(self): + return self._play_sound + + @play_sound.setter + def play_sound(self, flag:bool): + self._play_sound = flag + if not flag: self.audio_stream + + def _resolve_relative_path(self, file: _pathT) -> Path: if not (file := Path(file)).is_absolute(): file = self.savepath / file @@ -739,7 +760,7 @@ class Spectrometer: with self._plot_manager.plot_context: self._plot_manager.update_figure() - def play(self, *comment_or_index: _keyT, use_processed_timetrace:bool=False, min_duration:Union[None, float]=None): + def play(self, comment_or_index: _keyT, use_processed_timetrace: bool = False, min_duration: Union[None, float] = None): """Plays the noise out loud to allow the scientist to use their auditory input. Parameters @@ -751,18 +772,17 @@ class Spectrometer: """ - # Need to unravel 'all' or slice for colors below - comment_or_index = self._unravel_coi(*comment_or_index)[0] + key = self._parse_keys(comment_or_index)[0] BITRATE = 44100 - fs = self._data[comment_or_index]['settings']['fs'] + fs = self._data[key]['settings'].fs dt = 1/fs if use_processed_timetrace: - data = self._data[comment_or_index]['timetrace_processed'][-1] + data = self._data[key]['timetrace_processed'][-1] else: - data = self._data[comment_or_index]['timetrace_raw'][-1] + data = self._data[key]['timetrace_raw'][-1] original_duration = dt*len(data) # in s @@ -771,7 +791,7 @@ class Spectrometer: repetitions = np.ceil(min_duration/original_duration) if repetitions > 1: data = np.repeat(data[None, :], repetitions, axis=0).flatten() - data = data[:int(np.ceil(min_duration/dt))] + data = int(data[:np.ceil(min_duration/dt)]) duration = dt*len(data) num = int(np.floor(BITRATE*duration)) @@ -779,6 +799,7 @@ class Spectrometer: # sample data to match the BITRATE wavedata = signal.resample(data, num) + # normalize the waveform if self.audio_normalization == "single_max": wavedata /= np.max(np.abs(wavedata)) elif isinstance(self.audio_normalization, float): @@ -1155,61 +1176,69 @@ class Spectrometer: return [(key, value) for key, value in sorted(self._data.items())] -def _audio_thread(waveform_queue:queue.Queue, stop_flag:threading.Event, max_playbacks:Union[int, float]): +def _audio_playback_target(waveform_queue: queue.Queue, stop_flag: threading.Event, max_playbacks: Union[int, float]): import time import pyaudio # Create a PyAudio object - p = pyaudio.PyAudio() + pyaudio_instance = pyaudio.PyAudio() # Open a PyAudio stream - stream = p.open(format=pyaudio.paFloat32, + stream = pyaudio_instance.open(format=pyaudio.paFloat32, channels=1, rate=44100, output=True) - # waiting for the first sample - while waveform_queue.empty() and not stop_flag.is_set(): - time.sleep(0.01) - last_waveform = None repeats = 0 # run the playback look until the stop flag is set while not stop_flag.is_set(): + # waiting for a sample while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): time.sleep(0.01) + # getting the latest sample form the queue and resetting the playback counter while not waveform_queue.empty() and not stop_flag.is_set(): last_waveform = waveform_queue.get() repeats = 0 + # exit the playback loop then the stop flag is set. + if stop_flag.is_set(): break + + # playing back the last sample and increasing the counter + # this plays the last sample on repeat up to a set number of repetitions if last_waveform is not None: stream.write(last_waveform) repeats += 1 + # if the counter surpasses the max_playbacks, remove the sample if repeats >= max_playbacks: last_waveform = None # the stop_flag has been raised, thus thing will be closed. stream.close() - p.terminate() + pyaudio_instance.terminate() class AuditoryManager: """ Manages a thread used to play back the recorded noise samples. - This class has been written with the help of ChatGPT 4o. + This class has been written with the help of ChatGPT 4o. Parameter --------- max_playbacks : Union[int, float] How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) + """ def __init__(self, max_playbacks=10): + if pyaudio is None: + raise ValueError("Please install PyAudio listen to noise.") + self.max_playbacks = max_playbacks self.waveform_queue = queue.Queue() @@ -1228,7 +1257,7 @@ class AuditoryManager: self.stop_flag.clear() # start the thread - self.playback_thread = threading.Thread(target=_audio_thread, args=(self.waveform_queue, self.stop_flag, self.max_playbacks)) + self.playback_thread = threading.Thread(target=_audio_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks)) # playback_thread.daemon = True # Ensure the thread exits when the main program does self.playback_thread.start() -- GitLab From 6ec571d6b3c41294a92f920dde46e4f0511e5743 Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Thu, 13 Jun 2024 11:05:15 +0200 Subject: [PATCH 07/24] moved the audio manager into a separate file (!21) --- src/python_spectrometer/_audio_manager.py | 144 ++++++++++++++++ src/python_spectrometer/core.py | 198 ++++++---------------- 2 files changed, 194 insertions(+), 148 deletions(-) create mode 100644 src/python_spectrometer/_audio_manager.py diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py new file mode 100644 index 0000000..d3faf34 --- /dev/null +++ b/src/python_spectrometer/_audio_manager.py @@ -0,0 +1,144 @@ +""" This module contains methods for using auditory channels to interface to humans """ + +from typing import * +import threading +import queue + +import numpy as np +from scipy import signal + +try: + import pyaudio +except ImportError: + pyaudio = None + +def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading.Event, max_playbacks: Union[int, float], bitrate: int): + """ This function will be started in a separate thread to feed the audio output with new data. + """ + + import time + import pyaudio + + # Create a PyAudio object + pyaudio_instance = pyaudio.PyAudio() + + # Open a PyAudio stream + stream = pyaudio_instance.open(format=pyaudio.paFloat32, + channels=1, + rate=bitrate, + output=True) + + last_waveform = None + repeats = 0 + + # run the playback look until the stop flag is set + while not stop_flag.is_set(): + + # waiting for a sample + while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): + time.sleep(0.01) + + # getting the latest sample form the queue and resetting the playback counter + while not waveform_queue.empty() and not stop_flag.is_set(): + last_waveform = waveform_queue.get() + repeats = 0 + + # exit the playback loop then the stop flag is set. + if stop_flag.is_set(): break + + # playing back the last sample and increasing the counter + # this plays the last sample on repeat up to a set number of repetitions + if last_waveform is not None: + stream.write(last_waveform) + repeats += 1 + + # if the counter surpasses the max_playbacks, remove the sample + if repeats >= max_playbacks: + last_waveform = None + + # the stop_flag has been raised, thus thing will be closed. + stream.close() + pyaudio_instance.terminate() + +class WaveformPlaybackManager: + """ Manages a thread used to play back the recorded noise samples. + This class has been written with the help of ChatGPT 4o. + + Parameter + --------- + max_playbacks : Union[int, float] + How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) + audio_amplitude_normalization : Union[Literal["single_max"], float], default "single_max" + The factor with with which the waveform is divided by to + normalize the waveform. This can be used to set the volume. + The default "single_max" normalized each sample depending on + only that sample, thus the volume might not carry significant + information. + + """ + + def __init__(self, max_playbacks: int = 10, amplitude_normalization: Union[Literal["single_max"], float] = "single_max"): + + if pyaudio is None: + raise ValueError("Please install PyAudio listen to noise.") + + self.max_playbacks = max_playbacks + self.amplitude_normalization = amplitude_normalization + + self.waveform_queue = queue.Queue() + self.stop_flag = threading.Event() + self.playback_thread = None + self._BITRATE = 44100 + + def start(self): + """Starts the thread. The thread then waits until a samples is given via the notify method. + """ + + # empty the queue + while not self.waveform_queue.empty(): + self.waveform_queue.get() + + # unset the termination flag + self.stop_flag.clear() + + # start the thread + self.playback_thread = threading.Thread(target=_waveform_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks, self._BITRATE)) + # playback_thread.daemon = True # Ensure the thread exits when the main program does + self.playback_thread.start() + + def notify(self, waveform, bitrate): + """ Sends a waveform of a noise sample to the playback thread. The thread is started if the thread is not running. + """ + + # calculating the number of samples that the waveform should have to fit the target bit rate. + num = int(np.floor(self._BITRATE/bitrate*len(waveform))) + + # normalize the waveform + if self.amplitude_normalization == "single_max": + waveform /= np.max(np.abs(waveform)) + elif isinstance(self.amplitude_normalization, float): + waveform /= np.abs(self.amplitude_normalization) + + # sample data to match the BITRATE + waveform = signal.resample(waveform, num) + + # if the thread is not running, start it + if self.playback_thread is None or not self.playback_thread.is_alive(): + self.start() + + # put the waveform into the queue + self.waveform_queue.put(waveform.flatten().astype("float32")) + + def stop(self): + """ Stops the playback and the thread. + """ + + # notify the thread + self.stop_flag.set() + + # wait until the thread has terminated + if self.playback_thread is not None and self.playback_thread.is_alive(): + self.playback_thread.join() + + def __del__(self): + self.stop() \ No newline at end of file diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 82112be..10903a8 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -3,8 +3,6 @@ import inspect import os import shelve import warnings -import threading -import queue from datetime import datetime from pathlib import Path from pprint import pprint @@ -24,13 +22,8 @@ from qutil.typecheck import check_literals from qutil.ui import progressbar from scipy import integrate, signal -try: - import pyaudio -except ImportError: - pyaudio = None - - from ._plot_manager import PlotManager +from ._audio_manager import WaveformPlaybackManager from .daq import settings as daq_settings from .daq.core import DAQ @@ -181,7 +174,7 @@ class Spectrometer: A property cycler for styling the plotted lines. play_sound : bool, default False Play the recorded noise sample out loud. - audio_normalization : Union[Literal["single_max"], float], default "single_max" + audio_amplitude_normalization : Union[Literal["single_max"], float], default "single_max" The factor with with which the waveform is divided by to normalize the waveform. This can be used to set the volume. The default "single_max" normalized each sample depending on @@ -271,7 +264,7 @@ class Spectrometer: plot_style: _styleT = 'fast', plot_update_mode: Literal['fast', 'always', 'never'] = 'fast', plot_dB_scale: bool = False, prop_cycle=None, - play_sound: bool = False, audio_normalization: Union[Literal["single_max"], float] = "single_max", + play_sound: bool = False, audio_amplitude_normalization: Union[Literal["single_max"], float] = "single_max", purge_raw_data: bool = False, savepath: _pathT = None, compress: bool = True, raw_unit: str = 'V', processed_unit: str = 'V', figure_kw: Optional[Mapping] = None, subplot_kw: Optional[Mapping] = None, @@ -307,9 +300,10 @@ class Spectrometer: prop_cycle, raw_unit, processed_unit, uses_windowed_estimator, figure_kw, subplot_kw, gridspec_kw, legend_kw) - self.play_sound = play_sound + self._audio_stream = None - self.audio_normalization = audio_normalization + self.audio_amplitude_normalization = audio_amplitude_normalization + self.play_sound = play_sound # Expose plot properties from plot manager _to_expose = ('fig', 'ax', 'ax_raw', 'leg', 'plot_raw', 'plot_timetrace', 'plot_cumulative', @@ -372,17 +366,25 @@ class Spectrometer: def savepath(self, path): self._savepath = io.to_global_path(path) - @property - def audio_stream(self): - if self.play_sound and self._audio_stream is None: - self._audio_stream = AuditoryManager() - elif not self.play_sound: - if self._audio_stream is not None: - self._audio_stream.stop() - del self._audio_stream - self._audio_stream = None + def start_audio_stream(self): + if self._audio_stream is None: + # setting up a new playback manager + self._audio_stream = WaveformPlaybackManager( + amplitude_normalization=self.audio_amplitude_normalization + ) + + return self._audio_stream + + def get_audio_stream(self): return self._audio_stream + def close_audio_stream(self): + # stopping the playback and deleting the manager in the case of them existing. + if self._audio_stream is not None: + self._audio_stream.stop() + del self._audio_stream + self._audio_stream = None + @property def play_sound(self): return self._play_sound @@ -390,7 +392,25 @@ class Spectrometer: @play_sound.setter def play_sound(self, flag:bool): self._play_sound = flag - if not flag: self.audio_stream + # as the play back was deactivate, the stream might need to be stopped. + # this will be done now: + if not flag: + # close the stream + self.close_audio_stream() + else: + # start or get the current stream + self.start_audio_stream() + + @property + def audio_amplitude_normalization(self): + return self._audio_amplitude_normalization + + @audio_amplitude_normalization.setter + def audio_amplitude_normalization(self, value): + self._audio_amplitude_normalization = value + # if the playback manager already exists, then we update the value there: + if astream := self.get_audio_stream() is not None: + astream.amplitude_normalization = value def _resolve_relative_path(self, file: _pathT) -> Path: @@ -568,7 +588,7 @@ class Spectrometer: self.set_reference_spectrum(self.reference_spectrum) self.show(key) - if self.play_sound and pyaudio is not None: + if self.play_sound: self.play(key) self._data[key].update(measurement_metadata=measurement_metadata) @@ -772,9 +792,12 @@ class Spectrometer: """ - key = self._parse_keys(comment_or_index)[0] + playback_manager = self.start_audio_stream() - BITRATE = 44100 + if not playback_manager: + raise ValueError("Playback manager could not be started.") + + key = self._parse_keys(comment_or_index)[0] fs = self._data[key]['settings'].fs dt = 1/fs @@ -793,20 +816,8 @@ class Spectrometer: data = np.repeat(data[None, :], repetitions, axis=0).flatten() data = int(data[:np.ceil(min_duration/dt)]) - duration = dt*len(data) - num = int(np.floor(BITRATE*duration)) - - # sample data to match the BITRATE - wavedata = signal.resample(data, num) - - # normalize the waveform - if self.audio_normalization == "single_max": - wavedata /= np.max(np.abs(wavedata)) - elif isinstance(self.audio_normalization, float): - wavedata /= np.abs(self.audio_normalization) - - if self.audio_stream is not None: - self.audio_stream.notify(wavedata.flatten().astype("float32")) + if playback_manager is not None: + playback_manager.notify(data.flatten().astype("float32"), fs) def reprocess_data(self, *comment_or_index: _keyT, @@ -1176,115 +1187,6 @@ class Spectrometer: return [(key, value) for key, value in sorted(self._data.items())] -def _audio_playback_target(waveform_queue: queue.Queue, stop_flag: threading.Event, max_playbacks: Union[int, float]): - - import time - import pyaudio - - # Create a PyAudio object - pyaudio_instance = pyaudio.PyAudio() - - # Open a PyAudio stream - stream = pyaudio_instance.open(format=pyaudio.paFloat32, - channels=1, - rate=44100, - output=True) - - last_waveform = None - repeats = 0 - - # run the playback look until the stop flag is set - while not stop_flag.is_set(): - - # waiting for a sample - while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): - time.sleep(0.01) - - # getting the latest sample form the queue and resetting the playback counter - while not waveform_queue.empty() and not stop_flag.is_set(): - last_waveform = waveform_queue.get() - repeats = 0 - - # exit the playback loop then the stop flag is set. - if stop_flag.is_set(): break - - # playing back the last sample and increasing the counter - # this plays the last sample on repeat up to a set number of repetitions - if last_waveform is not None: - stream.write(last_waveform) - repeats += 1 - - # if the counter surpasses the max_playbacks, remove the sample - if repeats >= max_playbacks: - last_waveform = None - - # the stop_flag has been raised, thus thing will be closed. - stream.close() - pyaudio_instance.terminate() - - -class AuditoryManager: - """ Manages a thread used to play back the recorded noise samples. - This class has been written with the help of ChatGPT 4o. - - Parameter - --------- - max_playbacks : Union[int, float] - How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) - - """ - - def __init__(self, max_playbacks=10): - - if pyaudio is None: - raise ValueError("Please install PyAudio listen to noise.") - - self.max_playbacks = max_playbacks - - self.waveform_queue = queue.Queue() - self.stop_flag = threading.Event() - self.playback_thread = None - - def start(self): - """Starts the thread. The thread then waits until a samples is given via the notify method. - """ - - # empty the queue - while not self.waveform_queue.empty(): - self.waveform_queue.get() - - # unset the termination flag - self.stop_flag.clear() - - # start the thread - self.playback_thread = threading.Thread(target=_audio_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks)) - # playback_thread.daemon = True # Ensure the thread exits when the main program does - self.playback_thread.start() - - def notify(self, waveform): - """ Sends a waveform of a noise sample to the playback thread. The thread is started if the thread is not running. - """ - - # if the thread is not running, start it - if self.playback_thread is None or not self.playback_thread.is_alive(): - self.start() - - # put the waveform into the queue - self.waveform_queue.put(waveform) - - def stop(self): - """ Stops the playback and the thread. - """ - - # notify the thread - self.stop_flag.set() - - # wait until the thread has terminated - if self.playback_thread is not None and self.playback_thread.is_alive(): - self.playback_thread.join() - - def __del__(self): - self.stop() def _load_spectrum(file: _pathT) -> Dict[str, Any]: -- GitLab From abff94ac6acfc1c8c6b4cc1eb57c0f197114c4c6 Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Fri, 21 Jun 2024 10:14:38 +0200 Subject: [PATCH 08/24] two bug fixes that came up during use --- src/python_spectrometer/_audio_manager.py | 3 +++ src/python_spectrometer/core.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index d3faf34..b792c06 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -119,6 +119,9 @@ class WaveformPlaybackManager: elif isinstance(self.amplitude_normalization, float): waveform /= np.abs(self.amplitude_normalization) + # removing the mean of the signal + waveform -= np.mean(waveform) + # sample data to match the BITRATE waveform = signal.resample(waveform, num) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 10903a8..7656761 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -409,7 +409,7 @@ class Spectrometer: def audio_amplitude_normalization(self, value): self._audio_amplitude_normalization = value # if the playback manager already exists, then we update the value there: - if astream := self.get_audio_stream() is not None: + if (astream := self.get_audio_stream()) is not None: astream.amplitude_normalization = value @@ -809,6 +809,10 @@ class Spectrometer: original_duration = dt*len(data) # in s + # taking the real component of the signal if a complex numpy array is given + if np.any(~np.isreal(data)): + data = np.abs(data) + # repeat the wave to go up to the min_duration if min_duration is not None: repetitions = np.ceil(min_duration/original_duration) -- GitLab From 94abb265e51e1fa9bfe7cba4e538e9529fab92d9 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Sun, 1 Sep 2024 16:33:24 +0200 Subject: [PATCH 09/24] Formatting + remove redundant comments --- src/python_spectrometer/_audio_manager.py | 34 ++++++++++------------- src/python_spectrometer/core.py | 18 ++++++------ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index b792c06..861f26f 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -12,17 +12,16 @@ try: except ImportError: pyaudio = None + def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading.Event, max_playbacks: Union[int, float], bitrate: int): - """ This function will be started in a separate thread to feed the audio output with new data. + """ This function will be started in a separate thread to feed the audio output with new data. """ import time import pyaudio - # Create a PyAudio object pyaudio_instance = pyaudio.PyAudio() - - # Open a PyAudio stream + stream = pyaudio_instance.open(format=pyaudio.paFloat32, channels=1, rate=bitrate, @@ -60,27 +59,28 @@ def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading. stream.close() pyaudio_instance.terminate() + class WaveformPlaybackManager: """ Manages a thread used to play back the recorded noise samples. - This class has been written with the help of ChatGPT 4o. + This class has been written with the help of ChatGPT 4o. Parameter --------- max_playbacks : Union[int, float] How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) audio_amplitude_normalization : Union[Literal["single_max"], float], default "single_max" - The factor with with which the waveform is divided by to - normalize the waveform. This can be used to set the volume. - The default "single_max" normalized each sample depending on - only that sample, thus the volume might not carry significant - information. + The factor with with which the waveform is divided by to + normalize the waveform. This can be used to set the volume. + The default "single_max" normalized each sample depending on + only that sample, thus the volume might not carry significant + information. """ def __init__(self, max_playbacks: int = 10, amplitude_normalization: Union[Literal["single_max"], float] = "single_max"): - if pyaudio is None: - raise ValueError("Please install PyAudio listen to noise.") + if pyaudio is None: + raise ValueError("Please install PyAudio to listen to noise.") self.max_playbacks = max_playbacks self.amplitude_normalization = amplitude_normalization @@ -94,16 +94,12 @@ class WaveformPlaybackManager: """Starts the thread. The thread then waits until a samples is given via the notify method. """ - # empty the queue while not self.waveform_queue.empty(): self.waveform_queue.get() - # unset the termination flag self.stop_flag.clear() - # start the thread self.playback_thread = threading.Thread(target=_waveform_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks, self._BITRATE)) - # playback_thread.daemon = True # Ensure the thread exits when the main program does self.playback_thread.start() def notify(self, waveform, bitrate): @@ -119,17 +115,14 @@ class WaveformPlaybackManager: elif isinstance(self.amplitude_normalization, float): waveform /= np.abs(self.amplitude_normalization) - # removing the mean of the signal waveform -= np.mean(waveform) # sample data to match the BITRATE waveform = signal.resample(waveform, num) - # if the thread is not running, start it if self.playback_thread is None or not self.playback_thread.is_alive(): self.start() - # put the waveform into the queue self.waveform_queue.put(waveform.flatten().astype("float32")) def stop(self): @@ -144,4 +137,5 @@ class WaveformPlaybackManager: self.playback_thread.join() def __del__(self): - self.stop() \ No newline at end of file + self.stop() + diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 7656761..d069590 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -175,10 +175,10 @@ class Spectrometer: play_sound : bool, default False Play the recorded noise sample out loud. audio_amplitude_normalization : Union[Literal["single_max"], float], default "single_max" - The factor with with which the waveform is divided by to - normalize the waveform. This can be used to set the volume. - The default "single_max" normalized each sample depending on - only that sample, thus the volume might not carry significant + The factor with with which the waveform is divided by to + normalize the waveform. This can be used to set the volume. + The default "single_max" normalized each sample depending on + only that sample, thus the volume might not carry significant information. savepath : str or Path Directory where the data is saved. All relative paths, for @@ -392,12 +392,12 @@ class Spectrometer: @play_sound.setter def play_sound(self, flag:bool): self._play_sound = flag - # as the play back was deactivate, the stream might need to be stopped. + # as the play back was deactivate, the stream might need to be stopped. # this will be done now: - if not flag: + if not flag: # close the stream self.close_audio_stream() - else: + else: # start or get the current stream self.start_audio_stream() @@ -788,9 +788,9 @@ class Spectrometer: use_processed_timetrace : bool If true, then the 'timetrace_processed' data is used for the playback. If False is given, then 'timetrace_raw' is used. (default=False) min_duration : Union[None, float] - The minimum duration that the noise is to be played. The sample will be repeated until the overall duration is equal to or larger than the min_duration. + The minimum duration that the noise is to be played. The sample will be repeated until the overall duration is equal to or larger than the min_duration. - """ + """ playback_manager = self.start_audio_stream() -- GitLab From 2e96b364762f0728628b742cf8709a0706c51515 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Sun, 1 Sep 2024 16:34:27 +0200 Subject: [PATCH 10/24] spaces :) --- src/python_spectrometer/_audio_manager.py | 182 +++++++++++----------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index 861f26f..aab357f 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -8,134 +8,134 @@ import numpy as np from scipy import signal try: - import pyaudio + import pyaudio except ImportError: - pyaudio = None + pyaudio = None def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading.Event, max_playbacks: Union[int, float], bitrate: int): - """ This function will be started in a separate thread to feed the audio output with new data. - """ + """ This function will be started in a separate thread to feed the audio output with new data. + """ - import time - import pyaudio + import time + import pyaudio - pyaudio_instance = pyaudio.PyAudio() + pyaudio_instance = pyaudio.PyAudio() - stream = pyaudio_instance.open(format=pyaudio.paFloat32, - channels=1, - rate=bitrate, - output=True) + stream = pyaudio_instance.open(format=pyaudio.paFloat32, + channels=1, + rate=bitrate, + output=True) - last_waveform = None - repeats = 0 + last_waveform = None + repeats = 0 - # run the playback look until the stop flag is set - while not stop_flag.is_set(): + # run the playback look until the stop flag is set + while not stop_flag.is_set(): - # waiting for a sample - while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): - time.sleep(0.01) + # waiting for a sample + while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): + time.sleep(0.01) - # getting the latest sample form the queue and resetting the playback counter - while not waveform_queue.empty() and not stop_flag.is_set(): - last_waveform = waveform_queue.get() - repeats = 0 + # getting the latest sample form the queue and resetting the playback counter + while not waveform_queue.empty() and not stop_flag.is_set(): + last_waveform = waveform_queue.get() + repeats = 0 - # exit the playback loop then the stop flag is set. - if stop_flag.is_set(): break + # exit the playback loop then the stop flag is set. + if stop_flag.is_set(): break - # playing back the last sample and increasing the counter - # this plays the last sample on repeat up to a set number of repetitions - if last_waveform is not None: - stream.write(last_waveform) - repeats += 1 + # playing back the last sample and increasing the counter + # this plays the last sample on repeat up to a set number of repetitions + if last_waveform is not None: + stream.write(last_waveform) + repeats += 1 - # if the counter surpasses the max_playbacks, remove the sample - if repeats >= max_playbacks: - last_waveform = None + # if the counter surpasses the max_playbacks, remove the sample + if repeats >= max_playbacks: + last_waveform = None - # the stop_flag has been raised, thus thing will be closed. - stream.close() - pyaudio_instance.terminate() + # the stop_flag has been raised, thus thing will be closed. + stream.close() + pyaudio_instance.terminate() class WaveformPlaybackManager: - """ Manages a thread used to play back the recorded noise samples. - This class has been written with the help of ChatGPT 4o. + """ Manages a thread used to play back the recorded noise samples. + This class has been written with the help of ChatGPT 4o. - Parameter - --------- - max_playbacks : Union[int, float] - How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) - audio_amplitude_normalization : Union[Literal["single_max"], float], default "single_max" - The factor with with which the waveform is divided by to - normalize the waveform. This can be used to set the volume. - The default "single_max" normalized each sample depending on - only that sample, thus the volume might not carry significant - information. + Parameter + --------- + max_playbacks : Union[int, float] + How often one sample is to be replayed. If 1 is given, then the sample is played back only once. If 10 is given, then the sample is played back 10 times if no new waveform is acquired. if np.inf is given, then the sample is played back until the AudtoryManager.stop() is called. (default = 10) + audio_amplitude_normalization : Union[Literal["single_max"], float], default "single_max" + The factor with with which the waveform is divided by to + normalize the waveform. This can be used to set the volume. + The default "single_max" normalized each sample depending on + only that sample, thus the volume might not carry significant + information. - """ + """ - def __init__(self, max_playbacks: int = 10, amplitude_normalization: Union[Literal["single_max"], float] = "single_max"): + def __init__(self, max_playbacks: int = 10, amplitude_normalization: Union[Literal["single_max"], float] = "single_max"): - if pyaudio is None: - raise ValueError("Please install PyAudio to listen to noise.") + if pyaudio is None: + raise ValueError("Please install PyAudio to listen to noise.") - self.max_playbacks = max_playbacks - self.amplitude_normalization = amplitude_normalization + self.max_playbacks = max_playbacks + self.amplitude_normalization = amplitude_normalization - self.waveform_queue = queue.Queue() - self.stop_flag = threading.Event() - self.playback_thread = None - self._BITRATE = 44100 + self.waveform_queue = queue.Queue() + self.stop_flag = threading.Event() + self.playback_thread = None + self._BITRATE = 44100 - def start(self): - """Starts the thread. The thread then waits until a samples is given via the notify method. - """ + def start(self): + """Starts the thread. The thread then waits until a samples is given via the notify method. + """ - while not self.waveform_queue.empty(): - self.waveform_queue.get() + while not self.waveform_queue.empty(): + self.waveform_queue.get() - self.stop_flag.clear() + self.stop_flag.clear() - self.playback_thread = threading.Thread(target=_waveform_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks, self._BITRATE)) - self.playback_thread.start() + self.playback_thread = threading.Thread(target=_waveform_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks, self._BITRATE)) + self.playback_thread.start() - def notify(self, waveform, bitrate): - """ Sends a waveform of a noise sample to the playback thread. The thread is started if the thread is not running. - """ + def notify(self, waveform, bitrate): + """ Sends a waveform of a noise sample to the playback thread. The thread is started if the thread is not running. + """ - # calculating the number of samples that the waveform should have to fit the target bit rate. - num = int(np.floor(self._BITRATE/bitrate*len(waveform))) + # calculating the number of samples that the waveform should have to fit the target bit rate. + num = int(np.floor(self._BITRATE/bitrate*len(waveform))) - # normalize the waveform - if self.amplitude_normalization == "single_max": - waveform /= np.max(np.abs(waveform)) - elif isinstance(self.amplitude_normalization, float): - waveform /= np.abs(self.amplitude_normalization) + # normalize the waveform + if self.amplitude_normalization == "single_max": + waveform /= np.max(np.abs(waveform)) + elif isinstance(self.amplitude_normalization, float): + waveform /= np.abs(self.amplitude_normalization) - waveform -= np.mean(waveform) + waveform -= np.mean(waveform) - # sample data to match the BITRATE - waveform = signal.resample(waveform, num) + # sample data to match the BITRATE + waveform = signal.resample(waveform, num) - if self.playback_thread is None or not self.playback_thread.is_alive(): - self.start() + if self.playback_thread is None or not self.playback_thread.is_alive(): + self.start() - self.waveform_queue.put(waveform.flatten().astype("float32")) + self.waveform_queue.put(waveform.flatten().astype("float32")) - def stop(self): - """ Stops the playback and the thread. - """ + def stop(self): + """ Stops the playback and the thread. + """ - # notify the thread - self.stop_flag.set() + # notify the thread + self.stop_flag.set() - # wait until the thread has terminated - if self.playback_thread is not None and self.playback_thread.is_alive(): - self.playback_thread.join() + # wait until the thread has terminated + if self.playback_thread is not None and self.playback_thread.is_alive(): + self.playback_thread.join() - def __del__(self): - self.stop() + def __del__(self): + self.stop() -- GitLab From c186ffcfe60c4daaf567b754e75d19957fb730ce Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Sun, 1 Sep 2024 16:38:37 +0200 Subject: [PATCH 11/24] Sort imports --- src/python_spectrometer/_audio_manager.py | 4 ++-- src/python_spectrometer/core.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index aab357f..30b10f0 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -1,8 +1,8 @@ """ This module contains methods for using auditory channels to interface to humans """ -from typing import * -import threading import queue +import threading +from typing import Union import numpy as np from scipy import signal diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index d069590..153fe79 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -22,8 +22,8 @@ from qutil.typecheck import check_literals from qutil.ui import progressbar from scipy import integrate, signal -from ._plot_manager import PlotManager from ._audio_manager import WaveformPlaybackManager +from ._plot_manager import PlotManager from .daq import settings as daq_settings from .daq.core import DAQ -- GitLab From a701e12ea15e0e122c8d8f2255c9d36d46a4c08f Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Mon, 28 Oct 2024 15:33:10 +0100 Subject: [PATCH 12/24] addressed one comment --- src/python_spectrometer/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 8a05d52..acc3159 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -881,7 +881,7 @@ class Spectrometer: original_duration = dt*len(data) # in s # taking the real component of the signal if a complex numpy array is given - if np.any(~np.isreal(data)): + if np.iscomplexobj(data): data = np.abs(data) # repeat the wave to go up to the min_duration -- GitLab From 428754390a22ca3507e6efd7a8d3bcb388e04631 Mon Sep 17 00:00:00 2001 From: Paul Surrey <neubaupaul@gmail.com> Date: Mon, 20 Jan 2025 17:19:40 +0100 Subject: [PATCH 13/24] applying cached_property suggestion --- src/python_spectrometer/core.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 9c9d303..867a11a 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -377,17 +377,9 @@ class Spectrometer: def savepath(self, path): self._savepath = io.to_global_path(path) - def start_audio_stream(self): - if self._audio_stream is None: - # setting up a new playback manager - self._audio_stream = WaveformPlaybackManager( - amplitude_normalization=self.audio_amplitude_normalization - ) - - return self._audio_stream - - def get_audio_stream(self): - return self._audio_stream + @cached_property + def audio_stream(self) -> WaveformPlaybackManager: + return WaveformPlaybackManager(self.audio_amplitude_normalization) def close_audio_stream(self): # stopping the playback and deleting the manager in the case of them existing. -- GitLab From 2cd0bf2695b231ab2c262fa750920548bba9db0f Mon Sep 17 00:00:00 2001 From: Paul Surrey <neubaupaul@gmail.com> Date: Mon, 20 Jan 2025 17:20:04 +0100 Subject: [PATCH 14/24] removing a close for the cached_property --- src/python_spectrometer/core.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 867a11a..be62661 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -381,13 +381,6 @@ class Spectrometer: def audio_stream(self) -> WaveformPlaybackManager: return WaveformPlaybackManager(self.audio_amplitude_normalization) - def close_audio_stream(self): - # stopping the playback and deleting the manager in the case of them existing. - if self._audio_stream is not None: - self._audio_stream.stop() - del self._audio_stream - self._audio_stream = None - @property def play_sound(self): return self._play_sound -- GitLab From 2b28905c1f126f65da9ea076f18dbc382692570e Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Mon, 20 Jan 2025 18:41:23 +0100 Subject: [PATCH 15/24] Install build dependency for pyaudio --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ce2b9aa..5071d68 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,8 @@ cache: - .cache/pip before_script: + # Install build dependency for pyaudio + - apt-get update -yq && apt-get install -yq portaudio19-dev - python -m pip install --upgrade pip - python -m pip install hatch -- GitLab From ce001b7c9dd986aae45df040560e1ecf5656f8e2 Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Tue, 21 Jan 2025 09:25:00 +0100 Subject: [PATCH 16/24] requested changes --- src/python_spectrometer/_audio_manager.py | 54 ++++++++++++----------- src/python_spectrometer/core.py | 31 ++++--------- 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index 30b10f0..ab5c3c3 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -31,33 +31,35 @@ def _waveform_playback_target(waveform_queue: queue.Queue, stop_flag: threading. repeats = 0 # run the playback look until the stop flag is set - while not stop_flag.is_set(): + try: + while not stop_flag.is_set(): + + # waiting for a sample + while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): + time.sleep(0.01) + + # getting the latest sample from the queue and resetting the playback counter + while not waveform_queue.empty() and not stop_flag.is_set(): + last_waveform = waveform_queue.get() + repeats = 0 + + # exit the playback loop then the stop flag is set. + if stop_flag.is_set(): break + + # playing back the last sample and increasing the counter + # this plays the last sample on repeat up to a set number of repetitions + if last_waveform is not None: + stream.write(last_waveform) + repeats += 1 + + # if the counter surpasses the max_playbacks, remove the sample + if repeats >= max_playbacks: + last_waveform = None + finally: + # the stop_flag has been raised, thus thing will be closed. + stream.close() + pyaudio_instance.terminate() - # waiting for a sample - while last_waveform is None and waveform_queue.empty() and not stop_flag.is_set(): - time.sleep(0.01) - - # getting the latest sample form the queue and resetting the playback counter - while not waveform_queue.empty() and not stop_flag.is_set(): - last_waveform = waveform_queue.get() - repeats = 0 - - # exit the playback loop then the stop flag is set. - if stop_flag.is_set(): break - - # playing back the last sample and increasing the counter - # this plays the last sample on repeat up to a set number of repetitions - if last_waveform is not None: - stream.write(last_waveform) - repeats += 1 - - # if the counter surpasses the max_playbacks, remove the sample - if repeats >= max_playbacks: - last_waveform = None - - # the stop_flag has been raised, thus thing will be closed. - stream.close() - pyaudio_instance.terminate() class WaveformPlaybackManager: diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index be62661..ca93b3e 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -327,6 +327,12 @@ class Spectrometer: locals().update({attr: _forward_property(PlotManager, '_plot_manager', attr) for attr in _to_expose}) + # Exposing properties from the audio manager + _to_expose = ('amplitude_normalization', ) + locals().update({attr: _forward_property(WaveformPlaybackManager, '_audio_stream', attr) + for attr in _to_expose}) + + def __repr__(self) -> str: if self.keys(): return super().__repr__() + ' with keys\n' + self._repr_keys() @@ -390,23 +396,8 @@ class Spectrometer: self._play_sound = flag # as the play back was deactivate, the stream might need to be stopped. # this will be done now: - if not flag: - # close the stream - self.close_audio_stream() - else: - # start or get the current stream - self.start_audio_stream() - - @property - def audio_amplitude_normalization(self): - return self._audio_amplitude_normalization - - @audio_amplitude_normalization.setter - def audio_amplitude_normalization(self, value): - self._audio_amplitude_normalization = value - # if the playback manager already exists, then we update the value there: - if (astream := self.get_audio_stream()) is not None: - astream.amplitude_normalization = value + if not flag: + self.audio_stream.stop() def _resolve_path(self, file: _pathT) -> Path: """Resolve file to a fully qualified path.""" @@ -851,11 +842,6 @@ class Spectrometer: """ - playback_manager = self.start_audio_stream() - - if not playback_manager: - raise ValueError("Playback manager could not be started.") - key = self._parse_keys(comment_or_index)[0] fs = self._data[key]['settings'].fs @@ -877,7 +863,6 @@ class Spectrometer: repetitions = np.ceil(min_duration/original_duration) if repetitions > 1: data = np.repeat(data[None, :], repetitions, axis=0).flatten() - data = int(data[:np.ceil(min_duration/dt)]) if playback_manager is not None: playback_manager.notify(data.flatten().astype("float32"), fs) -- GitLab From 02263efc23a152fe5046e6590007e98e1028fd2a Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Tue, 21 Jan 2025 09:43:58 +0100 Subject: [PATCH 17/24] Install gcc --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5071d68..774c5bb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,7 +16,7 @@ cache: before_script: # Install build dependency for pyaudio - - apt-get update -yq && apt-get install -yq portaudio19-dev + - apt-get update -yq && apt-get install -yq gcc portaudio19-dev - python -m pip install --upgrade pip - python -m pip install hatch -- GitLab From 53f2ffef870b1173e2505635e68038f9bef2dadf Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Tue, 21 Jan 2025 09:43:59 +0100 Subject: [PATCH 18/24] audio seams to work on mac os --- src/python_spectrometer/_audio_manager.py | 12 ++++++++++-- src/python_spectrometer/core.py | 6 +++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index ab5c3c3..9d15566 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -2,7 +2,7 @@ import queue import threading -from typing import Union +from typing import Union, Literal import numpy as np from scipy import signal @@ -85,13 +85,21 @@ class WaveformPlaybackManager: raise ValueError("Please install PyAudio to listen to noise.") self.max_playbacks = max_playbacks - self.amplitude_normalization = amplitude_normalization + self._amplitude_normalization = amplitude_normalization self.waveform_queue = queue.Queue() self.stop_flag = threading.Event() self.playback_thread = None self._BITRATE = 44100 + @property + def amplitude_normalization(self): + return self._amplitude_normalization + + @amplitude_normalization.setter + def amplitude_normalization(self, factor): + self._amplitude_normalization = factor + def start(self): """Starts the thread. The thread then waits until a samples is given via the notify method. """ diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index ca93b3e..749ab8e 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -385,7 +385,7 @@ class Spectrometer: @cached_property def audio_stream(self) -> WaveformPlaybackManager: - return WaveformPlaybackManager(self.audio_amplitude_normalization) + return WaveformPlaybackManager(amplitude_normalization=self.audio_amplitude_normalization) @property def play_sound(self): @@ -864,8 +864,8 @@ class Spectrometer: if repetitions > 1: data = np.repeat(data[None, :], repetitions, axis=0).flatten() - if playback_manager is not None: - playback_manager.notify(data.flatten().astype("float32"), fs) + if self.audio_stream is not None: + self.audio_stream.notify(data.flatten().astype("float32"), fs) def reprocess_data(self, *comment_or_index: _keyT, -- GitLab From 12a471ce3903a2919447e47a0fca337dbfcb8f86 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Tue, 21 Jan 2025 11:39:54 +0100 Subject: [PATCH 19/24] Try using non-slim image to avoid installing gcc --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 774c5bb..ec2a8a4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,5 @@ # we use the oldest compatible version -image: python:3.9-slim +image: python:3.9 variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" @@ -16,7 +16,7 @@ cache: before_script: # Install build dependency for pyaudio - - apt-get update -yq && apt-get install -yq gcc portaudio19-dev + - apt-get update -yq && apt-get install -yq portaudio19-dev - python -m pip install --upgrade pip - python -m pip install hatch -- GitLab From 598996965e1ca421764604dae13800016a6a692d Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Tue, 21 Jan 2025 12:29:20 +0100 Subject: [PATCH 20/24] added a doctest example on how to use the audio playback option --- src/python_spectrometer/_audio_manager.py | 2 +- src/python_spectrometer/core.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index 9d15566..c95a13a 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -112,7 +112,7 @@ class WaveformPlaybackManager: self.playback_thread = threading.Thread(target=_waveform_playback_target, args=(self.waveform_queue, self.stop_flag, self.max_playbacks, self._BITRATE)) self.playback_thread.start() - def notify(self, waveform, bitrate): + def notify(self, waveform:np.ndarray, bitrate:int): """ Sends a waveform of a noise sample to the playback thread. The thread is started if the thread is not running. """ diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 749ab8e..c33ec7a 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -185,8 +185,10 @@ class Spectrometer: The factor with with which the waveform is divided by to normalize the waveform. This can be used to set the volume. The default "single_max" normalized each sample depending on - only that sample, thus the volume might not carry significant - information. + only that one sample, thus the volume might not carry significant + information. Alternatively a factor like 1e-9 can be given to + specify that 1nA of signal corresponds to the full audio output + amplitude. savepath : str or Path Directory where the data is saved. All relative paths, for example those given to :meth:`serialize_to_disk`, will be @@ -252,6 +254,12 @@ class Spectrometer: 'noverlap': 2000, 'nperseg': 4000} + Use the audio interface to listen to the noise + >>> spect_with_audio = Spectrometer(daq, savepath=mkdtemp(), play_sound=True) + >>> spect_with_audio.take('a comment', f_max=20000, A=2e-4) + >>> spect_with_audio.audio_stream.stop() + + """ _OLD_PARAMETER_NAMES = { 'plot_cumulative_power': 'plot_cumulative', @@ -271,7 +279,8 @@ class Spectrometer: plot_style: _styleT = 'fast', plot_update_mode: Optional[Literal['fast', 'always', 'never']] = None, plot_dB_scale: bool = False, play_sound: bool = False, - audio_amplitude_normalization: Union[Literal["single_max"], float] = "single_max", threaded_acquisition: bool = True, + audio_amplitude_normalization: Union[Literal["single_max"], float] = "single_max", + threaded_acquisition: bool = True, purge_raw_data: bool = False, prop_cycle=None, savepath: _pathT = None, relative_paths: bool = True, compress: bool = True, raw_unit: str = 'V', processed_unit: str = 'V', figure_kw: Optional[Mapping] = None, @@ -315,7 +324,6 @@ class Spectrometer: uses_windowed_estimator, figure_kw, subplot_kw, gridspec_kw, legend_kw) - self._audio_stream = None self.audio_amplitude_normalization = audio_amplitude_normalization self.play_sound = play_sound @@ -329,7 +337,7 @@ class Spectrometer: # Exposing properties from the audio manager _to_expose = ('amplitude_normalization', ) - locals().update({attr: _forward_property(WaveformPlaybackManager, '_audio_stream', attr) + locals().update({attr: _forward_property(WaveformPlaybackManager, 'audio_stream', attr) for attr in _to_expose}) -- GitLab From 15356e3ac34dbb6940cff873b9a4533beefb624f Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Tue, 21 Jan 2025 12:30:08 +0100 Subject: [PATCH 21/24] added one type hint --- src/python_spectrometer/_audio_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index c95a13a..546d5d2 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -97,7 +97,7 @@ class WaveformPlaybackManager: return self._amplitude_normalization @amplitude_normalization.setter - def amplitude_normalization(self, factor): + def amplitude_normalization(self, factor:Union[Literal["single_max"], float]): self._amplitude_normalization = factor def start(self): -- GitLab From 6529312cf0078cdc4d2b7901cdf3726b76f20ef0 Mon Sep 17 00:00:00 2001 From: Paul Surrey <paul.surrey@rwth-aachen.de> Date: Tue, 21 Jan 2025 12:33:53 +0100 Subject: [PATCH 22/24] added myself to the author list --- CITATION.cff | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CITATION.cff b/CITATION.cff index e283da2..e629750 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,6 +13,11 @@ authors: email: tobias.hangleiter@rwth-aachen.de affiliation: RWTH Aachen University orcid: 'https://orcid.org/0000-0002-5177-6162' + - given-names: Paul + family-names: Surrey + email: paul.surrey@rwth-aachen.de + affiliation: RWTH Aachen University + orcid: 'https://orcid.org/0009-0002-9033-0670' - name: >- JARA-FIT Institute for Quantum Information, Forschungszentrum Jülich GmbH and RWTH Aachen -- GitLab From d7f5e2e1550708e43870e766aa15704964117138 Mon Sep 17 00:00:00 2001 From: "Tobias Hangleiter (Valhalla)" <tobias.hangleiter@rwth-aachen.de> Date: Tue, 21 Jan 2025 16:36:09 +0100 Subject: [PATCH 23/24] Synchronize Spectrometer and WaveformPlaybackManager --- src/python_spectrometer/_audio_manager.py | 10 +------ src/python_spectrometer/core.py | 35 ++++++++++++++--------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/python_spectrometer/_audio_manager.py b/src/python_spectrometer/_audio_manager.py index 546d5d2..f515b2c 100644 --- a/src/python_spectrometer/_audio_manager.py +++ b/src/python_spectrometer/_audio_manager.py @@ -85,21 +85,13 @@ class WaveformPlaybackManager: raise ValueError("Please install PyAudio to listen to noise.") self.max_playbacks = max_playbacks - self._amplitude_normalization = amplitude_normalization + self.amplitude_normalization = amplitude_normalization self.waveform_queue = queue.Queue() self.stop_flag = threading.Event() self.playback_thread = None self._BITRATE = 44100 - @property - def amplitude_normalization(self): - return self._amplitude_normalization - - @amplitude_normalization.setter - def amplitude_normalization(self, factor:Union[Literal["single_max"], float]): - self._amplitude_normalization = factor - def start(self): """Starts the thread. The thread then waits until a samples is given via the notify method. """ diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index c33ec7a..71b7ab8 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -21,7 +21,6 @@ from qutil.itertools import count from qutil.signal_processing.real_space import Id, welch from qutil.typecheck import check_literals from qutil.ui import progressbar -from scipy import integrate, signal from ._audio_manager import WaveformPlaybackManager from ._plot_manager import PlotManager @@ -324,8 +323,8 @@ class Spectrometer: uses_windowed_estimator, figure_kw, subplot_kw, gridspec_kw, legend_kw) - self.audio_amplitude_normalization = audio_amplitude_normalization - self.play_sound = play_sound + self._audio_amplitude_normalization = audio_amplitude_normalization + self._play_sound = play_sound # Expose plot properties from plot manager _to_expose = ('fig', 'ax', 'ax_raw', 'leg', 'plot_raw', 'plot_timetrace', 'plot_cumulative', @@ -335,12 +334,6 @@ class Spectrometer: locals().update({attr: _forward_property(PlotManager, '_plot_manager', attr) for attr in _to_expose}) - # Exposing properties from the audio manager - _to_expose = ('amplitude_normalization', ) - locals().update({attr: _forward_property(WaveformPlaybackManager, 'audio_stream', attr) - for attr in _to_expose}) - - def __repr__(self) -> str: if self.keys(): return super().__repr__() + ' with keys\n' + self._repr_keys() @@ -393,19 +386,33 @@ class Spectrometer: @cached_property def audio_stream(self) -> WaveformPlaybackManager: + """Manages audio waveform playback.""" return WaveformPlaybackManager(amplitude_normalization=self.audio_amplitude_normalization) @property def play_sound(self): + """Play the recorded noise sample out loud.""" return self._play_sound @play_sound.setter def play_sound(self, flag:bool): - self._play_sound = flag - # as the play back was deactivate, the stream might need to be stopped. - # this will be done now: - if not flag: - self.audio_stream.stop() + if self._play_sound != flag: + self._play_sound = flag + # as the play back was deactivate, the stream might need to be stopped. + # this will be done now: + if not flag and 'audio_stream' in self.__dict__: + del self.audio_stream + + @property + def audio_amplitude_normalization(self): + """The factor the waveform is divided by to normalize the waveform.""" + return self._audio_amplitude_normalization + + @audio_amplitude_normalization.setter + def audio_amplitude_normalization(self, val): + self._audio_amplitude_normalization = val + if 'audio_stream' in self.__dict__: + self.audio_stream.amplitude_normalization = val def _resolve_path(self, file: _pathT) -> Path: """Resolve file to a fully qualified path.""" -- GitLab From de8f118d0e21c1addaccdd3f14f75f1f6316ca7f Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter <tobias.hangleiter@rwth-aachen.de> Date: Mon, 27 Jan 2025 15:37:24 +0100 Subject: [PATCH 24/24] Fix docstring rendering --- src/python_spectrometer/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python_spectrometer/core.py b/src/python_spectrometer/core.py index 71b7ab8..23a4ad9 100644 --- a/src/python_spectrometer/core.py +++ b/src/python_spectrometer/core.py @@ -253,12 +253,12 @@ class Spectrometer: 'noverlap': 2000, 'nperseg': 4000} - Use the audio interface to listen to the noise + Use the audio interface to listen to the noise: + >>> spect_with_audio = Spectrometer(daq, savepath=mkdtemp(), play_sound=True) >>> spect_with_audio.take('a comment', f_max=20000, A=2e-4) >>> spect_with_audio.audio_stream.stop() - """ _OLD_PARAMETER_NAMES = { 'plot_cumulative_power': 'plot_cumulative', -- GitLab