From 11cc623363b75b5a453a9d2fd1ec5336a02b6a8b Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Thu, 29 Apr 2021 22:07:20 +0200 Subject: [PATCH 01/28] First work on new Siggen implementation --- lasp/lasp_siggen.py | 279 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 lasp/lasp_siggen.py diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py new file mode 100644 index 0000000..0144d7f --- /dev/null +++ b/lasp/lasp_siggen.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3.6 +# -*- coding: utf-8 -*- +""" +Author: J.A. de Jong - ASCEE + +Description: Signal generator code + +""" +import dataclasses +import logging +import multiprocessing as mp +from typing import Tuple + +import numpy as np + +from .filter import PinkNoise +from .lasp_avstream import AvStream, AvType +from .wrappers import Siggen as pyxSiggen + +QUEUE_BUFFER_TIME = 0.3 # The amount of time used in the queues for buffering +# of data, larger is more stable, but also enlarges latency + +__all__ = ["SignalType", "NoiseType", "SiggenMessage", "SiggenData", "Siggen"] + + +class SignalType: + Periodic = 0 + Noise = 1 + Sweep = 2 + Meas = 3 + + +class NoiseType: + white = (0, "White noise") + pink = (1, "Pink noise") + types = (white, pink) + + @staticmethod + def fillComboBox(combo): + for type_ in NoiseType.types: + combo.addItem(type_[1]) + + @staticmethod + def getCurrent(cb): + return NoiseType.types[cb.currentIndex()] + + +class SiggenWorkerDone(Exception): + def __str__(self): + return "Done generating signal" + + +class SiggenMessage: + """ + Different messages that can be send to the signal generator over the pipe + connection. + """ + + stop = 0 # Stop and quit the signal generator + generate = 1 + adjustVolume = 2 # Adjust the volume + newEqSettings = 3 # Forward new equalizer settings + + # These messages are send back to the main thread over the pipe + ready = 4 + error = 5 + done = 6 + + +@dataclasses.dataclass +class SiggenData: + fs: float # Sample rate [Hz] + + # Number of frames "samples" to send in one block + nframes_per_block: int + + # The data type to output + dtype: np.dtype + + # Settings for the equalizer etc + eqdata: object # Equalizer data + + # Level of output signal [dBFS]el + level_dB: float + + # Signal type specific data, i.e. + signaltype: SignalType + signaltypedata: Tuple = None + + +def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, + queues: list + ): + """ + Main function running in a different process, is responsible for generating + new signal data. Uses the signal queue to push new generated signal data + on. + """ + fs = siggendata.fs + nframes_per_block = siggendata.nframes_per_block + level_dB = siggendata.level_dB + dtype = siggendata.dtype + eq = None + + signaltype = siggendata.signaltype + signaltypedata = siggendata.signaltypedata + + dataq, msgq, statusq = queues + + def generate(siggen, eq): + """ + Generate a single block of data + """ + signal = siggen.genSignal(nframes_per_block) + if eq is not None: + signal = eq.equalize(signal) + if np.issubdtype((dtype := siggendata.dtype), np.integer): + bitdepth_fixed = dtype.itemsize * 8 + signal *= 2 ** (bitdepth_fixed - 1) - 1 + dataq.put(signal.astype(dtype)) + + def newSiggen(): + """ + Create a signal generator based on parameters specified in global + function data. + """ + if signaltype == SignalType.Periodic: + freq, = signaltypedata + siggen = pyxSiggen.sineWave(fs, freq, level_dB) + elif signaltype == SignalType.Noise: + noisetype, zerodBpoint = signaltypedata + if noisetype == NoiseType.white: + sos_colorfilter = None + elif noisetype == NoiseType.pink: + sos_colorfilter = PinkNoise(fs, zerodBpoint).flatten() + else: + raise ValueError(f"Unknown noise type") + + siggen = pyxSiggen.noise(fs, level_dB, sos_colorfilter) + + elif signaltype == SignalType.Sweep: + fl, fu, Ts, Tq, sweep_flags = signaltypedata + siggen = pyxSiggen.sweep(fs, fl, fu, Ts, Tq, sweep_flags, level_dB) + + else: + raise ValueError(f"Not implemented signal type: {signaltype}") + + # Pre-generate blocks of signal data + while dataq.qsize() < nblocks_buffer: + generate(siggen, eq) + + return siggen + + # Initialization + try: + siggen = newSiggen() + + except Exception as e: + statusq.put((SiggenMessage.error, str(e))) + return 1 + + finally: + statusq.put((SiggenMessage.done, None)) + + while True: + msg, data = msgq.get() + if msg == SiggenMessage.stop: + logging.debug("Signal generator caught 'stop' message. Exiting.") + return 0 + elif msg == SiggenMessage.generate: + # logging.debug(f"Signal generator caught 'generate' message") + try: + while dataq.qsize() < nblocks_buffer: + # Generate new data and put it in the queue! + generate(siggen, eq) + except SiggenWorkerDone: + statusq.put(SiggenMessage.done) + return 0 + elif msg == SiggenMessage.adjustVolume: + logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") + level_dB = data + siggen = newSiggen() + else: + statusq.put( + SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" + ) + return 1 + + +class Siggen: + """ + Signal generator class, generates signal data in a different process to + unload the work in the calling thread. + """ + + def __init__(self, stream: AvStream, siggendata: SiggenData): + """""" + self.stream = stream + self.nblocks_buffer = max( + 1, int(QUEUE_BUFFER_TIME * stream.samplerate / stream.blocksize) + ) + + qs = [mp.Queue() for i in range(3)] + self.dataq, self.msgq, self.statusq = qs + + self.process = mp.Process( + target=siggenFcn, + args=(siggendata, self.nblocks_buffer, qs), + ) + self.process.start() + + self.handle_msgs() + if not self.process.is_alive(): + raise RuntimeError('Unexpected signal generator exception') + + self.stopped = False + + def setLevel(self, new_level): + """ + Set a new signal level to the generator + + Args: + new_level: The new level in [dBFS] + """ + self.msgq.put((SiggenMessage.adjustVolume, new_level)) + + def handle_msgs(self): + while not self.statusq.empty(): + msg, data = self.statusq.get() + if msg == SiggenMessage.error: + self.stop() + raise RuntimeError( + f"Error in initialization of signal generator: {data}" + ) + elif msg == SiggenMessage.ready: + return + + elif msg == SiggenMessage.done: + self.stop() + + def start(self): + self.stream.addCallback(self.streamCallback, AvType.audio_output) + self.handle_msgs() + + def stop(self): + logging.debug('Siggen::stop()') + if self.stopped: + raise RuntimeError('BUG: Siggen::stop() is called twice!') + self.stream.removeCallback(self.streamCallback, AvType.audio_output) + while not self.dataq.empty(): + self.dataq.get() + while not self.statusq.empty(): + self.statusq.get() + self.msgq.put((SiggenMessage.stop, None)) + + logging.debug('Joining siggen process') + self.process.join() + logging.debug('Joining siggen process done') + self.process.close() + + self.process = None + logging.debug('End Siggen::stop()') + self.stopped = True + + def streamCallback(self, indata, outdata, blockctr): + """Callback from AvStream. + + Copy generated signal from queue + """ + # logging.debug('Siggen::streamCallback()') + assert outdata is not None + if not self.dataq.empty(): + outdata[:, :] = self.dataq.get()[:, np.newaxis] + else: + logging.warning("Signal generator queue empty!") + outdata[:, :] = 0 + + if self.dataq.qsize() < self.nblocks_buffer: + self.msgq.put((SiggenMessage.generate, None)) From 547b00f116d7a89f362a41fef9ff201d050daa23 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Fri, 30 Apr 2021 21:56:54 +0200 Subject: [PATCH 02/28] Added a setLevel function to siggen.h, Some bugfix and partially working signal generator --- lasp/__init__.py | 7 ++- lasp/c/lasp_siggen.c | 7 +++ lasp/c/lasp_siggen.h | 8 ++++ lasp/device/__init__.py | 2 - lasp/device/lasp_daq.pyx | 2 +- lasp/device/lasp_daqconfig.pyx | 1 - lasp/device/lasp_device_common.py | 9 +--- lasp/device/lasp_deviceinfo.pyx | 9 ++++ lasp/lasp_avstream.py | 13 ++---- lasp/lasp_common.py | 9 ++++ lasp/lasp_imptube.py | 13 +++++- lasp/lasp_siggen.py | 72 +++++++++++++++++++++++-------- lasp/wrappers.pyx | 6 ++- 13 files changed, 111 insertions(+), 47 deletions(-) diff --git a/lasp/__init__.py b/lasp/__init__.py index becea65..c0fadc5 100644 --- a/lasp/__init__.py +++ b/lasp/__init__.py @@ -1,11 +1,10 @@ -from .lasp_atomic import * -from .lasp_avstream import * from .lasp_common import * +from .lasp_avstream import * +from .wrappers import * +from .lasp_atomic import * from .lasp_imptube import * from .lasp_measurement import * from .lasp_octavefilter import * from .lasp_slm import * from .lasp_siggen import * from .lasp_weighcal import * -from .wrappers import * -from .device import AvType diff --git a/lasp/c/lasp_siggen.c b/lasp/c/lasp_siggen.c index d9610b4..766af76 100644 --- a/lasp/c/lasp_siggen.c +++ b/lasp/c/lasp_siggen.c @@ -332,6 +332,13 @@ us Siggen_getN(const Siggen* siggen) { feTRACE(15); return 0; } +void Siggen_setLevel(Siggen* siggen, const d new_level_dB) { + fsTRACE(15); + + siggen->level_amp = d_pow(10, new_level_dB/20); + + feTRACE(15); +} void Siggen_free(Siggen* siggen) { fsTRACE(15); diff --git a/lasp/c/lasp_siggen.h b/lasp/c/lasp_siggen.h index d5ec290..52e7862 100644 --- a/lasp/c/lasp_siggen.h +++ b/lasp/c/lasp_siggen.h @@ -46,6 +46,14 @@ Siggen* Siggen_Sinewave_create(const d fs,const d freq,const d level_dB); */ Siggen* Siggen_Noise_create(const d fs, const d level_dB, Sosfilterbank* colorfilter); +/** + * Set the level of the signal generator + * @param[in] Siggen* Signal generator handle + * + * @param[in] new_level_dB The new level, in dBFS + */ +void Siggen_setLevel(Siggen*, const d new_level_dB); + /** * Obtain the repetition period for a periodic excitation. diff --git a/lasp/device/__init__.py b/lasp/device/__init__.py index 7bd944a..6fde552 100644 --- a/lasp/device/__init__.py +++ b/lasp/device/__init__.py @@ -1,6 +1,4 @@ -#!/usr/bin/python3 from .lasp_device_common import * -from .lasp_daq import * from .lasp_deviceinfo import * from .lasp_daqconfig import * from .lasp_daq import * diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index c05854e..8b1039c 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -4,7 +4,7 @@ from .lasp_daqconfig cimport DaqConfiguration from cpython.ref cimport PyObject,Py_INCREF, Py_DECREF import numpy as np -from .lasp_device_common import AvType +from ..lasp_common import AvType __all__ = ['Daq'] diff --git a/lasp/device/lasp_daqconfig.pyx b/lasp/device/lasp_daqconfig.pyx index b1d382f..a34063d 100644 --- a/lasp/device/lasp_daqconfig.pyx +++ b/lasp/device/lasp_daqconfig.pyx @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- """! Author: J.A. de Jong - ASCEE diff --git a/lasp/device/lasp_device_common.py b/lasp/device/lasp_device_common.py index 5cedd8e..40c9bde 100644 --- a/lasp/device/lasp_device_common.py +++ b/lasp/device/lasp_device_common.py @@ -1,17 +1,10 @@ -__all__ = ['AvType', 'DaqChannel'] +__all__ = ['DaqChannel'] from ..lasp_common import Qty, SIQtys from dataclasses import dataclass, field from dataclasses_json import dataclass_json from typing import List import json -class AvType: - """Specificying the type of data, for adding and removing callbacks from - the stream.""" - audio_input = 1 - audio_output = 2 - video = 4 - @dataclass_json @dataclass class DaqChannel: diff --git a/lasp/device/lasp_deviceinfo.pyx b/lasp/device/lasp_deviceinfo.pyx index 2ead824..c5e991b 100644 --- a/lasp/device/lasp_deviceinfo.pyx +++ b/lasp/device/lasp_deviceinfo.pyx @@ -1,3 +1,12 @@ +# -*- coding: utf-8 -*- +"""! +Author: J.A. de Jong - ASCEE + +Description: + +DeviceInfo C++ object wrapper + +""" __all__ = ['DeviceInfo'] diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 70bfc87..79a0664 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -1,20 +1,15 @@ -#!/usr/bin/env python3.6 # -*- coding: utf-8 -*- """ Description: Read data from image stream and record sound at the same time """ #import cv2 as cv from .lasp_atomic import Atomic +from .lasp_common import AvType +from .device import (Daq, DeviceInfo, DaqConfiguration) from threading import Thread, Lock import numpy as np - -class DAQConfiguration: - pass - import time -from .device import (Daq, DeviceInfo, - AvType, - ) + __all__ = ['AvStream'] @@ -28,7 +23,7 @@ class AvStream: def __init__(self, avtype: AvType, device: DeviceInfo, - daqconfig: DAQConfiguration, + daqconfig: DaqConfiguration, video=None): """Open a stream for audio in/output and video input. For audio output, by default all available channels are opened for outputting data. diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index 2125b73..1fb8249 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -20,6 +20,7 @@ __all__ = [ 'P_REF', 'FreqWeighting', 'TimeWeighting', 'getTime', 'getFreq', 'Qty', 'SIQtys', 'lasp_shelve', 'this_lasp_shelve', 'W_REF', 'U_REF', 'I_REF', 'dBFS_REF', + 'AvType' ] # Reference sound pressure level @@ -42,6 +43,14 @@ U_REF = 5e-8 # 50 nano meter / s # hence this is the reference level as specified below. dBFS_REF = 0.5*2**0.5 # Which level would be -3.01 dBFS +class AvType: + """Specificying the type of data, for adding and removing callbacks from + the stream.""" + audio_input = 1 + audio_output = 2 + video = 4 + + @dataclass_json @dataclass class Qty: diff --git a/lasp/lasp_imptube.py b/lasp/lasp_imptube.py index 56d471d..f32ec44 100644 --- a/lasp/lasp_imptube.py +++ b/lasp/lasp_imptube.py @@ -11,6 +11,8 @@ from .lasp_measurement import Measurement from numpy import pi, sqrt, exp import numpy as np from scipy.interpolate import UnivariateSpline +from lrftubes import PrsDuct +from functools import lru_cache class TwoMicImpedanceTube: def __init__(self, mnormal: Measurement, @@ -22,6 +24,8 @@ class TwoMicImpedanceTube: fu: float = None, periodic_method=False, mat= Air(), + D_imptube = 50e-3, + thermoviscous = True, **kwargs): """ @@ -60,6 +64,9 @@ class TwoMicImpedanceTube: kmax = ksmax/s self.fu = kmax*mat.c0/2/pi + self.thermoviscous = thermoviscous + self.D_imptube = D_imptube + self.periodic_method = periodic_method self.channels = [kwargs.pop('chan0', 0), kwargs.pop('chan1', 1)] # Compute calibration correction @@ -82,8 +89,9 @@ class TwoMicImpedanceTube: # Calibration correction factor # self.K = 0*self.freq + 1.0 K = sqrt(C2[:,0,1]*C1[:,0,0]/(C2[:,1,1]*C1[:,1,0])) - self.K = UnivariateSpline(self.freq, K.real)(self.freq) +\ - 1j*UnivariateSpline(self.freq, K.imag)(self.freq) + # self.K = UnivariateSpline(self.freq, K.real)(self.freq) +\ + # 1j*UnivariateSpline(self.freq, K.imag)(self.freq) + self.K = K def cut_to_limits(self, ar): return ar[self.il:self.ul] @@ -94,6 +102,7 @@ class TwoMicImpedanceTube: """ return self.cut_to_limits(self.freq) + @lru_cache def G_AB(self, meas): if meas is self.mnormal: C = self.C1 diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index 0144d7f..9f8fcd5 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -14,8 +14,10 @@ from typing import Tuple import numpy as np from .filter import PinkNoise +from .lasp_octavefilter import SosOctaveFilterBank, SosThirdOctaveFilterBank +from .filter import OctaveBankDesigner, PinkNoise, ThirdOctaveBankDesigner from .lasp_avstream import AvStream, AvType -from .wrappers import Siggen as pyxSiggen +from .wrappers import Siggen as pyxSiggen, Equalizer QUEUE_BUFFER_TIME = 0.3 # The amount of time used in the queues for buffering # of data, larger is more stable, but also enlarges latency @@ -89,7 +91,7 @@ class SiggenData: def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, - queues: list + dataq: mp.Queue, pipe ): """ Main function running in a different process, is responsible for generating @@ -100,12 +102,10 @@ def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, nframes_per_block = siggendata.nframes_per_block level_dB = siggendata.level_dB dtype = siggendata.dtype - eq = None signaltype = siggendata.signaltype signaltypedata = siggendata.signaltypedata - dataq, msgq, statusq = queues def generate(siggen, eq): """ @@ -119,6 +119,31 @@ def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, signal *= 2 ** (bitdepth_fixed - 1) - 1 dataq.put(signal.astype(dtype)) + def createEqualizer(eqdata): + """ + Create an equalizer object from equalizer data + + Args: + eqdata: dictionary containing equalizer data. TODO: document the + requiring fields. + """ + if eqdata is None: + return None + eq_type = eqdata['type'] + eq_levels = eqdata['levels'] + + if eq_type == 'three': + fb = SosThirdOctaveFilterBank(fs) + elif eq_type == 'one': + fb = SosOctaveFilterBank(fs) + + eq = Equalizer(fb._fb) + if eq_levels is not None: + eq.setLevels(eq_levels) + return eq + + eq = createEqualizer(siggendata.eqdata) + def newSiggen(): """ Create a signal generator based on parameters specified in global @@ -156,14 +181,14 @@ def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, siggen = newSiggen() except Exception as e: - statusq.put((SiggenMessage.error, str(e))) + pipe.send((SiggenMessage.error, str(e))) return 1 finally: - statusq.put((SiggenMessage.done, None)) + pipe.send((SiggenMessage.done, None)) while True: - msg, data = msgq.get() + msg, data = pipe.recv() if msg == SiggenMessage.stop: logging.debug("Signal generator caught 'stop' message. Exiting.") return 0 @@ -174,14 +199,17 @@ def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, # Generate new data and put it in the queue! generate(siggen, eq) except SiggenWorkerDone: - statusq.put(SiggenMessage.done) + pipe.send(SiggenMessage.done) return 0 elif msg == SiggenMessage.adjustVolume: logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") level_dB = data - siggen = newSiggen() + siggen.setLevel(level_dB) + elif msg == SiggenMessage.newEqSettings: + eqdata = data + eq = createEqualizer(eqdata) else: - statusq.put( + pipe.send( SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" ) return 1 @@ -201,11 +229,12 @@ class Siggen: ) qs = [mp.Queue() for i in range(3)] - self.dataq, self.msgq, self.statusq = qs + self.dataq = mp.Queue() + self.pipe, client_end = mp.Pipe(duplex=True) self.process = mp.Process( target=siggenFcn, - args=(siggendata, self.nblocks_buffer, qs), + args=(siggendata, self.nblocks_buffer, self.dataq, client_end), ) self.process.start() @@ -222,11 +251,14 @@ class Siggen: Args: new_level: The new level in [dBFS] """ - self.msgq.put((SiggenMessage.adjustVolume, new_level)) + self.pipe.send((SiggenMessage.adjustVolume, new_level)) + + def setEqData(self, eqdata): + self.pipe.send((SiggenMessage.newEqSettings, eqdata)) def handle_msgs(self): - while not self.statusq.empty(): - msg, data = self.statusq.get() + while self.pipe.poll(): + msg, data = self.pipe.recv() if msg == SiggenMessage.error: self.stop() raise RuntimeError( @@ -239,6 +271,9 @@ class Siggen: self.stop() def start(self): + if self.stopped: + raise RuntimeError('BUG: This Siggen object cannot be used again.') + self.stream.addCallback(self.streamCallback, AvType.audio_output) self.handle_msgs() @@ -249,9 +284,8 @@ class Siggen: self.stream.removeCallback(self.streamCallback, AvType.audio_output) while not self.dataq.empty(): self.dataq.get() - while not self.statusq.empty(): - self.statusq.get() - self.msgq.put((SiggenMessage.stop, None)) + self.pipe.send((SiggenMessage.stop, None)) + self.pipe.close() logging.debug('Joining siggen process') self.process.join() @@ -276,4 +310,4 @@ class Siggen: outdata[:, :] = 0 if self.dataq.qsize() < self.nblocks_buffer: - self.msgq.put((SiggenMessage.generate, None)) + self.pipe.send((SiggenMessage.generate, None)) diff --git a/lasp/wrappers.pyx b/lasp/wrappers.pyx index e9e6fa9..536c396 100644 --- a/lasp/wrappers.pyx +++ b/lasp/wrappers.pyx @@ -74,7 +74,7 @@ cdef extern from "lasp_python.h": __all__ = ['AvPowerSpectra', 'SosFilterBank', 'FilterBank', 'Siggen', 'sweep_flag_forward', 'sweep_flag_backward', 'sweep_flag_linear', 'sweep_flag_exponential', - 'load_fft_wisdom', 'store_fft_wisdom'] + 'load_fft_wisdom', 'store_fft_wisdom', 'Window'] setTracerLevel(15) @@ -635,6 +635,7 @@ cdef extern from "lasp_siggen.h": c_Siggen* Siggen_Sweep_create(d fs, d fl, d fu, d Ts,d Tq, us sweep_flags, d level_dB) + void Siggen_setLevel(c_Siggen*,d new_level_dB) us Siggen_getN(const c_Siggen*) void Siggen_genSignal(c_Siggen*, vd* samples) nogil void Siggen_free(c_Siggen*) @@ -659,6 +660,9 @@ cdef class Siggen: if self._siggen: Siggen_free(self._siggen) + def setLevel(self,d level_dB): + Siggen_setLevel(self._siggen, level_dB) + def genSignal(self, us nsamples): output = np.empty(nsamples, dtype=np.float) assert self._siggen != NULL From b9e31d79fd08ef3caea4d339a5e0df5db42cc0f5 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Tue, 4 May 2021 10:29:51 +0200 Subject: [PATCH 03/28] Added Monkeypatch to make multiprocessing work with a list of queues in the manager --- lasp/lasp_multiprocessingpatch.py | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 lasp/lasp_multiprocessingpatch.py diff --git a/lasp/lasp_multiprocessingpatch.py b/lasp/lasp_multiprocessingpatch.py new file mode 100644 index 0000000..fcd8e79 --- /dev/null +++ b/lasp/lasp_multiprocessingpatch.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +""" +Author: J.A. de Jong + +Description: MonkeyPatch required to let the Multiprocessing library work properly. +Should be applied prior to running any other multiprocessing code. Comes from +Stackoverflow and is mainly used for managing a list of queues that can be +shared between processes. + +For more information, see: +https://stackoverflow.com/questions/46779860/multiprocessing-managers-and-custom-classes +""" +from multiprocessing import managers +import logging +from functools import wraps +from inspect import signature + +orig_AutoProxy = managers.AutoProxy + +__all__ = ['apply_patch'] + + +@wraps(managers.AutoProxy) +def AutoProxy(*args, incref=True, manager_owned=False, **kwargs): + # Create the autoproxy without the manager_owned flag, then + # update the flag on the generated instance. If the manager_owned flag + # is set, `incref` is disabled, so set it to False here for the same + # result. + autoproxy_incref = False if manager_owned else incref + proxy = orig_AutoProxy(*args, incref=autoproxy_incref, **kwargs) + proxy._owned_by_manager = manager_owned + return proxy + + +def apply_patch(): + if "manager_owned" in signature(managers.AutoProxy).parameters: + return + + logging.debug("Patching multiprocessing.managers.AutoProxy to add manager_owned") + managers.AutoProxy = AutoProxy + + # re-register any types already registered to SyncManager without a custom + # proxy type, as otherwise these would all be using the old unpatched AutoProxy + SyncManager = managers.SyncManager + registry = managers.SyncManager._registry + for typeid, (callable, exposed, method_to_typeid, proxytype) in registry.items(): + if proxytype is not orig_AutoProxy: + continue + create_method = hasattr(managers.SyncManager, typeid) + SyncManager.register( + typeid, + callable=callable, + exposed=exposed, + method_to_typeid=method_to_typeid, + create_method=create_method, + ) + From 465706346710dc87dd8e52371224e0c2e26e79a0 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Tue, 4 May 2021 15:10:13 +0200 Subject: [PATCH 04/28] Record is back working. Now ready for testing signal generator --- lasp/device/lasp_cpprtaudio.cpp | 2 +- lasp/device/lasp_daq.pyx | 1 - lasp/device/lasp_daqconfig.pyx | 20 +- lasp/lasp_avstream.py | 443 +++++++++++++++++++++++--------- lasp/lasp_record.py | 270 ++++++++++++------- lasp/lasp_siggen.py | 116 +++++---- scripts/lasp_record | 105 ++++---- 7 files changed, 613 insertions(+), 344 deletions(-) diff --git a/lasp/device/lasp_cpprtaudio.cpp b/lasp/device/lasp_cpprtaudio.cpp index d5969ff..53f9e91 100644 --- a/lasp/device/lasp_cpprtaudio.cpp +++ b/lasp/device/lasp_cpprtaudio.cpp @@ -175,7 +175,7 @@ class AudioDaq: public Daq { &streamoptions, &myerrorcallback ); - } catch(...) { + } catch(RtAudioError& e) { if(rtaudio) delete rtaudio; if(instreamparams) delete instreamparams; if(outstreamparams) delete outstreamparams; diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index 8b1039c..fdeabe9 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -166,7 +166,6 @@ cdef class Daq: try: self.daq_device = cppDaq.createDaq(devinfo[0], daqconfig[0]) except Exception as e: - print(e) raise self.nFramesPerBlock = self.daq_device.framesPerBlock() self.samplerate = self.daq_device.samplerate() diff --git a/lasp/device/lasp_daqconfig.pyx b/lasp/device/lasp_daqconfig.pyx index a34063d..a3cad81 100644 --- a/lasp/device/lasp_daqconfig.pyx +++ b/lasp/device/lasp_daqconfig.pyx @@ -45,6 +45,8 @@ cdef class DaqConfigurations: def loadConfigs(): """ Returns a list of currently available configurations + + The first configuration is for input, the second for output """ with lasp_shelve() as sh: configs_json = sh.load('daqconfigs', {}) @@ -66,13 +68,16 @@ cdef class DaqConfigurations: del configs_json[name] sh.store('daqconfigs', configs_json) +def constructDaqConfig(dict_data): + return DaqConfiguration.from_dict(dict_data) cdef class DaqConfiguration: """ Initialize a device descriptor """ - def __init__(self): - pass + + def __str__(self): + return str(self.to_json()) @staticmethod def fromDeviceInfo(DeviceInfo devinfo): @@ -89,6 +94,9 @@ cdef class DaqConfiguration: config_dict = json.loads(jsonstring) return DaqConfiguration.from_dict(config_dict) + def __reduce__(self): + return (constructDaqConfig, (self.to_dict(),)) + @staticmethod def from_dict(pydict): cdef: @@ -126,8 +134,8 @@ cdef class DaqConfiguration: return pydaqcfg - def to_json(self): - return json.dumps(dict( + def to_dict(self): + return dict( apicode = self.config.api.apicode, device_name = self.config.device_name.decode('utf-8'), @@ -158,8 +166,10 @@ cdef class DaqConfiguration: inputIEPEEnabled = self.config.inputIEPEEnabled, inputACCouplingMode = self.config.inputACCouplingMode, inputRangeIndices = self.config.inputRangeIndices, + ) - )) + def to_json(self): + return json.dumps(self.to_dict()) def getInChannel(self, i:int): return DaqChannel( diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 79a0664..82e2688 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -1,28 +1,213 @@ # -*- coding: utf-8 -*- """ +Author: J.A. de Jong + Description: Read data from image stream and record sound at the same time """ #import cv2 as cv +import multiprocessing as mp +import signal +from .lasp_multiprocessingpatch import apply_patch +apply_patch() + from .lasp_atomic import Atomic from .lasp_common import AvType from .device import (Daq, DeviceInfo, DaqConfiguration) from threading import Thread, Lock import numpy as np -import time +import time, logging +from enum import unique, Enum, auto __all__ = ['AvStream'] video_x, video_y = 640, 480 +@unique +class StreamMsg(Enum): + """ + First part, control messages that can be send to the stream + """ + startStream = auto() + stopStream = auto() + getStreamMetaData = auto() + endProcess = auto() + + activateSiggen = auto() + deactivateSiggen = auto() + + + """ + Second part, status messages that are send back on all listeners + """ + # "Normal messages" + streamStarted = auto() + streamStopped = auto() + streamMetaData = auto() + streamData = auto() + + # Error messages + streamError = auto() + streamFatalError = auto() + + + +class AvStreamProcess(mp.Process): + + def __init__(self, daqconfig: DaqConfiguration, + pipe, in_qlist, outq): + """ + + Args: + device: DeviceInfo + + """ + self.daqconfig = daqconfig + self.pipe = pipe + self.in_qlist = in_qlist + self.outq = outq + self.aframectr = 0 + self.daq = None + self.streamdata = None + + super().__init__() + + def run(self): + """ + The actual function running in a different process. + """ + # https://stackoverflow.com/questions/21104997/keyboard-interrupt-with-pythons-multiprocessing + signal.signal(signal.SIGINT, signal.SIG_IGN) + + self.siggen_activated = Atomic(False) + self.running = Atomic(False) + self.aframectr = Atomic(0) + + daqconfig = self.daqconfig + devices = Daq.getDeviceInfo() + api_devices = devices[daqconfig.api] + + matching_devices = [ + device for device in api_devices if device.name == daqconfig.device_name] + + if len(matching_devices) == 0: + self.pipe.send((StreamMsg.streamFatalError, f"Device {daqconfig.device_name} not available")) + + self.device = matching_devices[0] + # logging.debug(self.device) + # logging.debug(self.daqconfig) + while True: + msg, data = self.pipe.recv() + logging.debug(f'Obtained message {msg}') + + if msg == StreamMsg.activateSiggen: + self.siggen_activated <<= True + elif msg == StreamMsg.deactivateSiggen: + self.siggen_activated <<= False + + elif msg == StreamMsg.endProcess: + if self.streamdata is not None and self.running: + logging.error('Process exit while stream is still running') + return + + elif msg == StreamMsg.getStreamMetaData: + self.pipe.send((StreamMsg.streamMetaData, self.streamdata)) + for q in self.in_qlist: + q.put((StreamMsg.streamMetaData, self.streamdata)) + + elif msg == StreamMsg.startStream: + self.startStream() + elif msg == StreamMsg.stopStream: + self.stopStream() + + def startStream(self): + """ + Start the DAQ stream. + """ + if self.daq is not None: + self.pipe.send((StreamMsg.streamError, 'Stream has already been started')) + return + + try: + self.daq = Daq(self.device, + self.daqconfig) + samplerate = self.daq.start(self.streamCallback) + streamdata = { + 'blocksize': self.daq.nFramesPerBlock, + 'samplerate': samplerate, + 'dtype': self.daq.getNumpyDataType(), + } + + self.streamdata = streamdata + self.pipe.send((StreamMsg.streamStarted, streamdata)) + self.putAllInQueues(StreamMsg.streamStarted, streamdata) + except Exception as e: + logging.debug(f'Error starting stream: {e}') + self.daq = None + self.pipe.send((StreamMsg.streamError, str(e))) + + def stopStream(self): + """ + Stop the DAQ stream. + """ + + if self.daq is None: + self.pipe.send((StreamMsg.streamError, 'Stream is not running')) + return + + try: + self.daq.stop() + self.running <<= False + self.streamdata = None + self.pipe.send((StreamMsg.streamStopped, None)) + self.putAllInQueues(StreamMsg.streamStopped, None) + except Exception as e: + self.pipe.send((StreamMsg.streamError, f'Error stopping stream: {e}')) + + self.streamdata + self.daq = None + + def streamCallback(self, indata, outdata, nframes): + """This is called (from a separate thread) for each audio block.""" + self.aframectr += nframes + + if self.siggen_activated: + if self.outq.empty(): + outdata[:, :] = 0 + msgtxt = 'Output signal buffer underflow' + self.pipe.send((StreamMsg.streamError, msgtxt)) + self.putAllInQueues(StreamMsg.streamError, msgtxt) + else: + newdata = self.outq.get() + if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: + self.pipe.send(StreamMsg.streamFatalError, 'Invalid output data obtained from queue') + return 1 + + if indata is not None: + self.putAllInQueues(StreamMsg.streamData, indata) + + return 0 if self.running else 1 + + def putAllInQueues(self, msg, data): + """ + Put a message and data on all input queues in the queue list + """ + for q in self.in_qlist: + # Fan out the input data to all queues in the queue list + q.put((msg, data)) + +def ignoreSigInt(): + signal.signal(signal.SIGINT, signal.SIG_IGN) class AvStream: - """Audio and video data stream, to which callbacks can be added for - processing the data.""" - + """Audio and video data stream, to which callbacks can be adde + daqconfig: DAQConfiguration instance. If duplex mode flag is set, + please make sure that output_device is None, as in that case the + output config will be taken from the input device. + video: + """ def __init__(self, avtype: AvType, - device: DeviceInfo, daqconfig: DaqConfiguration, video=None): """Open a stream for audio in/output and video input. For audio output, @@ -50,146 +235,160 @@ class AvStream: self.input_sensitivity = np.asarray(self.input_sensitivity) self.input_qtys = [ch.qty for ch in en_in_ch] - # Counters for the number of frames that have been coming in - self._aframectr = Atomic(0) self._vframectr = Atomic(0) - # Lock - self._callbacklock = Lock() - self._running = Atomic(False) + # Multiprocessing manager, pipe, output queue, input queue, + self.manager = mp.managers.SyncManager() + # https://stackoverflow.com/questions/21104997/keyboard-interrupt-with-pythons-multiprocessing + self.manager.start(ignoreSigInt) - self._video = video - self._video_started = Atomic(False) + # List of queues for all entities that require 'microphone' or input + # data. We need a local list, to manage listener queues, as the queues + # which are in the manager list get a new object id. The local list is + # used to find the index in the manager queues list upon deletion by + # 'removeListener()' + self.in_qlist = self.manager.list([]) + self.in_qlist_local = [] - # Storage for callbacks, specified by type - self._callbacks = { - AvType.audio_input: [], - AvType.audio_output: [], - AvType.video: [] - } + # Queue used for signal generator data + self.outq = self.manager.Queue() + + # Messaging pipe + self.pipe, child_pipe = mp.Pipe(duplex=True) + self.streamProcess = AvStreamProcess( + daqconfig, + child_pipe, + self.in_qlist, + self.outq) + self.streamProcess.start() # Possible, but long not tested: store video + # self._video = video + # self._video_started = Atomic(False) self._videothread = None - # self._audiobackend = RtAudio(daqconfig.api) - self._daq = Daq(device, daqconfig) - self.blocksize = self._daq.nFramesPerBlock - self.samplerate = self._daq.samplerate - self.dtype = self._daq.getNumpyDataType() + self.daqconfig = daqconfig + self.streammetadata = None - def nCallbacks(self): - """Returns the current number of installed callbacks.""" - return len(self._callbacks[AvType.audio_input]) + \ - len(self._callbacks[AvType.audio_output]) + \ - len(self._callbacks[AvType.video]) + def getStreamMetaData(self): + return self.streammetadata - def addCallback(self, cb: callable, cbtype: AvType): - """Add as stream callback to the list of callbacks.""" - with self._callbacklock: - outputcallbacks = self._callbacks[AvType.audio_output] - if cbtype == AvType.audio_output and len(outputcallbacks) > 0: - raise RuntimeError( - 'Only one audio output callback can be allowed') + def getOutputQueue(self): + """ + Returns the output queue object. - if cb not in self._callbacks[cbtype]: - self._callbacks[cbtype].append(cb) + Note, should only be used by one signal generator at the time! + """ + return self.outq - def removeCallback(self, cb, cbtype: AvType): - with self._callbacklock: - if cb in self._callbacks[cbtype]: - self._callbacks[cbtype].remove(cb) + def addListener(self): + """ + Add a listener queue to the list of queues, and return the queue. + + Returns: + listener queue + """ + newqueue = self.manager.Queue() + self.in_qlist.append(newqueue) + self.in_qlist_local.append(newqueue) + return newqueue + + def removeListener(self, queue): + idx = self.in_qlist_local.index(queue) + del self.in_qlist[idx] + del self.in_qlist_local[idx] + + def nListeners(self): + """Returns the current number of installed listeners.""" + return len(self.in_qlist) def start(self): """Start the stream, which means the callbacks are called with stream data (audio/video)""" - - if self._running: - raise RuntimeError('Stream already started') - - assert self._videothread is None - - self._running <<= True - if self._video is not None: - self._videothread = Thread(target=self._videoThread) - self._videothread.start() + logging.debug('Starting stream...') + self.pipe.send((StreamMsg.startStream, None)) + msg, data = self.pipe.recv() + if msg == StreamMsg.streamStarted: + self.streammetadata = data + return data + elif msg == StreamMsg.streamError: + raise RuntimeError(data) else: - self._video_started <<= True - - self.samplerate = self._daq.start(self._audioCallback) - - def _videoThread(self): - cap = cv.VideoCapture(self._video) - if not cap.isOpened(): - cap.open() - vframectr = 0 - loopctr = 0 - while self._running: - ret, frame = cap.read() - # print(frame.shape) - if ret is True: - if vframectr == 0: - self._video_started <<= True - with self._callbacklock: - for cb in self._callbacks[AvType.video]: - cb(frame, vframectr) - vframectr += 1 - self._vframectr += 1 - else: - loopctr += 1 - if loopctr == 10: - print('Error: no video capture!') - time.sleep(0.2) - - cap.release() - print('stopped videothread') - - def _audioCallback(self, indata, outdata, nframes): - """This is called (from a separate thread) for each audio block.""" - self._aframectr += nframes - with self._callbacklock: - # Count the number of output callbacks. If no output callbacks are - # present, and there should be output callbacks, we explicitly set - # the output buffer to zero - noutput_cb = len(self._callbacks[AvType.audio_output]) - - # Loop over callbacks - if outdata is not None: - try: - if len(self._callbacks[AvType.audio_output]) == 0: - outdata[:, :] = 0 - for cb in self._callbacks[AvType.audio_output]: - cb(indata, outdata, self._aframectr()) - except Exception as e: - print(e) - return 2 - if indata is not None: - try: - for cb in self._callbacks[AvType.audio_input]: - cb(indata, outdata, self._aframectr()) - except Exception as e: - print(e) - return 1 - - return 0 if self._running else 1 + raise RuntimeError('BUG: got unexpected message: {msg}') def stop(self): - self._running <<= False + self.pipe.send((StreamMsg.stopStream, None)) + msg, data = self.pipe.recv() + if msg == StreamMsg.streamStopped: + return + elif msg == StreamMsg.streamError: + raise RuntimeError(data) + else: + raise RuntimeError('BUG: got unexpected message: {msg}') - if self._video: - self._videothread.join() - self._videothread = None + def cleanup(self): + """ + Stops the stream if it is still running, and after that, it stops the + stream process. - self._aframectr <<= 0 - self._vframectr <<= 0 - self._video_started <<= False + This method SHOULD always be called before removing a AvStream object. + Otherwise things will wait forever... - self._daq.stop() - self._daq = None - - def isRunning(self): - return self._running() + """ + self.pipe.send((StreamMsg.endProcess, None)) + logging.debug('Joining stream process...') + self.streamProcess.join() + logging.debug('Joining stream process done') def hasVideo(self): - return True if self._video is not None else False + """ + Stub, TODO: for future + """ + return False + + + def isRunning(self): + self.pipe.send((StreamMsg.getStreamMetaData, None)) + msg, data = self.pipe.recv() + if msg == StreamMsg.streamMetaData: + streamdata = data + return streamdata is not None + + elif msg == StreamMsg.streamError: + raise RuntimeError(data) + else: + raise RuntimeError('BUG: got unexpected message: {msg}') + + +# def _videoThread(self): +# cap = cv.VideoCapture(self._video) +# if not cap.isOpened(): +# cap.open() +# vframectr = 0 +# loopctr = 0 +# while self._running: +# ret, frame = cap.read() +# # print(frame.shape) +# if ret is True: +# if vframectr == 0: +# self._video_started <<= True +# with self._callbacklock: +# for cb in self._callbacks[AvType.video]: +# cb(frame, vframectr) +# vframectr += 1 +# self._vframectr += 1 +# else: +# loopctr += 1 +# if loopctr == 10: +# print('Error: no video capture!') +# time.sleep(0.2) + +# cap.release() +# print('stopped videothread') + + +# def hasVideo(self): +# return True if self._video is not None else False + diff --git a/lasp/lasp_record.py b/lasp/lasp_record.py index b208f36..336f1ab 100644 --- a/lasp/lasp_record.py +++ b/lasp/lasp_record.py @@ -3,56 +3,141 @@ """ Read data from stream and record sound and video at the same time """ -from .lasp_atomic import Atomic -from threading import Condition -from .lasp_avstream import AvType, AvStream -import h5py import dataclasses +import logging import os import time +import h5py +from .lasp_avstream import AvStream, AvType, StreamMsg + @dataclasses.dataclass class RecordStatus: curT: float done: bool + class Recording: - def __init__(self, fn: str, stream: AvStream, - rectime: float=None, wait: bool = True, + def __init__(self, fn: str, stream: AvStream, + rectime: float = None, wait: bool = True, progressCallback=None): """ + Start a recording. Blocks if wait is set to True. Args: - fn: Filename to record to. extension is added + fn: Filename to record to. Extension is automatically added if not + provided. stream: AvStream instance to record from. Should have input channels! - rectime: Recording time, None for infinite + rectime: Recording time [s], None for infinite, in seconds. If set + to None, or np.inf, the recording continues indefintely. + progressCallback: callable that is called with an instance of + RecordStatus instance as argument. """ ext = '.h5' if ext not in fn: fn += ext self._stream = stream - self.blocksize = stream.blocksize - self.samplerate = stream.samplerate - self._running = Atomic(False) - self._running_cond = Condition() + self.rectime = rectime self._fn = fn self._video_frame_positions = [] self._curT_rounded_to_seconds = 0 - self._ablockno = Atomic(0) + # Counter of the number of blocks + self._ablockno = 0 self._vframeno = 0 - self._progressCallback = progressCallback + self._progressCallback = progressCallback self._wait = wait self._f = h5py.File(self._fn, 'w') + + # This flag is used to delete the file on finish(), and can be used + # when a recording is canceled. self._deleteFile = False + f = self._f + nchannels = len(stream.input_channel_names) + + # Input queue + self.inq = stream.addListener() + + # Start the stream, if it is not running + try: + if not stream.isRunning(): + metadata = stream.start() + else: + metadata = stream.getStreamMetaData() + except: + # Cleanup stuff, something is going wrong when starting the stream + try: + f.close() + except: + pass + self.__deleteFile() + self.blocksize = metadata['blocksize'] + self.samplerate = metadata['samplerate'] + self.dtype = metadata['dtype'] + + # The 'Audio' dataset as specified in lasp_measurement, where data is + # send to. We use gzip as compression, this gives moderate a moderate + # compression to the data. + self._ad = f.create_dataset('audio', + (1, self.blocksize, nchannels), + dtype=self.dtype, + maxshape=( + None, # This means, we can add blocks + # indefinitely + self.blocksize, + nchannels), + compression='gzip' + ) + + # TODO: This piece of code is not up-to-date and should be changed at a + # later instance once we really want to record video simultaneously + # with audio. + if stream.hasVideo(): + video_x, video_y = stream.video_x, stream.video_y + self._vd = f.create_dataset('video', + (1, video_y, video_x, 3), + dtype='uint8', + maxshape=( + None, video_y, video_x, 3), + compression='gzip' + ) + + # Set the bunch of attributes + f.attrs['samplerate'] = self.samplerate + f.attrs['nchannels'] = nchannels + f.attrs['blocksize'] = self.blocksize + f.attrs['sensitivity'] = stream.input_sensitivity + f.attrs['channel_names'] = stream.input_channel_names + f.attrs['time'] = time.time() + + # Measured quantities + f.attrs['qtys'] = [qty.to_json() for qty in stream.input_qtys] + + logging.debug('Starting record....') + # TODO: Fix this later when we want video + # if stream.hasVideo(): + # stream.addCallback(self._aCallback, AvType.audio_input) + self.stop = False + + if self._wait: + logging.debug('Stop recording with CTRL-C') + try: + while not self.stop: + self.handleQueue() + time.sleep(0.01) + except KeyboardInterrupt: + logging.debug("Keyboard interrupt on record") + finally: + self.finish() + def setDelete(self, val: bool): """ Set the delete flag. If set, measurement file is deleted at the end of @@ -61,92 +146,77 @@ class Recording: """ self._deleteFile = val - def __enter__(self): + def finish(self): """ + This method should be called to finish and a close a recording file, + remove the queue from the stream, etc. - with Recording(fn, stream, wait=False): - event_loop_here() - - or: - - with Recording(fn, stream, wait=True): - pass """ - stream = self._stream - f = self._f - nchannels = len(stream.input_channel_names) + # TODO: Fix when video + # if stream.hasVideo(): + # stream.removeCallback(self._vCallback, AvType.video_input) + # self._f['video_frame_positions'] = self._video_frame_positions - self._ad = f.create_dataset('audio', - (1, stream.blocksize, nchannels), - dtype=stream.dtype, - maxshape=(None, stream.blocksize, - nchannels), - compression='gzip' - ) - if stream.hasVideo(): - video_x, video_y = stream.video_x, stream.video_y - self._vd = f.create_dataset('video', - (1, video_y, video_x, 3), - dtype='uint8', - maxshape=( - None, video_y, video_x, 3), - compression='gzip' - ) + try: + stream.removeListener(self.inq) + except Exception as e: + logging.error(f'Could not remove queue from stream: {e}') - f.attrs['samplerate'] = stream.samplerate - f.attrs['nchannels'] = nchannels - f.attrs['blocksize'] = stream.blocksize - f.attrs['sensitivity'] = stream.input_sensitivity - f.attrs['channel_names'] = stream.input_channel_names - f.attrs['time'] = time.time() - f.attrs['qtys'] = [qty.to_json() for qty in stream.input_qtys] - self._running <<= True + try: + # Close the recording file + self._f.close() + except Exception as e: + logging.error(f'Error closing file: {e}') - if not stream.isRunning(): - stream.start() - - print('Starting record....') - stream.addCallback(self._aCallback, AvType.audio_input) - if stream.hasVideo(): - stream.addCallback(self._aCallback, AvType.audio_input) - - if self._wait: - with self._running_cond: - print('Stop recording with CTRL-C') - try: - while self._running: - self._running_cond.wait() - except KeyboardInterrupt: - print("Keyboard interrupt on record") - self._running <<= False - - - def __exit__(self, type, value, traceback): - self._running <<= False - stream = self._stream - stream.removeCallback(self._aCallback, AvType.audio_input) - if stream.hasVideo(): - stream.removeCallback(self._vCallback, AvType.video_input) - self._f['video_frame_positions'] = self._video_frame_positions - - self._f.close() - print('\nEnding record') + logging.debug('Recording ended') if self._deleteFile: - try: - os.remove(self._fn) - except Exception as e: - print(f'Error deleting file: {self._fn}') + self.__deleteFile() + + def __deleteFile(self): + """ + Cleanup the recording file. + """ + try: + os.remove(self._fn) + except Exception as e: + logging.debug(f'Error deleting file: {self._fn}') + + def handleQueue(self): + """ + This method should be called to grab data from the input queue, which + is filled by the stream, and put it into a file. It should be called at + a regular interval to prevent overflowing of the queue. It is called + within the start() method of the recording, if block is set to True. + Otherwise, it should be called from its parent at regular intervals. + For example, in Qt this can be done using a QTimer. - def _aCallback(self, indata, outdata, aframe): - if indata is None: - return + """ + while self.inq.qsize() > 0: + msg, data = self.inq.get() + if msg == StreamMsg.streamData: + self.__addTimeData(data) + elif msg == StreamMsg.streamStarted: + pass + elif msg == StreamMsg.streamMetaData: + pass + else: + # An error occured, we do not remove the file, but we stop. + self.stop = True - curT = self._ablockno()*self.blocksize/self.samplerate + def __addTimeData(self, indata): + """ + Called by handleQueue() and adds new time data to the storage file. + """ + + # The current time that is recorded and stored into the file, without + # the new data + curT = self._ablockno*self.blocksize/self.samplerate recstatus = RecordStatus( - curT = curT, - done = False) + curT=curT, + done=False) + if self._progressCallback is not None: self._progressCallback(recstatus) @@ -159,22 +229,22 @@ class Recording: if self.rectime is not None and curT > self.rectime: # We are done! - self._running <<= False - with self._running_cond: - self._running_cond.notify() if self._progressCallback is not None: recstatus.done = True self._progressCallback(recstatus) + self.stop = True return - self._ad.resize(self._ablockno()+1, axis=0) - self._ad[self._ablockno(), :, :] = indata + # Add the data to the file + self._ad.resize(self._ablockno+1, axis=0) + self._ad[self._ablockno, :, :] = indata + + # Increase the block counter self._ablockno += 1 - def _vCallback(self, frame, framectr): - self._video_frame_positions.append(self._ablockno()) - vframeno = self._vframeno - self._vd.resize(vframeno+1, axis=0) - self._vd[vframeno, :, :] = frame - self._vframeno += 1 - + # def _vCallback(self, frame, framectr): + # self._video_frame_positions.append(self._ablockno()) + # vframeno = self._vframeno + # self._vd.resize(vframeno+1, axis=0) + # self._vd[vframeno, :, :] = frame + # self._vframeno += 1 diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index 9f8fcd5..fcd5e7b 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -6,11 +6,10 @@ Author: J.A. de Jong - ASCEE Description: Signal generator code """ +import multiprocessing as mp import dataclasses import logging -import multiprocessing as mp from typing import Tuple - import numpy as np from .filter import PinkNoise @@ -18,6 +17,7 @@ from .lasp_octavefilter import SosOctaveFilterBank, SosThirdOctaveFilterBank from .filter import OctaveBankDesigner, PinkNoise, ThirdOctaveBankDesigner from .lasp_avstream import AvStream, AvType from .wrappers import Siggen as pyxSiggen, Equalizer +from enum import Enum, unique, auto QUEUE_BUFFER_TIME = 0.3 # The amount of time used in the queues for buffering # of data, larger is more stable, but also enlarges latency @@ -25,26 +25,30 @@ QUEUE_BUFFER_TIME = 0.3 # The amount of time used in the queues for buffering __all__ = ["SignalType", "NoiseType", "SiggenMessage", "SiggenData", "Siggen"] -class SignalType: - Periodic = 0 - Noise = 1 - Sweep = 2 - Meas = 3 + +class SignalType(Enum): + Periodic = auto() + Noise = auto() + Sweep = auto() + Meas = auto() -class NoiseType: - white = (0, "White noise") - pink = (1, "Pink noise") - types = (white, pink) +@unique +class NoiseType(Enum): + white = "White noise" + pink = "Pink noise" + + def __str__(self): + return self.value @staticmethod def fillComboBox(combo): - for type_ in NoiseType.types: - combo.addItem(type_[1]) + for type_ in list(NoiseType): + combo.addItem(str(type_)) @staticmethod def getCurrent(cb): - return NoiseType.types[cb.currentIndex()] + return list(NoiseType)[cb.currentIndex()] class SiggenWorkerDone(Exception): @@ -52,21 +56,22 @@ class SiggenWorkerDone(Exception): return "Done generating signal" -class SiggenMessage: +@unique +class SiggenMessage(Enum): """ Different messages that can be send to the signal generator over the pipe connection. """ - stop = 0 # Stop and quit the signal generator - generate = 1 - adjustVolume = 2 # Adjust the volume - newEqSettings = 3 # Forward new equalizer settings + stop = auto() # Stop and quit the signal generator + generate = auto() + adjustVolume = auto() # Adjust the volume + newEqSettings = auto() # Forward new equalizer settings # These messages are send back to the main thread over the pipe - ready = 4 - error = 5 - done = 6 + ready = auto() + error = auto() + done = auto() @dataclasses.dataclass @@ -90,13 +95,16 @@ class SiggenData: signaltypedata: Tuple = None -def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, - dataq: mp.Queue, pipe - ): +def siggenFcn(siggendata: SiggenData, dataq: mp.Queue, pipe): """ Main function running in a different process, is responsible for generating new signal data. Uses the signal queue to push new generated signal data on. + + Args: + siggendata: The signal generator data to start with. + dataq: The queue to put generated signal on + pipe: Control and status messaging pipe """ fs = siggendata.fs nframes_per_block = siggendata.nframes_per_block @@ -106,6 +114,9 @@ def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, signaltype = siggendata.signaltype signaltypedata = siggendata.signaltypedata + nblocks_buffer = max( + 1, int(QUEUE_BUFFER_TIME * fs/ nframes_per_block) + ) def generate(siggen, eq): """ @@ -188,31 +199,31 @@ def siggenFcn(siggendata: SiggenData, nblocks_buffer: int, pipe.send((SiggenMessage.done, None)) while True: - msg, data = pipe.recv() - if msg == SiggenMessage.stop: - logging.debug("Signal generator caught 'stop' message. Exiting.") - return 0 - elif msg == SiggenMessage.generate: - # logging.debug(f"Signal generator caught 'generate' message") + if pipe.poll(timeout=QUEUE_BUFFER_TIME / 2): + msg, data = pipe.recv() + if msg == SiggenMessage.stop: + logging.debug("Signal generator caught 'stop' message. Exiting.") + return 0 + elif msg == SiggenMessage.adjustVolume: + logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") + level_dB = data + siggen.setLevel(level_dB) + elif msg == SiggenMessage.newEqSettings: + eqdata = data + eq = createEqualizer(eqdata) + else: + pipe.send( + SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" + ) + elif dataq.qsize() < nblocks_buffer: + # Generate new data and put it in the queue! try: - while dataq.qsize() < nblocks_buffer: - # Generate new data and put it in the queue! - generate(siggen, eq) + generate(siggen, eq) except SiggenWorkerDone: pipe.send(SiggenMessage.done) return 0 - elif msg == SiggenMessage.adjustVolume: - logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") - level_dB = data - siggen.setLevel(level_dB) - elif msg == SiggenMessage.newEqSettings: - eqdata = data - eq = createEqualizer(eqdata) - else: - pipe.send( - SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" - ) - return 1 + + return 1 class Siggen: @@ -221,20 +232,14 @@ class Siggen: unload the work in the calling thread. """ - def __init__(self, stream: AvStream, siggendata: SiggenData): + def __init__(self, dataq, siggendata: SiggenData): """""" - self.stream = stream - self.nblocks_buffer = max( - 1, int(QUEUE_BUFFER_TIME * stream.samplerate / stream.blocksize) - ) - qs = [mp.Queue() for i in range(3)] - self.dataq = mp.Queue() self.pipe, client_end = mp.Pipe(duplex=True) self.process = mp.Process( target=siggenFcn, - args=(siggendata, self.nblocks_buffer, self.dataq, client_end), + args=(siggendata, dataq, client_end), ) self.process.start() @@ -273,8 +278,7 @@ class Siggen: def start(self): if self.stopped: raise RuntimeError('BUG: This Siggen object cannot be used again.') - - self.stream.addCallback(self.streamCallback, AvType.audio_output) + self.handle_msgs() def stop(self): diff --git a/scripts/lasp_record b/scripts/lasp_record index 0d42279..fc64a2d 100755 --- a/scripts/lasp_record +++ b/scripts/lasp_record @@ -1,77 +1,64 @@ #!/usr/bin/python3 -import argparse -import sys - - -parser = argparse.ArgumentParser( - description='Acquire data and store a measurement file' -) -parser.add_argument('filename', type=str, - help='File name to record to.' - ' Extension is automatically added.') -parser.add_argument('--duration', '-d', type=float, - help='The recording duration in [s]') - -device_help = 'DAQ Device to record from' -parser.add_argument('--input-daq', '-i', help=device_help, type=str, - default='Default') - -args = parser.parse_args() +import sys, logging, os, argparse +logging.basicConfig(level=logging.DEBUG) +import multiprocessing +from lasp.lasp_multiprocessingpatch import apply_patch +from lasp.device import Daq, DaqChannel, DaqConfigurations from lasp.lasp_avstream import AvStream, AvType from lasp.lasp_record import Recording -from lasp.device import DaqConfiguration, Daq, DaqChannel -configs = DaqConfiguration.loadConfigs() +if __name__ == '__main__': + multiprocessing.set_start_method('forkserver', force=True) + apply_patch() -for i, (key, val) in enumerate(configs.items()): - print(f'{i:2} : {key}') + parser = argparse.ArgumentParser( + description='Acquire data and store a measurement file' + ) + parser.add_argument('filename', type=str, + help='File name to record to.' + ' Extension is automatically added.') + parser.add_argument('--duration', '-d', type=float, + help='The recording duration in [s]') -daqindex = input('Please enter required config: ') -try: - daqindex = int(daqindex) -except: - sys.exit(0) + device_help = 'DAQ Device to record from' + parser.add_argument('--input-daq', '-i', help=device_help, type=str, + default='Default') -for i, (key, val) in enumerate(configs.items()): - if i == daqindex: - config = configs[key] + args = parser.parse_args() -config = configs[key] + configs = DaqConfigurations.loadConfigs() + config_keys = [key for key in configs.keys()] + for i, key in enumerate(config_keys): + print(f'{i:2} : {key}') + choosen_index = input('Number of configuration to use: ') + try: + daqindex = int(choosen_index) + except: + sys.exit(0) -print(config) -# daq = RtAudio() -devices = Daq.getDeviceInfo() + choosen_key = config_keys[daqindex] + config = configs[choosen_key].input_config -input_devices = {} -for device in devices: - if device.inputchannels >= 0: - input_devices[device.name] = device + config.__reduce__() -try: - input_device = input_devices[config.input_device_name] -except KeyError: - raise RuntimeError(f'Input device {config.input_device_name} not available') + print(f'Choosen configuration: {choosen_key}') -print(input_device) + try: + stream = AvStream( + AvType.audio_input, + config) + # stream.start() + rec = Recording(args.filename, stream, args.duration) + # input('Stream started, press any key to start record') + finally: + try: + stream.cleanup() + del stream + except NameError: + pass -stream = AvStream(input_device, - AvType.audio_input, - config) - - -rec = Recording(args.filename, stream, args.duration) -stream.start() -with rec: - pass - -print('Stopping stream...') -stream.stop() - -print('Stream stopped') -print('Closing stream...') -print('Stream closed') From 466a6f5cc140f00a8d06e5926eaa7e1f82b2f7f1 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Wed, 5 May 2021 19:48:04 +0200 Subject: [PATCH 05/28] Stream and recording seems to work. Also signal generator seems to work. Error handling is not working properly yet. --- lasp/device/lasp_daq.pyx | 2 +- lasp/lasp_avstream.py | 12 +++ lasp/lasp_siggen.py | 205 ++++++++++++++++++--------------------- scripts/lasp_record | 3 +- scripts/lasp_siggen | 121 ++++++++++++----------- 5 files changed, 175 insertions(+), 168 deletions(-) diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index fdeabe9..5529dee 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -82,7 +82,7 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: callback = sd.pyCallback # print(f'Number of input channels: {ninchannels}') # print(f'Number of out channels: {noutchannels}') - fprintf(stderr, 'Sleep time: %d us', sleeptime_us) + fprintf(stderr, 'Sleep time: %d us\n', sleeptime_us) while not sd.stopThread.load(): with gil: diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 82e2688..110e7ed 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -169,9 +169,11 @@ class AvStreamProcess(mp.Process): def streamCallback(self, indata, outdata, nframes): """This is called (from a separate thread) for each audio block.""" + # logging.debug('streamCallback()') self.aframectr += nframes if self.siggen_activated: + # logging.debug('siggen_activated') if self.outq.empty(): outdata[:, :] = 0 msgtxt = 'Output signal buffer underflow' @@ -182,6 +184,7 @@ class AvStreamProcess(mp.Process): if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: self.pipe.send(StreamMsg.streamFatalError, 'Invalid output data obtained from queue') return 1 + outdata[:, :] = newdata[:, np.newaxis] if indata is not None: self.putAllInQueues(StreamMsg.streamData, indata) @@ -283,6 +286,15 @@ class AvStream: """ return self.outq + def activateSiggen(self): + logging.debug('activateSiggen()') + self.pipe.send((StreamMsg.activateSiggen, None)) + + def deactivateSiggen(self): + logging.debug('activateSiggen()') + self.pipe.send((StreamMsg.deactivateSiggen, None)) + + def addListener(self): """ Add a listener queue to the list of queues, and return the queue. diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index fcd5e7b..ce776f2 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -62,14 +62,11 @@ class SiggenMessage(Enum): Different messages that can be send to the signal generator over the pipe connection. """ - stop = auto() # Stop and quit the signal generator - generate = auto() adjustVolume = auto() # Adjust the volume newEqSettings = auto() # Forward new equalizer settings # These messages are send back to the main thread over the pipe - ready = auto() error = auto() done = auto() @@ -95,71 +92,45 @@ class SiggenData: signaltypedata: Tuple = None -def siggenFcn(siggendata: SiggenData, dataq: mp.Queue, pipe): +class SiggenProcess(mp.Process): """ Main function running in a different process, is responsible for generating new signal data. Uses the signal queue to push new generated signal data on. - - Args: - siggendata: The signal generator data to start with. - dataq: The queue to put generated signal on - pipe: Control and status messaging pipe """ - fs = siggendata.fs - nframes_per_block = siggendata.nframes_per_block - level_dB = siggendata.level_dB - dtype = siggendata.dtype + def __init__(self, siggendata, dataq, pipe): - signaltype = siggendata.signaltype - signaltypedata = siggendata.signaltypedata - - nblocks_buffer = max( - 1, int(QUEUE_BUFFER_TIME * fs/ nframes_per_block) - ) - - def generate(siggen, eq): """ - Generate a single block of data - """ - signal = siggen.genSignal(nframes_per_block) - if eq is not None: - signal = eq.equalize(signal) - if np.issubdtype((dtype := siggendata.dtype), np.integer): - bitdepth_fixed = dtype.itemsize * 8 - signal *= 2 ** (bitdepth_fixed - 1) - 1 - dataq.put(signal.astype(dtype)) - - def createEqualizer(eqdata): - """ - Create an equalizer object from equalizer data - Args: - eqdata: dictionary containing equalizer data. TODO: document the - requiring fields. + siggendata: The signal generator data to start with. + dataq: The queue to put generated signal on + pipe: Control and status messaging pipe """ - if eqdata is None: - return None - eq_type = eqdata['type'] - eq_levels = eqdata['levels'] - if eq_type == 'three': - fb = SosThirdOctaveFilterBank(fs) - elif eq_type == 'one': - fb = SosOctaveFilterBank(fs) + self.dataq = dataq + self.siggendata = siggendata + self.pipe = pipe + self.eq = None + self.siggen = None - eq = Equalizer(fb._fb) - if eq_levels is not None: - eq.setLevels(eq_levels) - return eq + fs = self.siggendata.fs + nframes_per_block = siggendata.nframes_per_block + self.nblocks_buffer = max( + 1, int(QUEUE_BUFFER_TIME * fs/ nframes_per_block) + ) + super().__init__() - eq = createEqualizer(siggendata.eqdata) - - def newSiggen(): + def newSiggen(self, siggendata): """ Create a signal generator based on parameters specified in global function data. """ + fs = siggendata.fs + nframes_per_block = siggendata.nframes_per_block + level_dB = siggendata.level_dB + signaltype = siggendata.signaltype + signaltypedata = siggendata.signaltypedata + if signaltype == SignalType.Periodic: freq, = signaltypedata siggen = pyxSiggen.sineWave(fs, freq, level_dB) @@ -181,47 +152,82 @@ def siggenFcn(siggendata: SiggenData, dataq: mp.Queue, pipe): else: raise ValueError(f"Not implemented signal type: {signaltype}") - # Pre-generate blocks of signal data - while dataq.qsize() < nblocks_buffer: - generate(siggen, eq) - return siggen - # Initialization - try: - siggen = newSiggen() + def generate(self): + """ + Generate a single block of data and put it on the data queue + """ + signal = self.siggen.genSignal(self.siggendata.nframes_per_block) + dtype = self.siggendata.dtype + if self.eq is not None: + signal = self.eq.equalize(signal) + if np.issubdtype(dtype, np.integer): + bitdepth_fixed = dtype.itemsize * 8 + signal *= 2 ** (bitdepth_fixed - 1) - 1 + self.dataq.put(signal.astype(dtype)) - except Exception as e: - pipe.send((SiggenMessage.error, str(e))) - return 1 + def newEqualizer(self, eqdata): + """ + Create an equalizer object from equalizer data - finally: - pipe.send((SiggenMessage.done, None)) + Args: + eqdata: dictionary containing equalizer data. TODO: document the + requiring fields. + """ + if eqdata is None: + return None + eq_type = eqdata['type'] + eq_levels = eqdata['levels'] + fs = self.siggendata.fs - while True: - if pipe.poll(timeout=QUEUE_BUFFER_TIME / 2): - msg, data = pipe.recv() - if msg == SiggenMessage.stop: - logging.debug("Signal generator caught 'stop' message. Exiting.") - return 0 - elif msg == SiggenMessage.adjustVolume: - logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") - level_dB = data - siggen.setLevel(level_dB) - elif msg == SiggenMessage.newEqSettings: - eqdata = data - eq = createEqualizer(eqdata) - else: - pipe.send( - SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" - ) - elif dataq.qsize() < nblocks_buffer: - # Generate new data and put it in the queue! - try: - generate(siggen, eq) - except SiggenWorkerDone: - pipe.send(SiggenMessage.done) - return 0 + if eq_type == 'three': + fb = SosThirdOctaveFilterBank(fs) + elif eq_type == 'one': + fb = SosOctaveFilterBank(fs) + + eq = Equalizer(fb._fb) + if eq_levels is not None: + eq.setLevels(eq_levels) + return eq + + def run(self): + # Initialization + try: + self.siggen = self.newSiggen(self.siggendata) + self.eq = self.newEqualizer(self.siggendata.eqdata) + except Exception as e: + self.pipe.send((SiggenMessage.error, str(e))) + return 1 + + # Pre-generate blocks of signal data + while self.dataq.qsize() < self.nblocks_buffer: + self.generate() + + while True: + if self.pipe.poll(timeout=QUEUE_BUFFER_TIME / 2): + msg, data = self.pipe.recv() + if msg == SiggenMessage.stop: + logging.debug("Signal generator caught 'stop' message. Exiting.") + return 0 + elif msg == SiggenMessage.adjustVolume: + logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") + level_dB = data + self.siggen.setLevel(level_dB) + elif msg == SiggenMessage.newEqSettings: + eqdata = data + eq = self.newEqualizer(eqdata) + else: + self.pipe.send( + SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" + ) + while self.dataq.qsize() < self.nblocks_buffer: + # Generate new data and put it in the queue! + try: + self.generate() + except SiggenWorkerDone: + self.pipe.send(SiggenMessage.done) + return 0 return 1 @@ -237,10 +243,7 @@ class Siggen: self.pipe, client_end = mp.Pipe(duplex=True) - self.process = mp.Process( - target=siggenFcn, - args=(siggendata, dataq, client_end), - ) + self.process = SiggenProcess(siggendata, dataq, client_end) self.process.start() self.handle_msgs() @@ -285,9 +288,6 @@ class Siggen: logging.debug('Siggen::stop()') if self.stopped: raise RuntimeError('BUG: Siggen::stop() is called twice!') - self.stream.removeCallback(self.streamCallback, AvType.audio_output) - while not self.dataq.empty(): - self.dataq.get() self.pipe.send((SiggenMessage.stop, None)) self.pipe.close() @@ -300,18 +300,3 @@ class Siggen: logging.debug('End Siggen::stop()') self.stopped = True - def streamCallback(self, indata, outdata, blockctr): - """Callback from AvStream. - - Copy generated signal from queue - """ - # logging.debug('Siggen::streamCallback()') - assert outdata is not None - if not self.dataq.empty(): - outdata[:, :] = self.dataq.get()[:, np.newaxis] - else: - logging.warning("Signal generator queue empty!") - outdata[:, :] = 0 - - if self.dataq.qsize() < self.nblocks_buffer: - self.pipe.send((SiggenMessage.generate, None)) diff --git a/scripts/lasp_record b/scripts/lasp_record index fc64a2d..7f6f8fc 100755 --- a/scripts/lasp_record +++ b/scripts/lasp_record @@ -4,8 +4,7 @@ logging.basicConfig(level=logging.DEBUG) import multiprocessing from lasp.lasp_multiprocessingpatch import apply_patch - -from lasp.device import Daq, DaqChannel, DaqConfigurations +from lasp.device import DaqConfigurations from lasp.lasp_avstream import AvStream, AvType from lasp.lasp_record import Recording diff --git a/scripts/lasp_siggen b/scripts/lasp_siggen index c3b1524..eca1f68 100755 --- a/scripts/lasp_siggen +++ b/scripts/lasp_siggen @@ -1,65 +1,76 @@ #!/usr/bin/python3 import argparse import numpy as np - - -parser = argparse.ArgumentParser( - description='Play a sine wave' -) -device_help = 'DAQ Device to play to' -parser.add_argument('--device', '-d', help=device_help, type=str, - default='Default') - -args = parser.parse_args() - +import sys, logging, os, argparse +logging.basicConfig(level=logging.DEBUG) +import multiprocessing +from lasp.lasp_multiprocessingpatch import apply_patch from lasp.lasp_avstream import AvStream, AvType -from lasp.device import DAQConfiguration, RtAudio - -config = DAQConfiguration.loadConfigs()[args.device] - -rtaudio = RtAudio() -devices = rtaudio.getDeviceInfo() - -output_devices = {} -for device in devices: - if device.outputchannels >= 0: - output_devices[device.name] = device - -try: - output_device = output_devices[config.output_device_name] -except KeyError: - raise RuntimeError(f'output device {config.output_device_name} not available') - -samplerate = int(config.en_output_rate) -stream = AvStream(output_device, - AvType.audio_output, - config) - -# freq = 440. -freq = 1000. -omg = 2*np.pi*freq +from lasp.lasp_siggen import Siggen, SignalType, SiggenData +from lasp.device import DaqConfigurations -def mycallback(indata, outdata, blockctr): - frames = outdata.shape[0] - nchannels = outdata.shape[1] - # nchannels = 1 - streamtime = blockctr*frames/samplerate - t = np.linspace(streamtime, streamtime + frames/samplerate, - frames)[np.newaxis, :] - outp = 0.01*np.sin(omg*t) - for i in range(nchannels): - outdata[:,i] = ((2**16-1)*outp).astype(np.int16) +if __name__ == '__main__': + multiprocessing.set_start_method('forkserver', force=True) + parser = argparse.ArgumentParser( + description='Play a sine wave' + ) + device_help = 'DAQ Device to play to' + parser.add_argument('--device', '-d', help=device_help, type=str, + default='Default') -stream.addCallback(mycallback, AvType.audio_output) -stream.start() + args = parser.parse_args() -input() -print('Stopping stream...') -stream.stop() + configs = DaqConfigurations.loadConfigs() + + config_keys = [key for key in configs.keys()] + for i, key in enumerate(config_keys): + print(f'{i:2} : {key}') + + choosen_index = input('Number of configuration to use: ') + try: + daqindex = int(choosen_index) + except: + sys.exit(0) + + + choosen_key = config_keys[daqindex] + config = configs[choosen_key].output_config + + print(f'Choosen configuration: {choosen_key}') + + + + try: + siggendata = SiggenData( + fs=48e3, + nframes_per_block=1024, + dtype=np.dtype(np.int16), + eqdata=None, + level_dB=-20, + signaltype=SignalType.Periodic, + signaltypedata=(1000.,) + ) + + stream = AvStream( + AvType.audio_output, + config) + + outq = stream.getOutputQueue() + stream.activateSiggen() + siggen = Siggen(outq, siggendata) + + stream.start() + input('Press any key to stop...') + stream.stop() + siggen.stop() + + finally: + try: + stream.cleanup() + del stream + except NameError: + pass + -print('Stream stopped') -print('Closing stream...') -stream.close() -print('Stream closed') From ff5afb8eb1bc51422ad0dfc9bc6ce8379498af75 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Fri, 7 May 2021 22:48:02 +0200 Subject: [PATCH 06/28] Added pickling functionality for deviceInfo C++ object. Ugly but working implementation. --- lasp/device/lasp_common_decls.pxd | 3 + lasp/device/lasp_cppdaq.h | 238 ++++++++++++++++++++++-------- lasp/device/lasp_deviceinfo.pyx | 7 + 3 files changed, 183 insertions(+), 65 deletions(-) diff --git a/lasp/device/lasp_common_decls.pxd b/lasp/device/lasp_common_decls.pxd index 144b418..8152470 100644 --- a/lasp/device/lasp_common_decls.pxd +++ b/lasp/device/lasp_common_decls.pxd @@ -87,6 +87,9 @@ cdef extern from "lasp_cppdaq.h" nogil: unsigned ninchannels unsigned noutchannels + string serialize() + + cppDeviceInfo deserialize(string) bool hasInputIEPE bool hasInputACCouplingSwitch bool hasInputTrigger diff --git a/lasp/device/lasp_cppdaq.h b/lasp/device/lasp_cppdaq.h index 29eaa44..ea4d250 100644 --- a/lasp/device/lasp_cppdaq.h +++ b/lasp/device/lasp_cppdaq.h @@ -19,6 +19,7 @@ using std::cerr; using std::cout; using std::endl; +using std::getline; using std::runtime_error; using std::string; using std::vector; @@ -78,106 +79,213 @@ const DaqApi uldaqapi("UlDaq", 0); #ifdef HAS_RTAUDIO_API const DaqApi rtaudioAlsaApi("RtAudio Linux ALSA", 1, RtAudio::Api::LINUX_ALSA); const DaqApi rtaudioPulseaudioApi("RtAudio Linux Pulseaudio", 2, - RtAudio::Api::LINUX_PULSE); + RtAudio::Api::LINUX_PULSE); const DaqApi rtaudioWasapiApi("RtAudio Windows Wasapi", 3, - RtAudio::Api::WINDOWS_WASAPI); + RtAudio::Api::WINDOWS_WASAPI); const DaqApi rtaudioDsApi("RtAudio Windows DirectSound", 4, - RtAudio::Api::WINDOWS_DS); + RtAudio::Api::WINDOWS_DS); const DaqApi rtaudioAsioApi("RtAudio Windows ASIO", 5, - RtAudio::Api::WINDOWS_ASIO); + RtAudio::Api::WINDOWS_ASIO); #endif // Structure containing device info parameters class DeviceInfo { -public: - DaqApi api; - string device_name = ""; + public: + DaqApi api; + string device_name = ""; - int api_specific_devindex = -1; + int api_specific_devindex = -1; - vector availableDataTypes; - int prefDataTypeIndex = 0; + vector availableDataTypes; + int prefDataTypeIndex = 0; - vector availableSampleRates; - int prefSampleRateIndex = -1; + vector availableSampleRates; + int prefSampleRateIndex = -1; - vector availableFramesPerBlock; - unsigned prefFramesPerBlockIndex = 0; + vector availableFramesPerBlock; + unsigned prefFramesPerBlockIndex = 0; - dvec availableInputRanges; - int prefInputRangeIndex = 0; + dvec availableInputRanges; + int prefInputRangeIndex = 0; - unsigned ninchannels = 0; - unsigned noutchannels = 0; + unsigned ninchannels = 0; + unsigned noutchannels = 0; - bool hasInputIEPE = false; - bool hasInputACCouplingSwitch = false; - bool hasInputTrigger = false; + bool hasInputIEPE = false; + bool hasInputACCouplingSwitch = false; + bool hasInputTrigger = false; - /* DeviceInfo(): */ - /* datatype(dtype_invalid) { } */ + /* DeviceInfo(): */ + /* datatype(dtype_invalid) { } */ - double prefSampleRate() const { - if (((us)prefSampleRateIndex < availableSampleRates.size()) && - (prefSampleRateIndex >= 0)) { - return availableSampleRates.at(prefSampleRateIndex); - } else { - throw std::runtime_error("No prefered sample rate available"); + double prefSampleRate() const { + if (((us)prefSampleRateIndex < availableSampleRates.size()) && + (prefSampleRateIndex >= 0)) { + return availableSampleRates.at(prefSampleRateIndex); + } else { + throw std::runtime_error("No prefered sample rate available"); + } } - } - operator string() const { - std::stringstream str; - str << api.apiname + " " << api_specific_devindex + operator string() const { + std::stringstream str; + str << api.apiname + " " << api_specific_devindex << " number of input channels: " << ninchannels << " number of output channels: " << noutchannels; - return str.str(); - } + return str.str(); + } + + string serialize() const { + // Simple serializer for this object, used because we found a bit late that + // this object needs to be send over the wire. We do not want to make this + // implementation in Python, as these objects are created here, in the C++ + // code. The Python wrapper is just a readonly wrapper. + std::stringstream str; + + str << api.apiname << "\t"; + str << api.apicode << "\t"; + str << api.api_specific_subcode << "\t"; + str << device_name << "\t"; + + str << availableDataTypes.size() << "\t"; + for(const DataType& dtype: availableDataTypes) { + // WARNING: THIS GOES COMPLETELY WRONG WHEN NAMES contain A TAB!!! + str << dtype.name << "\t"; + str << dtype.sw << "\t"; + str << dtype.is_floating << "\t"; + } + str << prefDataTypeIndex << "\t"; + + str << availableSampleRates.size() << "\t"; + for(const double& fs: availableSampleRates) { + // WARNING: THIS GOES COMPLETELY WRONG WHEN NAMES contain A TAB!!! + str << fs << "\t"; + } + str << prefSampleRateIndex << "\t"; + + str << availableFramesPerBlock.size() << "\t"; + for(const us& fb: availableFramesPerBlock) { + // WARNING: THIS GOES COMPLETELY WRONG WHEN NAMES contain A TAB!!! + str << fb << "\t"; + } + str << prefFramesPerBlockIndex << "\t"; + + str << availableInputRanges.size() << "\t"; + for(const double& fs: availableInputRanges) { + // WARNING: THIS GOES COMPLETELY WRONG WHEN NAMES contain A TAB!!! + str << fs << "\t"; + } + str << prefInputRangeIndex << "\t"; + + str << ninchannels << "\t"; + str << noutchannels << "\t"; + str << int(hasInputIEPE) << "\t"; + str << int(hasInputACCouplingSwitch) << "\t"; + str << int(hasInputTrigger) << "\t"; + + return str.str(); + } + + static DeviceInfo deserialize(const string& dstr) { + DeviceInfo devinfo; + + std::stringstream str(dstr); + string tmp; + us N; + auto nexts = [&]() { getline(str, tmp, '\t'); return tmp; }; + auto nexti = [&]() { getline(str, tmp, '\t'); return std::atoi(tmp.c_str()); }; + auto nextf = [&]() { getline(str, tmp, '\t'); return std::atof(tmp.c_str()); }; + + // Api + string apiname = nexts(); + auto apicode = nexti(); + auto api_specific_subcode = nexti(); + DaqApi api(apiname, apicode, api_specific_subcode); + devinfo.api = api; + + devinfo.device_name = nexts(); + + N = us(nexti()); + for(us i=0;i inchannel_sensitivities; - vector inchannel_names; - vector inchannel_metadata; + vector inchannel_sensitivities; + vector inchannel_names; + vector inchannel_metadata; - vector outchannel_sensitivities; - vector outchannel_names; - vector outchannel_metadata; + vector outchannel_sensitivities; + vector outchannel_names; + vector outchannel_metadata; - us sampleRateIndex = 0; // Index in list of sample rates + us sampleRateIndex = 0; // Index in list of sample rates - us dataTypeIndex = 0; // Required datatype for output, should be - // present in the list + us dataTypeIndex = 0; // Required datatype for output, should be + // present in the list - us framesPerBlockIndex = 0; + us framesPerBlockIndex = 0; - bool monitorOutput = false; + bool monitorOutput = false; - boolvec inputIEPEEnabled; - boolvec inputACCouplingMode; + boolvec inputIEPEEnabled; + boolvec inputACCouplingMode; - usvec inputRangeIndices; + usvec inputRangeIndices; - // Create a default configuration, with all channels disabled on both - // input and output, and default channel names - DaqConfiguration(const DeviceInfo &device); - DaqConfiguration() {} + // Create a default configuration, with all channels disabled on both + // input and output, and default channel names + DaqConfiguration(const DeviceInfo &device); + DaqConfiguration() {} - bool match(const DeviceInfo &devinfo) const; + bool match(const DeviceInfo &devinfo) const; - int getHighestInChannel() const; - int getHighestOutChannel() const; + int getHighestInChannel() const; + int getHighestOutChannel() const; - int getLowestInChannel() const; - int getLowestOutChannel() const; + int getLowestInChannel() const; + int getLowestOutChannel() const; }; class Daq; @@ -185,7 +293,7 @@ class Daq : public DaqConfiguration, public DeviceInfo { mutable std::mutex mutex; -public: + public: static vector getDeviceInfo(); static Daq *createDaq(const DeviceInfo &, const DaqConfiguration &config); @@ -193,7 +301,7 @@ public: Daq(const DeviceInfo &devinfo, const DaqConfiguration &config); virtual void start(SafeQueue *inqueue, - SafeQueue *outqueue) = 0; + SafeQueue *outqueue) = 0; virtual void stop() = 0; diff --git a/lasp/device/lasp_deviceinfo.pyx b/lasp/device/lasp_deviceinfo.pyx index c5e991b..38454a0 100644 --- a/lasp/device/lasp_deviceinfo.pyx +++ b/lasp/device/lasp_deviceinfo.pyx @@ -9,6 +9,10 @@ DeviceInfo C++ object wrapper """ __all__ = ['DeviceInfo'] +def pickle(dat): + dev = DeviceInfo() + dev.devinfo.deserialize(dat) + return dev cdef class DeviceInfo: def __cinit__(self): @@ -17,6 +21,9 @@ cdef class DeviceInfo: def __init__(self): pass + def __reduce__(self): + return (pickle, (self.devinfo.serialize(),)) + @property def api(self): return self.devinfo.api.apiname.decode('utf-8') From ee888891d912b88e5e003a40bedd5ee12e893195 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Fri, 7 May 2021 22:50:03 +0200 Subject: [PATCH 07/28] Bugfix in lasp_daq.pyx. This was the reason of the initial tick in the audio. The CppSleep was done with the GIL held. Sometimes indentation is what you need in PYthon... --- lasp/device/lasp_daq.pyx | 4 ++-- lasp/device/lasp_daqconfig.pyx | 2 ++ scripts/lasp_record | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index 5529dee..36170d7 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -263,8 +263,8 @@ cdef class Daq: self.sd.thread = new CPPThread[void*, void (*)(void*)](audioCallbackPythonThreadFunction, self.sd) - # Allow stream stome time to start - CPPsleep_ms(500) + # Allow stream stome time to start + CPPsleep_ms(300) self.daq_device.start( self.sd.inQueue, diff --git a/lasp/device/lasp_daqconfig.pyx b/lasp/device/lasp_daqconfig.pyx index a3cad81..5ffea08 100644 --- a/lasp/device/lasp_daqconfig.pyx +++ b/lasp/device/lasp_daqconfig.pyx @@ -75,6 +75,8 @@ cdef class DaqConfiguration: """ Initialize a device descriptor """ + def __cinit__(self): + pass def __str__(self): return str(self.to_json()) diff --git a/scripts/lasp_record b/scripts/lasp_record index 7f6f8fc..c2fa06e 100755 --- a/scripts/lasp_record +++ b/scripts/lasp_record @@ -43,8 +43,6 @@ if __name__ == '__main__': choosen_key = config_keys[daqindex] config = configs[choosen_key].input_config - config.__reduce__() - print(f'Choosen configuration: {choosen_key}') try: From e72e9154aa280812131cd9567d5704d7c6fa84c6 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Fri, 7 May 2021 22:53:29 +0200 Subject: [PATCH 08/28] StreamManager is new frontend to all DAQ. --- lasp/device/lasp_daq.pyx | 2 +- lasp/lasp_avstream.py | 549 ++++++++++++++++++++++++--------------- lasp/lasp_common.py | 15 +- lasp/lasp_record.py | 5 +- lasp/lasp_siggen.py | 73 +++--- scripts/lasp_siggen | 35 +-- 6 files changed, 420 insertions(+), 259 deletions(-) diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index 36170d7..42cb7ca 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -1,10 +1,10 @@ cimport cython +from ..lasp_common import AvType from .lasp_deviceinfo cimport DeviceInfo from .lasp_daqconfig cimport DaqConfiguration from cpython.ref cimport PyObject,Py_INCREF, Py_DECREF import numpy as np -from ..lasp_common import AvType __all__ = ['Daq'] diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 110e7ed..d43fe96 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -2,26 +2,50 @@ """ Author: J.A. de Jong -Description: Read data from image stream and record sound at the same time +Description: Controlling an audio stream in a different process. """ #import cv2 as cv import multiprocessing as mp -import signal +import time, logging, signal +import numpy as np +from enum import unique, Enum, auto +from dataclasses import dataclass from .lasp_multiprocessingpatch import apply_patch apply_patch() from .lasp_atomic import Atomic from .lasp_common import AvType -from .device import (Daq, DeviceInfo, DaqConfiguration) -from threading import Thread, Lock -import numpy as np -import time, logging -from enum import unique, Enum, auto +from .device import (Daq, DeviceInfo, DaqConfiguration, DaqChannel) +from typing import List -__all__ = ['AvStream'] +__all__ = ['StreamManager', 'ignoreSigInt'] + + +def ignoreSigInt(): + """ + Ignore sigint signal. Should be set on all processes to let the main + process control this signal. + """ + signal.signal(signal.SIGINT, signal.SIG_IGN) + +@dataclass +class StreamMetaData: + # Sample rate [Hz] + fs: float + + # Input channels + in_ch: List[DaqChannel] + + # Output channels + out_ch: List[DaqChannel] + + # blocksize + blocksize: int + + # The data type of input and output blocks. + dtype: np.dtype -video_x, video_y = 640, 480 @unique class StreamMsg(Enum): @@ -30,17 +54,19 @@ class StreamMsg(Enum): """ startStream = auto() stopStream = auto() + stopAllStreams = auto() getStreamMetaData = auto() endProcess = auto() + scanDaqDevices = auto() activateSiggen = auto() deactivateSiggen = auto() - """ Second part, status messages that are send back on all listeners """ # "Normal messages" + deviceList = auto() streamStarted = auto() streamStopped = auto() streamMetaData = auto() @@ -51,10 +77,83 @@ class StreamMsg(Enum): streamFatalError = auto() +class AudioStream: + """ + Audio stream. + """ + def __init__(self, + avtype: AvType, + devices: list, + daqconfig: DaqConfiguration, + processCallback: callable): + """ + Initializes the audio stream and tries to start it. + + avtype: AvType + devices: List of device information + daqconfig: DaqConfiguration to used to generate audio stream backend + processCallback: callback function that will be called from a different + thread, with arguments (AudioStream, in + """ + + self.running = Atomic(False) + self.aframectr = Atomic(0) + self.avtype = avtype + self.siggen_activated = Atomic(False) + + api_devices = devices[daqconfig.api] + self.processCallback = processCallback + + matching_devices = [ + device for device in api_devices if + device.name == daqconfig.device_name] + + if len(matching_devices) == 0: + raise RuntimeError('Could not find device {daqconfig.device_name}') + + # TODO: We pick te first one, what to do if we have multiple matches? + # Is that even possible? + device = matching_devices[0] + + self.daq = Daq(device, daqconfig) + en_in_ch = daqconfig.getEnabledInChannels(include_monitor=True) + en_out_ch = daqconfig.getEnabledOutChannels() + + samplerate = self.daq.start(self.streamCallback) + self.streammetadata = StreamMetaData( + fs = samplerate, + in_ch = daqconfig.getEnabledInChannels(), + out_ch = daqconfig.getEnabledOutChannels(), + blocksize = self.daq.nFramesPerBlock, + dtype = self.daq.getNumpyDataType() + ) + + + def streamCallback(self, indata, outdata, nframes): + """ + This is called (from a separate thread) for each block + of audio data. + """ + return self.processCallback(self, indata, outdata) + + def stop(self): + """ + Stop the DAQ stream. Should be called only once. + """ + daq = self.daq + self.daq = None + self.running <<= False + daq.stop() + + self.streammetadata = None + class AvStreamProcess(mp.Process): + """ + Different process on which all audio streams are running + """ - def __init__(self, daqconfig: DaqConfiguration, + def __init__(self, pipe, in_qlist, outq): """ @@ -62,13 +161,15 @@ class AvStreamProcess(mp.Process): device: DeviceInfo """ - self.daqconfig = daqconfig self.pipe = pipe self.in_qlist = in_qlist self.outq = outq - self.aframectr = 0 - self.daq = None - self.streamdata = None + + self.devices = {} + self.daqconfigs = None + + # In, out, duplex + self.streams = {t: None for t in list(AvType)} super().__init__() @@ -76,122 +177,175 @@ class AvStreamProcess(mp.Process): """ The actual function running in a different process. """ + # First things first, ignore interrupt signals # https://stackoverflow.com/questions/21104997/keyboard-interrupt-with-pythons-multiprocessing signal.signal(signal.SIGINT, signal.SIG_IGN) + # Check for devices + self.rescanDaqDevices() self.siggen_activated = Atomic(False) - self.running = Atomic(False) - self.aframectr = Atomic(0) - daqconfig = self.daqconfig - devices = Daq.getDeviceInfo() - api_devices = devices[daqconfig.api] - - matching_devices = [ - device for device in api_devices if device.name == daqconfig.device_name] - - if len(matching_devices) == 0: - self.pipe.send((StreamMsg.streamFatalError, f"Device {daqconfig.device_name} not available")) - - self.device = matching_devices[0] - # logging.debug(self.device) - # logging.debug(self.daqconfig) while True: msg, data = self.pipe.recv() logging.debug(f'Obtained message {msg}') if msg == StreamMsg.activateSiggen: self.siggen_activated <<= True + elif msg == StreamMsg.deactivateSiggen: self.siggen_activated <<= False + elif msg == StreamMsg.scanDaqDevices: + self.rescanDaqDevices() + + elif msg == StreamMsg.stopAllStreams: + self.stopAllStreams() + elif msg == StreamMsg.endProcess: - if self.streamdata is not None and self.running: - logging.error('Process exit while stream is still running') + self.stopAllStreams() + # and.. exit! return elif msg == StreamMsg.getStreamMetaData: - self.pipe.send((StreamMsg.streamMetaData, self.streamdata)) - for q in self.in_qlist: - q.put((StreamMsg.streamMetaData, self.streamdata)) + avtype = data + stream = self.streams[avtype] + if stream is not None: + self.sendPipe(StreamMsg.streamMetaData, avtype, stream.streammetadata) + else: + self.sendPipe(StreamMsg.streamMetaData, avtype, None) elif msg == StreamMsg.startStream: - self.startStream() + avtype, daqconfig = data + self.startStream(avtype, daqconfig) + elif msg == StreamMsg.stopStream: - self.stopStream() + avtype, = data + self.stopStream(avtype) + + def startStream(self, avtype: AvType, daqconfig: DaqConfiguration): + """ + Start a stream, based on type and configuration + + """ + self.stopRequiredExistingStreams(avtype) + try: + stream = AudioStream(avtype, + self.devices, + daqconfig, self.streamCallback) + self.streams[avtype] = stream + + except Exception as e: + self.sendPipeAndAllQueues(StreamMsg.streamError, avtype, "Error starting stream {str(e)}") + return + + self.sendPipeAndAllQueues(StreamMsg.streamStarted, avtype, stream.streammetadata) + + def stopStream(self, avtype: AvType): + """ + Stop an existing stream, and sets the attribute in the list of streams + to None + + Args: + stream: AudioStream instance + """ + stream = self.streams[avtype] + if stream is not None: + try: + stream.stop() + self.sendPipeAndAllQueues(StreamMsg.streamStopped, stream.avtype) + except Exception as e: + self.sendPipeAndAllQueues(StreamMsg.streamError, stream.avtype, "Error occured in stopping stream: {str(e)}") + self.streams[avtype] = None + + + def stopRequiredExistingStreams(self, avtype: AvType): + """ + Stop all existing streams that conflict with the current avtype + """ + if avtype == AvType.audio_input: + # For a new input, duplex and input needs to be stopped + stream_to_stop = (AvType.audio_input, AvType.audio_duplex) + elif avtype == AvType.audio_output: + # For a new output, duplex and output needs to be stopped + stream_to_stop = (AvType.audio_output, AvType.audio_duplex) + elif avtype == AvType.audio_duplex: + # All others have to stop + stream_to_stop = list(AvType) # All of them + else: + raise ValueError('BUG') + + for stream in stream_to_stop: + if stream is not None: + self.stopStream(stream) - def startStream(self): + def stopAllStreams(self): """ - Start the DAQ stream. + Stops all streams """ - if self.daq is not None: - self.pipe.send((StreamMsg.streamError, 'Stream has already been started')) + for key in self.streams.keys(): + self.stopStream(key) + + def isStreamRunning(self, avtype: AvType = None): + """ + Check whether a stream is running + + Args: + avtype: The stream type to check whether it is still running. If + None, it checks all streams. + + Returns: + True if a stream is running, otherwise false + """ + if avtype is None: + avtype = list(AvType) + else: + avtype = (avtype,) + for t in avtype: + if self.streams[t] is not None: + return True + + return False + + def rescanDaqDevices(self): + """ + Rescan the available DaQ devices. + + """ + if self.isStreamRunning(): + self.sendPipe(StreamMsg.streamError, None, "A stream is running, cannot rescan DAQ devices.") return - try: - self.daq = Daq(self.device, - self.daqconfig) - samplerate = self.daq.start(self.streamCallback) - streamdata = { - 'blocksize': self.daq.nFramesPerBlock, - 'samplerate': samplerate, - 'dtype': self.daq.getNumpyDataType(), - } - - self.streamdata = streamdata - self.pipe.send((StreamMsg.streamStarted, streamdata)) - self.putAllInQueues(StreamMsg.streamStarted, streamdata) - except Exception as e: - logging.debug(f'Error starting stream: {e}') - self.daq = None - self.pipe.send((StreamMsg.streamError, str(e))) - - def stopStream(self): - """ - Stop the DAQ stream. - """ - - if self.daq is None: - self.pipe.send((StreamMsg.streamError, 'Stream is not running')) - return - - try: - self.daq.stop() - self.running <<= False - self.streamdata = None - self.pipe.send((StreamMsg.streamStopped, None)) - self.putAllInQueues(StreamMsg.streamStopped, None) - except Exception as e: - self.pipe.send((StreamMsg.streamError, f'Error stopping stream: {e}')) - - self.streamdata - self.daq = None - - def streamCallback(self, indata, outdata, nframes): + self.devices = Daq.getDeviceInfo() + self.sendPipe(StreamMsg.deviceList, self.devices) + + def streamCallback(self, audiostream, indata, outdata): """This is called (from a separate thread) for each audio block.""" # logging.debug('streamCallback()') - self.aframectr += nframes if self.siggen_activated: # logging.debug('siggen_activated') if self.outq.empty(): outdata[:, :] = 0 msgtxt = 'Output signal buffer underflow' - self.pipe.send((StreamMsg.streamError, msgtxt)) - self.putAllInQueues(StreamMsg.streamError, msgtxt) + self.sendPipeAndAllQueues(StreamMsg.streamError, + audiostream.avtype, + msgtxt) else: newdata = self.outq.get() if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: - self.pipe.send(StreamMsg.streamFatalError, 'Invalid output data obtained from queue') + self.sendPipeAndAllQueues(StreamMsg.streamFatalError, + audiostream.avtype, + 'Invalid output data obtained from queue') return 1 - outdata[:, :] = newdata[:, np.newaxis] + outdata[:, :] = newdata[:, None] if indata is not None: - self.putAllInQueues(StreamMsg.streamData, indata) + self.putAllInQueues(StreamMsg.streamData, audiostream.avtype, + indata) - return 0 if self.running else 1 + return 0 - def putAllInQueues(self, msg, data): + def putAllInQueues(self, msg, *data): """ Put a message and data on all input queues in the queue list """ @@ -199,51 +353,42 @@ class AvStreamProcess(mp.Process): # Fan out the input data to all queues in the queue list q.put((msg, data)) -def ignoreSigInt(): - signal.signal(signal.SIGINT, signal.SIG_IGN) + # Wrapper functions that safe some typing, they do not require an + # explanation. + def sendPipe(self, msg, *data): + self.pipe.send((msg, data)) -class AvStream: - """Audio and video data stream, to which callbacks can be adde - daqconfig: DAQConfiguration instance. If duplex mode flag is set, - please make sure that output_device is None, as in that case the - output config will be taken from the input device. - video: - """ - def __init__(self, - avtype: AvType, - daqconfig: DaqConfiguration, - video=None): + def sendPipeAndAllQueues(self, msg, *data): + self.sendPipe(msg, *data) + self.putAllInQueues(msg, *data) + + +@dataclass +class StreamStatus: + lastStatus: StreamMsg = StreamMsg.streamStopped + errorTxt: str = None + streammetadata: StreamMetaData = None + + +class StreamManager: + """ + Audio and video data stream manager, to which queus can be added + """ + def __init__(self): """Open a stream for audio in/output and video input. For audio output, by default all available channels are opened for outputting data. - Args: - device: DeviceInfo for the audio device - avtype: Type of stream. Input, output or duplex - - daqconfig: DAQConfiguration instance. If duplex mode flag is set, - please make sure that output_device is None, as in that case the - output config will be taken from the input device. - video: """ - self.avtype = avtype - - en_in_ch = daqconfig.getEnabledInChannels(include_monitor=True) - en_out_ch = daqconfig.getEnabledOutChannels() - - self.input_channel_names = [ch.channel_name for ch in en_in_ch] - self.output_channel_names = [ch.channel_name for ch in en_out_ch] - - self.input_sensitivity = [ch.sensitivity for ch in en_in_ch] - self.input_sensitivity = np.asarray(self.input_sensitivity) - self.input_qtys = [ch.qty for ch in en_in_ch] - - # Counters for the number of frames that have been coming in - self._vframectr = Atomic(0) + # Initialize streamstatus + self.streamstatus = {t: StreamStatus() for t in list(AvType)} + self.devices = None # Multiprocessing manager, pipe, output queue, input queue, self.manager = mp.managers.SyncManager() + + # Start this manager and ignore interrupts # https://stackoverflow.com/questions/21104997/keyboard-interrupt-with-pythons-multiprocessing self.manager.start(ignoreSigInt) @@ -260,40 +405,77 @@ class AvStream: # Messaging pipe self.pipe, child_pipe = mp.Pipe(duplex=True) + + # Create the stream process self.streamProcess = AvStreamProcess( - daqconfig, child_pipe, self.in_qlist, self.outq) self.streamProcess.start() - # Possible, but long not tested: store video - # self._video = video - # self._video_started = Atomic(False) - self._videothread = None + def handleMessages(self): + """ - self.daqconfig = daqconfig - self.streammetadata = None + Handle messages that are still on the pipe. - def getStreamMetaData(self): - return self.streammetadata + """ + while self.pipe.poll(): + msg, data = self.pipe.recv() + if msg == StreamMsg.streamStarted: + avtype, streammetadata = data + self.streamstatus[avtype].lastStatus = msg + self.streamstatus[avtype].errorTxt = None + self.streamstatus[avtype].streammetadata = streammetadata + + elif msg == StreamMsg.streamStopped: + avtype, = data + self.streamstatus[avtype].lastStatus = msg + self.streamstatus[avtype].errorTxt = None + self.streamstatus[avtype].streammetadata = None + + elif msg == StreamMsg.streamError: + avtype, errorTxt = data + if avtype is not None: + self.streamstatus[avtype].lastStatus = msg + self.streamstatus[avtype].errorTxt = None + + elif msg == StreamMsg.streamMetaData: + avtype, metadata = data + self.streamstatus[avtype].streammetadata = metadata + + elif msg == StreamMsg.deviceList: + devices = data + self.devices = devices + + def getDeviceList(self): + self.handleMessages() + return self.devices + + + def getStreamStatus(self, avtype: AvType): + """ + Returns the current stream Status. + """ + self.handleMessages() + return self.streamstatus[avtype] def getOutputQueue(self): """ Returns the output queue object. - Note, should only be used by one signal generator at the time! + Note, should (of course) only be used by one signal generator at the time! """ return self.outq def activateSiggen(self): + self.handleMessages() logging.debug('activateSiggen()') - self.pipe.send((StreamMsg.activateSiggen, None)) + self.sendPipe(StreamMsg.activateSiggen, None) def deactivateSiggen(self): + self.handleMessages() logging.debug('activateSiggen()') - self.pipe.send((StreamMsg.deactivateSiggen, None)) - + self.sendPipe(StreamMsg.deactivateSiggen, None) def addListener(self): """ @@ -302,12 +484,18 @@ class AvStream: Returns: listener queue """ + self.handleMessages() newqueue = self.manager.Queue() self.in_qlist.append(newqueue) self.in_qlist_local.append(newqueue) return newqueue def removeListener(self, queue): + """ + Remove an input listener queue from the queue list. + """ + # Uses a local queue list to find the index, based on the queue + self.handleMessages() idx = self.in_qlist_local.index(queue) del self.in_qlist[idx] del self.in_qlist_local[idx] @@ -316,29 +504,22 @@ class AvStream: """Returns the current number of installed listeners.""" return len(self.in_qlist) - def start(self): - """Start the stream, which means the callbacks are called with stream - data (audio/video)""" - logging.debug('Starting stream...') - self.pipe.send((StreamMsg.startStream, None)) - msg, data = self.pipe.recv() - if msg == StreamMsg.streamStarted: - self.streammetadata = data - return data - elif msg == StreamMsg.streamError: - raise RuntimeError(data) - else: - raise RuntimeError('BUG: got unexpected message: {msg}') + def startStream(self, avtype: AvType, daqconfig: DaqConfiguration): + """ + Start the stream, which means the callbacks are called with stream + data (audio/video) - def stop(self): - self.pipe.send((StreamMsg.stopStream, None)) - msg, data = self.pipe.recv() - if msg == StreamMsg.streamStopped: - return - elif msg == StreamMsg.streamError: - raise RuntimeError(data) - else: - raise RuntimeError('BUG: got unexpected message: {msg}') + """ + logging.debug('Starting stream...') + self.sendPipe(StreamMsg.startStream, avtype, daqconfig) + self.handleMessages() + + def stopStream(self, avtype: AvType): + self.handleMessages() + self.sendPipe(StreamMsg.stopStream, avtype) + + def stopAllStreams(self): + self.sendPipe(StreamMsg.stopAllStreams) def cleanup(self): """ @@ -349,7 +530,7 @@ class AvStream: Otherwise things will wait forever... """ - self.pipe.send((StreamMsg.endProcess, None)) + self.sendPipe(StreamMsg.endProcess, None) logging.debug('Joining stream process...') self.streamProcess.join() logging.debug('Joining stream process done') @@ -360,47 +541,9 @@ class AvStream: """ return False - - def isRunning(self): - self.pipe.send((StreamMsg.getStreamMetaData, None)) - msg, data = self.pipe.recv() - if msg == StreamMsg.streamMetaData: - streamdata = data - return streamdata is not None - - elif msg == StreamMsg.streamError: - raise RuntimeError(data) - else: - raise RuntimeError('BUG: got unexpected message: {msg}') - - -# def _videoThread(self): -# cap = cv.VideoCapture(self._video) -# if not cap.isOpened(): -# cap.open() -# vframectr = 0 -# loopctr = 0 -# while self._running: -# ret, frame = cap.read() -# # print(frame.shape) -# if ret is True: -# if vframectr == 0: -# self._video_started <<= True -# with self._callbacklock: -# for cb in self._callbacks[AvType.video]: -# cb(frame, vframectr) -# vframectr += 1 -# self._vframectr += 1 -# else: -# loopctr += 1 -# if loopctr == 10: -# print('Error: no video capture!') -# time.sleep(0.2) - -# cap.release() -# print('stopped videothread') - - -# def hasVideo(self): -# return True if self._video is not None else False + def sendPipe(self, msg, *data): + """ + Send a message with data over the control pipe + """ + self.pipe.send((msg, data)) diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index 1fb8249..4ad5ca8 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -11,6 +11,7 @@ from .wrappers import Window as wWindow from collections import namedtuple from dataclasses import dataclass from dataclasses_json import dataclass_json +from enum import Enum, unique, auto """ Common definitions used throughout the code. @@ -43,11 +44,19 @@ U_REF = 5e-8 # 50 nano meter / s # hence this is the reference level as specified below. dBFS_REF = 0.5*2**0.5 # Which level would be -3.01 dBFS -class AvType: +@unique +class AvType(Enum): """Specificying the type of data, for adding and removing callbacks from the stream.""" - audio_input = 1 - audio_output = 2 + + # Input stream + audio_input = auto() + + # Output stream + audio_output = auto() + + # Both input as well as output + audio_duplex = auto() video = 4 diff --git a/lasp/lasp_record.py b/lasp/lasp_record.py index 336f1ab..7062055 100644 --- a/lasp/lasp_record.py +++ b/lasp/lasp_record.py @@ -8,7 +8,8 @@ import logging import os import time import h5py -from .lasp_avstream import AvStream, AvType, StreamMsg +from .lasp_avstream import StreamMsg, StreamManager +from .lasp_common import AvType @dataclasses.dataclass @@ -19,7 +20,7 @@ class RecordStatus: class Recording: - def __init__(self, fn: str, stream: AvStream, + def __init__(self, fn: str, streammgr: StreamManager, rectime: float = None, wait: bool = True, progressCallback=None): """ diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index ce776f2..03cc6cf 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -15,17 +15,16 @@ import numpy as np from .filter import PinkNoise from .lasp_octavefilter import SosOctaveFilterBank, SosThirdOctaveFilterBank from .filter import OctaveBankDesigner, PinkNoise, ThirdOctaveBankDesigner -from .lasp_avstream import AvStream, AvType +from .lasp_avstream import StreamManager, ignoreSigInt from .wrappers import Siggen as pyxSiggen, Equalizer from enum import Enum, unique, auto -QUEUE_BUFFER_TIME = 0.3 # The amount of time used in the queues for buffering +QUEUE_BUFFER_TIME = 0.5 # The amount of time used in the queues for buffering # of data, larger is more stable, but also enlarges latency __all__ = ["SignalType", "NoiseType", "SiggenMessage", "SiggenData", "Siggen"] - class SignalType(Enum): Periodic = auto() Noise = auto() @@ -39,7 +38,7 @@ class NoiseType(Enum): pink = "Pink noise" def __str__(self): - return self.value + return str(self.value) @staticmethod def fillComboBox(combo): @@ -62,9 +61,11 @@ class SiggenMessage(Enum): Different messages that can be send to the signal generator over the pipe connection. """ - stop = auto() # Stop and quit the signal generator + endProcess = auto() # Stop and quit the signal generator adjustVolume = auto() # Adjust the volume newEqSettings = auto() # Forward new equalizer settings + ready = auto() # Send out once, once the signal generator is ready with + # pre-generating data. # These messages are send back to the main thread over the pipe error = auto() @@ -73,6 +74,9 @@ class SiggenMessage(Enum): @dataclasses.dataclass class SiggenData: + """ + Metadata used to create a Signal Generator + """ fs: float # Sample rate [Hz] # Number of frames "samples" to send in one block @@ -120,10 +124,13 @@ class SiggenProcess(mp.Process): ) super().__init__() - def newSiggen(self, siggendata): + def newSiggen(self, siggendata: SiggenData): """ Create a signal generator based on parameters specified in global function data. + + Args: + siggendata: SiggenData. Metadata to create a new signal generator. """ fs = siggendata.fs nframes_per_block = siggendata.nframes_per_block @@ -192,23 +199,31 @@ class SiggenProcess(mp.Process): return eq def run(self): - # Initialization + # The main function of the actual process + # First things first + ignoreSigInt() + try: self.siggen = self.newSiggen(self.siggendata) + except Exception as e: + self.pipe.send((SiggenMessage.error, str(e))) + + try: self.eq = self.newEqualizer(self.siggendata.eqdata) except Exception as e: self.pipe.send((SiggenMessage.error, str(e))) - return 1 # Pre-generate blocks of signal data while self.dataq.qsize() < self.nblocks_buffer: self.generate() + self.pipe.send((SiggenMessage.ready, None)) + while True: if self.pipe.poll(timeout=QUEUE_BUFFER_TIME / 2): msg, data = self.pipe.recv() - if msg == SiggenMessage.stop: - logging.debug("Signal generator caught 'stop' message. Exiting.") + if msg == SiggenMessage.endProcess: + logging.debug("Signal generator caught 'endProcess' message. Exiting.") return 0 elif msg == SiggenMessage.adjustVolume: logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") @@ -243,14 +258,27 @@ class Siggen: self.pipe, client_end = mp.Pipe(duplex=True) + self.stopped = False + self.process = SiggenProcess(siggendata, dataq, client_end) self.process.start() - self.handle_msgs() if not self.process.is_alive(): raise RuntimeError('Unexpected signal generator exception') - self.stopped = False + # Block waiting here for signal generator to be ready + msg, data = self.pipe.recv() + if msg == SiggenMessage.ready: + logging.debug('Signal generator ready') + elif msg == SiggenMessage.error: + e = data + raise RuntimeError('Signal generator exception: {str(e)}') + else: + # Done, or something + if msg == SiggenMessage.done: + self.stopped = True + + self.handle_msgs() def setLevel(self, new_level): """ @@ -268,27 +296,16 @@ class Siggen: while self.pipe.poll(): msg, data = self.pipe.recv() if msg == SiggenMessage.error: - self.stop() raise RuntimeError( f"Error in initialization of signal generator: {data}" ) - elif msg == SiggenMessage.ready: - return + # elif msg == SiggenMessage.done: + # self.stop() - elif msg == SiggenMessage.done: - self.stop() - def start(self): - if self.stopped: - raise RuntimeError('BUG: This Siggen object cannot be used again.') - - self.handle_msgs() - - def stop(self): + def cleanup(self): logging.debug('Siggen::stop()') - if self.stopped: - raise RuntimeError('BUG: Siggen::stop() is called twice!') - self.pipe.send((SiggenMessage.stop, None)) + self.pipe.send((SiggenMessage.endProcess, None)) self.pipe.close() logging.debug('Joining siggen process') @@ -297,6 +314,4 @@ class Siggen: self.process.close() self.process = None - logging.debug('End Siggen::stop()') - self.stopped = True diff --git a/scripts/lasp_siggen b/scripts/lasp_siggen index eca1f68..41f3d99 100755 --- a/scripts/lasp_siggen +++ b/scripts/lasp_siggen @@ -5,7 +5,8 @@ import sys, logging, os, argparse logging.basicConfig(level=logging.DEBUG) import multiprocessing from lasp.lasp_multiprocessingpatch import apply_patch -from lasp.lasp_avstream import AvStream, AvType +from lasp.lasp_avstream import StreamManager +from lasp.lasp_common import AvType from lasp.lasp_siggen import Siggen, SignalType, SiggenData from lasp.device import DaqConfigurations @@ -36,41 +37,33 @@ if __name__ == '__main__': choosen_key = config_keys[daqindex] - config = configs[choosen_key].output_config + daqconfig = configs[choosen_key].output_config print(f'Choosen configuration: {choosen_key}') - - try: + streammgr = StreamManager() + outq = streammgr.getOutputQueue() + siggendata = SiggenData( fs=48e3, - nframes_per_block=1024, + nframes_per_block=2048, dtype=np.dtype(np.int16), eqdata=None, level_dB=-20, signaltype=SignalType.Periodic, signaltypedata=(1000.,) ) - - stream = AvStream( - AvType.audio_output, - config) - - outq = stream.getOutputQueue() - stream.activateSiggen() siggen = Siggen(outq, siggendata) - stream.start() + streammgr.activateSiggen() + + streammgr.startStream(AvType.audio_output, daqconfig) + input('Press any key to stop...') - stream.stop() - siggen.stop() - + streammgr.stopStream(AvType.audio_output) finally: - try: - stream.cleanup() - del stream - except NameError: - pass + siggen.cleanup() + streammgr.cleanup() From b031dfb28088e08abb749a7ead5ac885d9a99b9b Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sat, 8 May 2021 15:03:57 +0200 Subject: [PATCH 09/28] Cleanup some debug print statements --> logging interface --- lasp/__init__.py | 1 + lasp/device/lasp_daq.pyx | 15 +++++++-------- scripts/{lasp_siggen => play_sine} | 0 3 files changed, 8 insertions(+), 8 deletions(-) rename scripts/{lasp_siggen => play_sine} (100%) diff --git a/lasp/__init__.py b/lasp/__init__.py index c0fadc5..fdabeb9 100644 --- a/lasp/__init__.py +++ b/lasp/__init__.py @@ -6,5 +6,6 @@ from .lasp_imptube import * from .lasp_measurement import * from .lasp_octavefilter import * from .lasp_slm import * +from .lasp_record import * from .lasp_siggen import * from .lasp_weighcal import * diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index 42cb7ca..15adc23 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -5,6 +5,7 @@ from .lasp_daqconfig cimport DaqConfiguration from cpython.ref cimport PyObject,Py_INCREF, Py_DECREF import numpy as np +import logging __all__ = ['Daq'] @@ -82,7 +83,7 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: callback = sd.pyCallback # print(f'Number of input channels: {ninchannels}') # print(f'Number of out channels: {noutchannels}') - fprintf(stderr, 'Sleep time: %d us\n', sleeptime_us) + # fprintf(stderr, 'Sleep time: %d us\n', sleeptime_us) while not sd.stopThread.load(): with gil: @@ -103,7 +104,7 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: ) except Exception as e: - print('exception in Cython callback for audio output: ', str(e)) + logging.error('exception in Cython callback for audio output: ', str(e)) return sd.outQueue.enqueue( outbuffer) @@ -113,7 +114,7 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: # Waiting indefinitely on the queue... inbuffer = sd.inQueue.dequeue() if inbuffer == NULL: - printf('Stopping thread...\n') + logging.debug('Stopping thread...\n') return try: @@ -130,7 +131,7 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: ) except Exception as e: - print('exception in cython callback for audio input: ', str(e)) + logging.error('exception in cython callback for audio input: ', str(e)) return CPPsleep_us(sleeptime_us); @@ -142,8 +143,6 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: # Inputbuffer memory is owned by Numpy, so should not be free'ed inbuffer = NULL - fprintf(stderr, 'Exiting python thread...\n') - cdef class Daq: def __cinit__(self, DeviceInfo pydevinfo, DaqConfiguration pydaqconfig): @@ -177,7 +176,7 @@ cdef class Daq: def __dealloc__(self): # fprintf(stderr, "UlDaq.__dealloc__\n") if self.sd is not NULL: - fprintf(stderr, "UlDaq.__dealloc__: stopping stream.\n") + logging.debug("UlDaq.__dealloc__: stopping stream.") self.stop() if self.daq_device is not NULL: @@ -303,7 +302,7 @@ cdef class Daq: free(sd.outQueue.dequeue()) del sd.outQueue sd.outQueue = NULL - fprintf(stderr, "End cleanup stream queues...\n") + logging.debug("End cleanup stream queues...\n") if sd.pyCallback: Py_DECREF( sd.pyCallback) diff --git a/scripts/lasp_siggen b/scripts/play_sine similarity index 100% rename from scripts/lasp_siggen rename to scripts/play_sine From 57fe4e6b7c894f892b999bd0a1052b7d41ba72f2 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sat, 8 May 2021 15:06:11 +0200 Subject: [PATCH 10/28] Playing a sine wave works, recording seems to work, although we have not checked what is actually recorded. For that we now switch to ACME! --- lasp/lasp_avstream.py | 101 ++++++++++++------ lasp/lasp_common.py | 3 +- lasp/lasp_measurement.py | 4 +- lasp/lasp_record.py | 219 +++++++++++++++++++++++---------------- scripts/lasp_record | 100 +++++++++--------- scripts/play_sine | 43 +++++--- 6 files changed, 278 insertions(+), 192 deletions(-) diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index d43fe96..0bc5d73 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -5,13 +5,14 @@ Author: J.A. de Jong Description: Controlling an audio stream in a different process. """ #import cv2 as cv +from .lasp_multiprocessingpatch import apply_patch +apply_patch() + import multiprocessing as mp import time, logging, signal import numpy as np from enum import unique, Enum, auto from dataclasses import dataclass -from .lasp_multiprocessingpatch import apply_patch -apply_patch() from .lasp_atomic import Atomic from .lasp_common import AvType @@ -127,6 +128,7 @@ class AudioStream: blocksize = self.daq.nFramesPerBlock, dtype = self.daq.getNumpyDataType() ) + self.running <<= True def streamCallback(self, indata, outdata, nframes): @@ -134,7 +136,12 @@ class AudioStream: This is called (from a separate thread) for each block of audio data. """ - return self.processCallback(self, indata, outdata) + if not self.running(): + return 1 + rv = self.processCallback(self, indata, outdata) + if rv != 0: + self.running <<= False + return rv def stop(self): """ @@ -187,7 +194,7 @@ class AvStreamProcess(mp.Process): while True: msg, data = self.pipe.recv() - logging.debug(f'Obtained message {msg}') + logging.debug(f'Streamprocess obtained message {msg}') if msg == StreamMsg.activateSiggen: self.siggen_activated <<= True @@ -207,12 +214,14 @@ class AvStreamProcess(mp.Process): return elif msg == StreamMsg.getStreamMetaData: - avtype = data + avtype, = data stream = self.streams[avtype] if stream is not None: - self.sendPipe(StreamMsg.streamMetaData, avtype, stream.streammetadata) + self.sendPipeAndAllQueues(StreamMsg.streamMetaData, avtype, + stream.streammetadata) else: - self.sendPipe(StreamMsg.streamMetaData, avtype, None) + self.sendPipeAndAllQueues(StreamMsg.streamMetaData, avtype, + None) elif msg == StreamMsg.startStream: avtype, daqconfig = data @@ -254,7 +263,8 @@ class AvStreamProcess(mp.Process): stream.stop() self.sendPipeAndAllQueues(StreamMsg.streamStopped, stream.avtype) except Exception as e: - self.sendPipeAndAllQueues(StreamMsg.streamError, stream.avtype, "Error occured in stopping stream: {str(e)}") + self.sendPipeAndAllQueues(StreamMsg.streamError, stream.avtype, + "Error occured in stopping stream: {str(e)}") self.streams[avtype] = None @@ -301,7 +311,7 @@ class AvStreamProcess(mp.Process): else: avtype = (avtype,) for t in avtype: - if self.streams[t] is not None: + if self.streams[t] is not None and self.streams[t].running(): return True return False @@ -321,27 +331,34 @@ class AvStreamProcess(mp.Process): def streamCallback(self, audiostream, indata, outdata): """This is called (from a separate thread) for each audio block.""" # logging.debug('streamCallback()') - - if self.siggen_activated: - # logging.debug('siggen_activated') - if self.outq.empty(): - outdata[:, :] = 0 - msgtxt = 'Output signal buffer underflow' - self.sendPipeAndAllQueues(StreamMsg.streamError, - audiostream.avtype, - msgtxt) - else: - newdata = self.outq.get() - if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: - self.sendPipeAndAllQueues(StreamMsg.streamFatalError, + if outdata is not None: + if self.siggen_activated(): + if not self.outq.empty(): + newdata = self.outq.get() + if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: + msgtxt = 'Invalid output data obtained from queue' + logging.fatal(msgtxt) + self.sendPipeAndAllQueues(StreamMsg.streamFatalError, + audiostream.avtype, + msgtxt + ) + return 1 + outdata[:, :] = newdata[:, None] + else: + outdata[:, :] = 0 + msgtxt = 'Output signal buffer underflow' + logging.error(msgtxt) + self.sendPipeAndAllQueues(StreamMsg.streamError, audiostream.avtype, - 'Invalid output data obtained from queue') - return 1 - outdata[:, :] = newdata[:, None] + msgtxt) + + # Siggen not activated + else: + logging.debug('siggen not activated') + outdata[:, :] = 0 if indata is not None: - self.putAllInQueues(StreamMsg.streamData, audiostream.avtype, - indata) + self.putAllInQueues(StreamMsg.streamData, indata) return 0 @@ -419,10 +436,13 @@ class StreamManager: Handle messages that are still on the pipe. """ + # logging.debug('StreamManager::handleMessages()') while self.pipe.poll(): msg, data = self.pipe.recv() + logging.debug(f'StreamManager obtained message {msg}') if msg == StreamMsg.streamStarted: avtype, streammetadata = data + # logging.debug(f'{avtype}, {streammetadata}') self.streamstatus[avtype].lastStatus = msg self.streamstatus[avtype].errorTxt = None self.streamstatus[avtype].streammetadata = streammetadata @@ -439,6 +459,11 @@ class StreamManager: self.streamstatus[avtype].lastStatus = msg self.streamstatus[avtype].errorTxt = None + elif msg == StreamMsg.streamFatalError: + avtype, errorTxt = data + logging.critical(f'Streamprocess fatal error: {errorTxt}') + self.cleanup() + elif msg == StreamMsg.streamMetaData: avtype, metadata = data self.streamstatus[avtype].streammetadata = metadata @@ -454,10 +479,10 @@ class StreamManager: def getStreamStatus(self, avtype: AvType): """ - Returns the current stream Status. + Sends a request for the stream status over the pipe, for given AvType """ self.handleMessages() - return self.streamstatus[avtype] + self.sendPipe(StreamMsg.getStreamMetaData, avtype) def getOutputQueue(self): """ @@ -465,6 +490,7 @@ class StreamManager: Note, should (of course) only be used by one signal generator at the time! """ + self.handleMessages() return self.outq def activateSiggen(self): @@ -504,15 +530,28 @@ class StreamManager: """Returns the current number of installed listeners.""" return len(self.in_qlist) - def startStream(self, avtype: AvType, daqconfig: DaqConfiguration): + def startStream(self, avtype: AvType, daqconfig: DaqConfiguration, + wait=False): """ Start the stream, which means the callbacks are called with stream data (audio/video) + Args: + wait: Wait until the stream starts talking before returning from + this function. + """ logging.debug('Starting stream...') - self.sendPipe(StreamMsg.startStream, avtype, daqconfig) self.handleMessages() + self.sendPipe(StreamMsg.startStream, avtype, daqconfig) + if wait: + # Wait for a message to come into the pipe + while True: + if self.pipe.poll(): + self.handleMessages() + if self.streamstatus[avtype].lastStatus != StreamMsg.streamStopped: + break + def stopStream(self, avtype: AvType): self.handleMessages() diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index 4ad5ca8..4485d3a 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import os -import platform +import os, platform import shelve import sys import appdirs diff --git a/lasp/lasp_measurement.py b/lasp/lasp_measurement.py index 1e86a24..5f96d0d 100644 --- a/lasp/lasp_measurement.py +++ b/lasp/lasp_measurement.py @@ -48,8 +48,6 @@ import os, time, wave, logging from .lasp_common import SIQtys, Qty, getFreq from .device import DaqChannel from .wrappers import AvPowerSpectra, Window, PowerSpectra -logger = logging.Logger(__name__) - def getSampWidth(dtype): @@ -242,7 +240,7 @@ class Measurement: except KeyError: # If quantity data is not available, this is an 'old' # measurement file. - logger.debug('Physical quantity data not available in measurement file. Assuming {SIQtys.default}') + logging.debug('Physical quantity data not available in measurement file. Assuming {SIQtys.default}') self._qtys = [SIQtys.default for i in range(self.nchannels)] def setAttribute(self, atrname, value): diff --git a/lasp/lasp_record.py b/lasp/lasp_record.py index 7062055..3974f95 100644 --- a/lasp/lasp_record.py +++ b/lasp/lasp_record.py @@ -1,14 +1,10 @@ -#!/usr/bin/python3 +#!/usr/bin/python3.8 # -*- coding: utf-8 -*- """ Read data from stream and record sound and video at the same time """ -import dataclasses -import logging -import os -import time -import h5py -from .lasp_avstream import StreamMsg, StreamManager +import dataclasses, logging, os, time, h5py +from .lasp_avstream import StreamManager, StreamMetaData, StreamMsg from .lasp_common import AvType @@ -40,7 +36,8 @@ class Recording: if ext not in fn: fn += ext - self._stream = stream + self.smgr = streammgr + self.metadata = None self.rectime = rectime self._fn = fn @@ -61,66 +58,26 @@ class Recording: # when a recording is canceled. self._deleteFile = False - f = self._f - nchannels = len(stream.input_channel_names) - - # Input queue - self.inq = stream.addListener() - - # Start the stream, if it is not running try: - if not stream.isRunning(): - metadata = stream.start() - else: - metadata = stream.getStreamMetaData() - except: + # Input queue + self.inq = streammgr.addListener() + + except RuntimeError: # Cleanup stuff, something is going wrong when starting the stream try: - f.close() - except: - pass + self._f.close() + except Exception as e: + logging.error( + 'Error preliminary closing measurement file {fn}: {str(e)}') + self.__deleteFile() - self.blocksize = metadata['blocksize'] - self.samplerate = metadata['samplerate'] - self.dtype = metadata['dtype'] + raise - # The 'Audio' dataset as specified in lasp_measurement, where data is - # send to. We use gzip as compression, this gives moderate a moderate - # compression to the data. - self._ad = f.create_dataset('audio', - (1, self.blocksize, nchannels), - dtype=self.dtype, - maxshape=( - None, # This means, we can add blocks - # indefinitely - self.blocksize, - nchannels), - compression='gzip' - ) + # Try to obtain stream metadata + streammgr.getStreamStatus(AvType.audio_input) + streammgr.getStreamStatus(AvType.audio_duplex) - # TODO: This piece of code is not up-to-date and should be changed at a - # later instance once we really want to record video simultaneously - # with audio. - if stream.hasVideo(): - video_x, video_y = stream.video_x, stream.video_y - self._vd = f.create_dataset('video', - (1, video_y, video_x, 3), - dtype='uint8', - maxshape=( - None, video_y, video_x, 3), - compression='gzip' - ) - - # Set the bunch of attributes - f.attrs['samplerate'] = self.samplerate - f.attrs['nchannels'] = nchannels - f.attrs['blocksize'] = self.blocksize - f.attrs['sensitivity'] = stream.input_sensitivity - f.attrs['channel_names'] = stream.input_channel_names - f.attrs['time'] = time.time() - - # Measured quantities - f.attrs['qtys'] = [qty.to_json() for qty in stream.input_qtys] + self._ad = None logging.debug('Starting record....') # TODO: Fix this later when we want video @@ -139,6 +96,92 @@ class Recording: finally: self.finish() + def handleQueue(self): + """ + This method should be called to grab data from the input queue, which + is filled by the stream, and put it into a file. It should be called at + a regular interval to prevent overflowing of the queue. It is called + within the start() method of the recording, if block is set to True. + Otherwise, it should be called from its parent at regular intervals. + For example, in Qt this can be done using a QTimer. + + + """ + while self.inq.qsize() > 0: + msg, data = self.inq.get() + if msg == StreamMsg.streamData: + samples, = data + self.__addTimeData(samples) + elif msg == StreamMsg.streamStarted: + logging.debug(f'handleQueue obtained message {msg}') + avtype, metadata = data + if metadata is None: + raise RuntimeError('BUG: no stream metadata') + self.processStreamMetaData(metadata) + elif msg == StreamMsg.streamMetaData: + logging.debug(f'handleQueue obtained message {msg}') + avtype, metadata = data + if metadata is not None: + self.processStreamMetaData(metadata) + else: + logging.debug(f'handleQueue obtained message {msg}') + # An error occured, we do not remove the file, but we stop. + self.stop = True + logging.debug(f'Stream message: {msg}. Recording stopped unexpectedly') + raise RuntimeError('Recording stopped unexpectedly') + + + def processStreamMetaData(self, md: StreamMetaData): + """ + Stream metadata has been catched. This is used to set all metadata in + the measurement file + + """ + logging.debug('Recording::processStreamMetaData()') + # The 'Audio' dataset as specified in lasp_measurement, where data is + # send to. We use gzip as compression, this gives moderate a moderate + # compression to the data. + f = self._f + blocksize = md.blocksize + nchannels = len(md.in_ch) + self._ad = f.create_dataset('audio', + (1, blocksize, nchannels), + dtype=md.dtype, + maxshape=( + None, # This means, we can add blocks + # indefinitely + blocksize, + nchannels), + compression='gzip' + ) + + # TODO: This piece of code is not up-to-date and should be changed at a + # later instance once we really want to record video simultaneously + # with audio. + # if smgr.hasVideo(): + # video_x, video_y = smgr.video_x, smgr.video_y + # self._vd = f.create_dataset('video', + # (1, video_y, video_x, 3), + # dtype='uint8', + # maxshape=( + # None, video_y, video_x, 3), + # compression='gzip' + # ) + + # Set the bunch of attributes + f.attrs['samplerate'] = md.fs + f.attrs['nchannels'] = nchannels + f.attrs['blocksize'] = blocksize + f.attrs['sensitivity'] = [ch.sensitivity for ch in md.in_ch] + f.attrs['channel_names'] = [ch.channel_name for ch in md.in_ch] + f.attrs['time'] = time.time() + self.blocksize = blocksize + self.fs = md.fs + + # Measured physical quantity metadata + f.attrs['qtys'] = [ch.qty.to_json() for ch in md.in_ch] + self.metadata = md + def setDelete(self, val: bool): """ Set the delete flag. If set, measurement file is deleted at the end of @@ -153,16 +196,18 @@ class Recording: remove the queue from the stream, etc. """ - stream = self._stream + logging.debug('Recording::finish()') + smgr = self.smgr + # TODO: Fix when video - # if stream.hasVideo(): - # stream.removeCallback(self._vCallback, AvType.video_input) + # if smgr.hasVideo(): + # smgr.removeCallback(self._vCallback, AvType.video_input) # self._f['video_frame_positions'] = self._video_frame_positions try: - stream.removeListener(self.inq) + smgr.removeListener(self.inq) except Exception as e: - logging.error(f'Could not remove queue from stream: {e}') + logging.error(f'Could not remove queue from smgr: {e}') try: # Close the recording file @@ -181,39 +226,29 @@ class Recording: try: os.remove(self._fn) except Exception as e: - logging.debug(f'Error deleting file: {self._fn}') - - def handleQueue(self): - """ - This method should be called to grab data from the input queue, which - is filled by the stream, and put it into a file. It should be called at - a regular interval to prevent overflowing of the queue. It is called - within the start() method of the recording, if block is set to True. - Otherwise, it should be called from its parent at regular intervals. - For example, in Qt this can be done using a QTimer. - - - """ - while self.inq.qsize() > 0: - msg, data = self.inq.get() - if msg == StreamMsg.streamData: - self.__addTimeData(data) - elif msg == StreamMsg.streamStarted: - pass - elif msg == StreamMsg.streamMetaData: - pass - else: - # An error occured, we do not remove the file, but we stop. - self.stop = True + logging.error(f'Error deleting file: {self._fn}') def __addTimeData(self, indata): """ Called by handleQueue() and adds new time data to the storage file. """ + # logging.debug('Recording::__addTimeData()') + + if self.stop: + # Stop flag is raised. We stop recording here. + return # The current time that is recorded and stored into the file, without # the new data - curT = self._ablockno*self.blocksize/self.samplerate + if not self.metadata: + # We obtained stream data, but metadata is not yet available. + # Therefore, we request it explicitly and then we return + logging.info('Requesting stream metadata') + self.smgr.getStreamStatus(AvType.audio_input) + self.smgr.getStreamStatus(AvType.audio_duplex) + return + + curT = self._ablockno*self.blocksize/self.fs recstatus = RecordStatus( curT=curT, done=False) diff --git a/scripts/lasp_record b/scripts/lasp_record index c2fa06e..220603e 100755 --- a/scripts/lasp_record +++ b/scripts/lasp_record @@ -1,61 +1,67 @@ -#!/usr/bin/python3 +#!/usr/bin/python3.8 import sys, logging, os, argparse -logging.basicConfig(level=logging.DEBUG) +FORMAT = "[%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" +parser = argparse.ArgumentParser( + description='Acquire data and store to a measurement file.' +) +parser.add_argument('filename', type=str, + help='File name to record to.' + ' Extension is automatically added.') +parser.add_argument('--duration', '-d', type=float, + help='The recording duration in [s]') + +device_help = 'DAQ Device to record from' +parser.add_argument('--input-daq', '-i', help=device_help, type=str, + default='Default') +parser.add_argument('--log', '-l', + help='Specify log level [info, debug, warning, ...]', + type=str, default='info') + + +args = parser.parse_args() +numeric_level = getattr(logging, args.log.upper(), None) +if not isinstance(numeric_level, int): + raise ValueError('Invalid log level: %s' % args.loglevel) +logging.basicConfig(format=FORMAT, level=numeric_level) + import multiprocessing -from lasp.lasp_multiprocessingpatch import apply_patch - from lasp.device import DaqConfigurations -from lasp.lasp_avstream import AvStream, AvType -from lasp.lasp_record import Recording +from lasp import AvType, StreamManager, Recording# configureLogging -if __name__ == '__main__': - multiprocessing.set_start_method('forkserver', force=True) - apply_patch() - - parser = argparse.ArgumentParser( - description='Acquire data and store a measurement file' - ) - parser.add_argument('filename', type=str, - help='File name to record to.' - ' Extension is automatically added.') - parser.add_argument('--duration', '-d', type=float, - help='The recording duration in [s]') - - device_help = 'DAQ Device to record from' - parser.add_argument('--input-daq', '-i', help=device_help, type=str, - default='Default') - - args = parser.parse_args() - - - configs = DaqConfigurations.loadConfigs() - - config_keys = [key for key in configs.keys()] - for i, key in enumerate(config_keys): - print(f'{i:2} : {key}') - - choosen_index = input('Number of configuration to use: ') +def main(args): try: - daqindex = int(choosen_index) - except: - sys.exit(0) + streammgr = StreamManager() + configs = DaqConfigurations.loadConfigs() - choosen_key = config_keys[daqindex] - config = configs[choosen_key].input_config + config_keys = [key for key in configs.keys()] + for i, key in enumerate(config_keys): + print(f'{i:2} : {key}') - print(f'Choosen configuration: {choosen_key}') + choosen_index = input('Number of configuration to use: ') + try: + daqindex = int(choosen_index) + except: + print('Invalid configuration number. Exiting.') + sys.exit(0) - try: - stream = AvStream( - AvType.audio_input, - config) - # stream.start() - rec = Recording(args.filename, stream, args.duration) - # input('Stream started, press any key to start record') + choosen_key = config_keys[daqindex] + config = configs[choosen_key].input_config + + print(f'Choosen configuration: {choosen_key}') + + streammgr.startStream(AvType.audio_input, config, wait=True) + rec = Recording(args.filename, streammgr, args.duration) + + streammgr.stopStream(AvType.audio_output) finally: try: - stream.cleanup() + streammgr.cleanup() del stream except NameError: pass +if __name__ == '__main__': + + multiprocessing.set_start_method('forkserver', force=True) + + main(args) diff --git a/scripts/play_sine b/scripts/play_sine index 41f3d99..227ef37 100755 --- a/scripts/play_sine +++ b/scripts/play_sine @@ -1,27 +1,35 @@ #!/usr/bin/python3 -import argparse import numpy as np import sys, logging, os, argparse -logging.basicConfig(level=logging.DEBUG) + +parser = argparse.ArgumentParser( + description='Play a sine wave' +) + +parser.add_argument('--freq', '-f', help='Sine frequency [Hz]', type=float, + default=1000.) + +parser.add_argument('--log', '-l', + help='Specify log level [info, debug, warning, ...]', + type=str, default='info') + +args = parser.parse_args() + +numeric_level = getattr(logging, args.log.upper(), None) +if not isinstance(numeric_level, int): + raise ValueError('Invalid log level: %s' % args.loglevel) + +FORMAT = "[%(levelname)s %(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" +logging.basicConfig(format=FORMAT, level=numeric_level) + import multiprocessing -from lasp.lasp_multiprocessingpatch import apply_patch -from lasp.lasp_avstream import StreamManager -from lasp.lasp_common import AvType -from lasp.lasp_siggen import Siggen, SignalType, SiggenData +from lasp import (StreamManager, AvType, Siggen, SignalType, SiggenData) from lasp.device import DaqConfigurations if __name__ == '__main__': multiprocessing.set_start_method('forkserver', force=True) - parser = argparse.ArgumentParser( - description='Play a sine wave' - ) - device_help = 'DAQ Device to play to' - parser.add_argument('--device', '-d', help=device_help, type=str, - default='Default') - - args = parser.parse_args() - + logging.info(f'Playing frequency {args.freq} [Hz]') configs = DaqConfigurations.loadConfigs() @@ -33,6 +41,7 @@ if __name__ == '__main__': try: daqindex = int(choosen_index) except: + print('Invalid configuration number. Exiting.') sys.exit(0) @@ -47,12 +56,12 @@ if __name__ == '__main__': siggendata = SiggenData( fs=48e3, - nframes_per_block=2048, + nframes_per_block=1024, dtype=np.dtype(np.int16), eqdata=None, level_dB=-20, signaltype=SignalType.Periodic, - signaltypedata=(1000.,) + signaltypedata=(args.freq,) ) siggen = Siggen(outq, siggendata) From bd4961710ece5b20a8ad07708b13f50ba2c5b986 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Thu, 13 May 2021 21:35:51 +0200 Subject: [PATCH 11/28] Bugfix in deserializing device info. Added comments and function in stream manager to rescan Daq devices --- lasp/device/lasp_cppdaq.h | 5 +- lasp/device/lasp_deviceinfo.pyx | 7 +- lasp/lasp_avstream.py | 173 +++++++++++++++++--------------- lasp/lasp_common.py | 5 +- lasp/lasp_logging.py | 24 +++++ scripts/lasp_record | 3 +- 6 files changed, 130 insertions(+), 87 deletions(-) create mode 100644 lasp/lasp_logging.py diff --git a/lasp/device/lasp_cppdaq.h b/lasp/device/lasp_cppdaq.h index ea4d250..e95bda4 100644 --- a/lasp/device/lasp_cppdaq.h +++ b/lasp/device/lasp_cppdaq.h @@ -23,6 +23,8 @@ using std::getline; using std::runtime_error; using std::string; using std::vector; +using std::to_string; + typedef unsigned int us; typedef vector boolvec; @@ -69,7 +71,7 @@ class DaqApi { return (apiname == other.apiname && apicode == other.apicode && api_specific_subcode == other.api_specific_subcode); } - + operator string() const { return apiname + ", code: " + to_string(apicode); } static vector getAvailableApis(); }; @@ -192,6 +194,7 @@ class DeviceInfo { std::stringstream str(dstr); string tmp; us N; + // Lambda functions for deserializing auto nexts = [&]() { getline(str, tmp, '\t'); return tmp; }; auto nexti = [&]() { getline(str, tmp, '\t'); return std::atoi(tmp.c_str()); }; auto nextf = [&]() { getline(str, tmp, '\t'); return std::atof(tmp.c_str()); }; diff --git a/lasp/device/lasp_deviceinfo.pyx b/lasp/device/lasp_deviceinfo.pyx index 38454a0..95bce55 100644 --- a/lasp/device/lasp_deviceinfo.pyx +++ b/lasp/device/lasp_deviceinfo.pyx @@ -11,7 +11,8 @@ __all__ = ['DeviceInfo'] def pickle(dat): dev = DeviceInfo() - dev.devinfo.deserialize(dat) + # print('DESERIALIZE****') + dev.devinfo = dev.devinfo.deserialize(dat) return dev cdef class DeviceInfo: @@ -22,7 +23,9 @@ cdef class DeviceInfo: pass def __reduce__(self): - return (pickle, (self.devinfo.serialize(),)) + serialized = self.devinfo.serialize() + # print('SERIALIZE****') + return (pickle, (serialized,)) @property def api(self): return self.devinfo.api.apiname.decode('utf-8') diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 0bc5d73..293f9d0 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -4,23 +4,26 @@ Author: J.A. de Jong Description: Controlling an audio stream in a different process. """ -#import cv2 as cv -from .lasp_multiprocessingpatch import apply_patch -apply_patch() - +import logging import multiprocessing as mp -import time, logging, signal -import numpy as np -from enum import unique, Enum, auto +# import cv2 as cv +import signal +import time from dataclasses import dataclass - -from .lasp_atomic import Atomic -from .lasp_common import AvType -from .device import (Daq, DeviceInfo, DaqConfiguration, DaqChannel) +from enum import Enum, auto, unique from typing import List +import numpy as np -__all__ = ['StreamManager', 'ignoreSigInt'] +from .device import Daq, DaqChannel, DaqConfiguration, DeviceInfo +from .lasp_atomic import Atomic +from .lasp_common import AvType +from .lasp_multiprocessingpatch import apply_patch + +apply_patch() + + +__all__ = ["StreamManager", "ignoreSigInt"] def ignoreSigInt(): @@ -30,6 +33,7 @@ def ignoreSigInt(): """ signal.signal(signal.SIGINT, signal.SIG_IGN) + @dataclass class StreamMetaData: # Sample rate [Hz] @@ -53,6 +57,7 @@ class StreamMsg(Enum): """ First part, control messages that can be send to the stream """ + startStream = auto() stopStream = auto() stopAllStreams = auto() @@ -62,7 +67,6 @@ class StreamMsg(Enum): activateSiggen = auto() deactivateSiggen = auto() - """ Second part, status messages that are send back on all listeners """ @@ -82,11 +86,14 @@ class AudioStream: """ Audio stream. """ - def __init__(self, - avtype: AvType, - devices: list, - daqconfig: DaqConfiguration, - processCallback: callable): + + def __init__( + self, + avtype: AvType, + devices: list, + daqconfig: DaqConfiguration, + processCallback: callable, + ): """ Initializes the audio stream and tries to start it. @@ -106,31 +113,30 @@ class AudioStream: self.processCallback = processCallback matching_devices = [ - device for device in api_devices if - device.name == daqconfig.device_name] + device for device in api_devices if device.name == daqconfig.device_name + ] if len(matching_devices) == 0: - raise RuntimeError('Could not find device {daqconfig.device_name}') + raise RuntimeError("Could not find device {daqconfig.device_name}") # TODO: We pick te first one, what to do if we have multiple matches? # Is that even possible? device = matching_devices[0] - self.daq = Daq(device, daqconfig) + self.daq = Daq(device, daqconfig) en_in_ch = daqconfig.getEnabledInChannels(include_monitor=True) en_out_ch = daqconfig.getEnabledOutChannels() samplerate = self.daq.start(self.streamCallback) self.streammetadata = StreamMetaData( - fs = samplerate, - in_ch = daqconfig.getEnabledInChannels(), - out_ch = daqconfig.getEnabledOutChannels(), - blocksize = self.daq.nFramesPerBlock, - dtype = self.daq.getNumpyDataType() + fs=samplerate, + in_ch=daqconfig.getEnabledInChannels(), + out_ch=daqconfig.getEnabledOutChannels(), + blocksize=self.daq.nFramesPerBlock, + dtype=self.daq.getNumpyDataType(), ) self.running <<= True - def streamCallback(self, indata, outdata, nframes): """ This is called (from a separate thread) for each block @@ -160,12 +166,11 @@ class AvStreamProcess(mp.Process): Different process on which all audio streams are running """ - def __init__(self, - pipe, in_qlist, outq): + def __init__(self, pipe, in_qlist, outq): """ Args: - device: DeviceInfo + device: DeviceInfo """ self.pipe = pipe @@ -194,7 +199,7 @@ class AvStreamProcess(mp.Process): while True: msg, data = self.pipe.recv() - logging.debug(f'Streamprocess obtained message {msg}') + logging.debug(f"Streamprocess obtained message {msg}") if msg == StreamMsg.activateSiggen: self.siggen_activated <<= True @@ -214,21 +219,21 @@ class AvStreamProcess(mp.Process): return elif msg == StreamMsg.getStreamMetaData: - avtype, = data + (avtype,) = data stream = self.streams[avtype] if stream is not None: - self.sendPipeAndAllQueues(StreamMsg.streamMetaData, avtype, - stream.streammetadata) + self.sendPipeAndAllQueues( + StreamMsg.streamMetaData, avtype, stream.streammetadata + ) else: - self.sendPipeAndAllQueues(StreamMsg.streamMetaData, avtype, - None) + self.sendPipeAndAllQueues(StreamMsg.streamMetaData, avtype, None) elif msg == StreamMsg.startStream: avtype, daqconfig = data self.startStream(avtype, daqconfig) elif msg == StreamMsg.stopStream: - avtype, = data + (avtype,) = data self.stopStream(avtype) def startStream(self, avtype: AvType, daqconfig: DaqConfiguration): @@ -238,16 +243,18 @@ class AvStreamProcess(mp.Process): """ self.stopRequiredExistingStreams(avtype) try: - stream = AudioStream(avtype, - self.devices, - daqconfig, self.streamCallback) + stream = AudioStream(avtype, self.devices, daqconfig, self.streamCallback) self.streams[avtype] = stream except Exception as e: - self.sendPipeAndAllQueues(StreamMsg.streamError, avtype, "Error starting stream {str(e)}") + self.sendPipeAndAllQueues( + StreamMsg.streamError, avtype, "Error starting stream {str(e)}" + ) return - self.sendPipeAndAllQueues(StreamMsg.streamStarted, avtype, stream.streammetadata) + self.sendPipeAndAllQueues( + StreamMsg.streamStarted, avtype, stream.streammetadata + ) def stopStream(self, avtype: AvType): """ @@ -260,14 +267,16 @@ class AvStreamProcess(mp.Process): stream = self.streams[avtype] if stream is not None: try: - stream.stop() - self.sendPipeAndAllQueues(StreamMsg.streamStopped, stream.avtype) + stream.stop() + self.sendPipeAndAllQueues(StreamMsg.streamStopped, stream.avtype) except Exception as e: - self.sendPipeAndAllQueues(StreamMsg.streamError, stream.avtype, - "Error occured in stopping stream: {str(e)}") + self.sendPipeAndAllQueues( + StreamMsg.streamError, + stream.avtype, + "Error occured in stopping stream: {str(e)}", + ) self.streams[avtype] = None - def stopRequiredExistingStreams(self, avtype: AvType): """ Stop all existing streams that conflict with the current avtype @@ -280,14 +289,14 @@ class AvStreamProcess(mp.Process): stream_to_stop = (AvType.audio_output, AvType.audio_duplex) elif avtype == AvType.audio_duplex: # All others have to stop - stream_to_stop = list(AvType) # All of them + stream_to_stop = list(AvType) # All of them else: - raise ValueError('BUG') + raise ValueError("BUG") for stream in stream_to_stop: if stream is not None: self.stopStream(stream) - + def stopAllStreams(self): """ Stops all streams @@ -322,12 +331,16 @@ class AvStreamProcess(mp.Process): """ if self.isStreamRunning(): - self.sendPipe(StreamMsg.streamError, None, "A stream is running, cannot rescan DAQ devices.") + self.sendPipe( + StreamMsg.streamError, + None, + "A stream is running, cannot rescan DAQ devices.", + ) return self.devices = Daq.getDeviceInfo() self.sendPipe(StreamMsg.deviceList, self.devices) - + def streamCallback(self, audiostream, indata, outdata): """This is called (from a separate thread) for each audio block.""" # logging.debug('streamCallback()') @@ -336,25 +349,24 @@ class AvStreamProcess(mp.Process): if not self.outq.empty(): newdata = self.outq.get() if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: - msgtxt = 'Invalid output data obtained from queue' + msgtxt = "Invalid output data obtained from queue" logging.fatal(msgtxt) - self.sendPipeAndAllQueues(StreamMsg.streamFatalError, - audiostream.avtype, - msgtxt - ) + self.sendPipeAndAllQueues( + StreamMsg.streamFatalError, audiostream.avtype, msgtxt + ) return 1 outdata[:, :] = newdata[:, None] else: outdata[:, :] = 0 - msgtxt = 'Output signal buffer underflow' + msgtxt = "Output signal buffer underflow" logging.error(msgtxt) - self.sendPipeAndAllQueues(StreamMsg.streamError, - audiostream.avtype, - msgtxt) + self.sendPipeAndAllQueues( + StreamMsg.streamError, audiostream.avtype, msgtxt + ) # Siggen not activated else: - logging.debug('siggen not activated') + logging.debug("siggen not activated") outdata[:, :] = 0 if indata is not None: @@ -391,9 +403,9 @@ class StreamManager: """ Audio and video data stream manager, to which queus can be added """ + def __init__(self): """Open a stream for audio in/output and video input. For audio output, - by default all available channels are opened for outputting data. """ @@ -424,10 +436,7 @@ class StreamManager: self.pipe, child_pipe = mp.Pipe(duplex=True) # Create the stream process - self.streamProcess = AvStreamProcess( - child_pipe, - self.in_qlist, - self.outq) + self.streamProcess = AvStreamProcess(child_pipe, self.in_qlist, self.outq) self.streamProcess.start() def handleMessages(self): @@ -448,7 +457,7 @@ class StreamManager: self.streamstatus[avtype].streammetadata = streammetadata elif msg == StreamMsg.streamStopped: - avtype, = data + (avtype,) = data self.streamstatus[avtype].lastStatus = msg self.streamstatus[avtype].errorTxt = None self.streamstatus[avtype].streammetadata = None @@ -461,7 +470,7 @@ class StreamManager: elif msg == StreamMsg.streamFatalError: avtype, errorTxt = data - logging.critical(f'Streamprocess fatal error: {errorTxt}') + logging.critical(f"Streamprocess fatal error: {errorTxt}") self.cleanup() elif msg == StreamMsg.streamMetaData: @@ -469,13 +478,19 @@ class StreamManager: self.streamstatus[avtype].streammetadata = metadata elif msg == StreamMsg.deviceList: - devices = data + devices, = data + # logging.debug(devices) self.devices = devices def getDeviceList(self): self.handleMessages() return self.devices + def rescanDaqDevices(self): + """ + Output the message to the stream process to rescan the list of devices + """ + self.sendPipe(StreamMsg.scanDaqDevices, None) def getStreamStatus(self, avtype: AvType): """ @@ -495,12 +510,12 @@ class StreamManager: def activateSiggen(self): self.handleMessages() - logging.debug('activateSiggen()') + logging.debug("activateSiggen()") self.sendPipe(StreamMsg.activateSiggen, None) def deactivateSiggen(self): self.handleMessages() - logging.debug('activateSiggen()') + logging.debug("activateSiggen()") self.sendPipe(StreamMsg.deactivateSiggen, None) def addListener(self): @@ -530,8 +545,7 @@ class StreamManager: """Returns the current number of installed listeners.""" return len(self.in_qlist) - def startStream(self, avtype: AvType, daqconfig: DaqConfiguration, - wait=False): + def startStream(self, avtype: AvType, daqconfig: DaqConfiguration, wait=False): """ Start the stream, which means the callbacks are called with stream data (audio/video) @@ -541,7 +555,7 @@ class StreamManager: this function. """ - logging.debug('Starting stream...') + logging.debug("Starting stream...") self.handleMessages() self.sendPipe(StreamMsg.startStream, avtype, daqconfig) if wait: @@ -552,7 +566,6 @@ class StreamManager: if self.streamstatus[avtype].lastStatus != StreamMsg.streamStopped: break - def stopStream(self, avtype: AvType): self.handleMessages() self.sendPipe(StreamMsg.stopStream, avtype) @@ -570,19 +583,17 @@ class StreamManager: """ self.sendPipe(StreamMsg.endProcess, None) - logging.debug('Joining stream process...') + logging.debug("Joining stream process...") self.streamProcess.join() - logging.debug('Joining stream process done') + logging.debug("Joining stream process done") def hasVideo(self): """ Stub, TODO: for future """ return False - def sendPipe(self, msg, *data): """ Send a message with data over the control pipe """ self.pipe.send((msg, data)) - diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index 4485d3a..e624830 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -104,7 +104,7 @@ class SIQtys: @staticmethod def fillComboBox(cb): """ - Fill FreqWeightings to a combobox + Fill to a combobox Args: cb: QComboBox to fill @@ -143,7 +143,7 @@ class CalibrationSettings: @staticmethod def fillComboBox(cb): """ - Fill FreqWeightings to a combobox + Fill Calibration Settings to a combobox Args: cb: QComboBox to fill @@ -300,6 +300,7 @@ class TimeWeighting: infinite = (0, 'Infinite') types_realtime = (ufast, fast, slow, tens, infinite) types_all = (none, uufast, ufast, fast, slow, tens, infinite) + default = fast default_index = 3 default_index_realtime = 1 diff --git a/lasp/lasp_logging.py b/lasp/lasp_logging.py new file mode 100644 index 0000000..0b90e20 --- /dev/null +++ b/lasp/lasp_logging.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +Author: J.A. de Jong + +Description: configure the logging of messages +""" +import logging, sys +# __all__ = ['configureLogging'] + +# global_loglevel = None + +# def configureLogging(level=None): + +# # Oh yeah, one global variable +# global global_loglevel +# if level is None: +# level is global_loglevel +# else: +# global_loglevel = level + +# if level is None: +# raise RuntimeError('Log level has not yet been set application wide') + + diff --git a/scripts/lasp_record b/scripts/lasp_record index 220603e..abf83dc 100755 --- a/scripts/lasp_record +++ b/scripts/lasp_record @@ -1,6 +1,5 @@ #!/usr/bin/python3.8 import sys, logging, os, argparse -FORMAT = "[%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" parser = argparse.ArgumentParser( description='Acquire data and store to a measurement file.' ) @@ -22,6 +21,8 @@ args = parser.parse_args() numeric_level = getattr(logging, args.log.upper(), None) if not isinstance(numeric_level, int): raise ValueError('Invalid log level: %s' % args.loglevel) + +FORMAT = "[%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" logging.basicConfig(format=FORMAT, level=numeric_level) import multiprocessing From 28e935e93b212190b95e7249737eb7c526fdceed Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Fri, 14 May 2021 10:37:52 +0200 Subject: [PATCH 12/28] Bugfix: serialization of DeviceInfo contained errors --- lasp/device/lasp_cppdaq.h | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lasp/device/lasp_cppdaq.h b/lasp/device/lasp_cppdaq.h index e95bda4..d0bc955 100644 --- a/lasp/device/lasp_cppdaq.h +++ b/lasp/device/lasp_cppdaq.h @@ -131,9 +131,9 @@ class DeviceInfo { operator string() const { std::stringstream str; - str << api.apiname + " " << api_specific_devindex - << " number of input channels: " << ninchannels - << " number of output channels: " << noutchannels; + str << api.apiname + " " << api_specific_devindex << endl + << " number of input channels: " << ninchannels << endl + << " number of output channels: " << noutchannels << endl; return str.str(); } @@ -216,13 +216,8 @@ class DeviceInfo { dtype.is_floating = bool(nexti()); devinfo.availableDataTypes.push_back(dtype); } - devinfo.prefDataTypeIndex = nexti(); - N = us(nexti()); - for(us i=0;i Date: Fri, 14 May 2021 11:24:07 +0200 Subject: [PATCH 13/28] Change in DaqConfigurations API for consistency --- lasp/device/lasp_cppdaq.h | 4 ++-- lasp/device/lasp_daqconfig.pyx | 18 ++++++++++++++---- scripts/lasp_record | 2 +- scripts/play_sine | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lasp/device/lasp_cppdaq.h b/lasp/device/lasp_cppdaq.h index d0bc955..2264dad 100644 --- a/lasp/device/lasp_cppdaq.h +++ b/lasp/device/lasp_cppdaq.h @@ -173,9 +173,9 @@ class DeviceInfo { str << prefFramesPerBlockIndex << "\t"; str << availableInputRanges.size() << "\t"; - for(const double& fs: availableInputRanges) { + for(const double& ir: availableInputRanges) { // WARNING: THIS GOES COMPLETELY WRONG WHEN NAMES contain A TAB!!! - str << fs << "\t"; + str << ir << "\t"; } str << prefInputRangeIndex << "\t"; diff --git a/lasp/device/lasp_daqconfig.pyx b/lasp/device/lasp_daqconfig.pyx index 5ffea08..3c9c437 100644 --- a/lasp/device/lasp_daqconfig.pyx +++ b/lasp/device/lasp_daqconfig.pyx @@ -42,11 +42,11 @@ cdef class DaqConfigurations: output_config) @staticmethod - def loadConfigs(): + def loadAllConfigs(): """ - Returns a list of currently available configurations + Returns a dictionary of all configurations presets. The dictionary keys + are the names of the configurations - The first configuration is for input, the second for output """ with lasp_shelve() as sh: configs_json = sh.load('daqconfigs', {}) @@ -55,6 +55,16 @@ cdef class DaqConfigurations: configs[name] = DaqConfigurations.from_json(val) return configs + @staticmethod + def loadConfigs(name: str): + """ + Load a configuration preset, containing input config and output config + """ + + with lasp_shelve() as sh: + configs_json = sh.load('daqconfigs', {}) + return DaqConfigurations.from_json(configs_json[name]) + def saveConfigs(self, name): with lasp_shelve() as sh: configs_json = sh.load('daqconfigs', {}) @@ -62,7 +72,7 @@ cdef class DaqConfigurations: sh.store('daqconfigs', configs_json) @staticmethod - def deleteConfig(name): + def deleteConfigs(name): with lasp_shelve() as sh: configs_json = sh.load('daqconfigs', {}) del configs_json[name] diff --git a/scripts/lasp_record b/scripts/lasp_record index abf83dc..9d2edef 100755 --- a/scripts/lasp_record +++ b/scripts/lasp_record @@ -32,7 +32,7 @@ from lasp import AvType, StreamManager, Recording# configureLogging def main(args): try: streammgr = StreamManager() - configs = DaqConfigurations.loadConfigs() + configs = DaqConfigurations.loadAllConfigs() config_keys = [key for key in configs.keys()] for i, key in enumerate(config_keys): diff --git a/scripts/play_sine b/scripts/play_sine index 227ef37..ffda324 100755 --- a/scripts/play_sine +++ b/scripts/play_sine @@ -31,7 +31,7 @@ if __name__ == '__main__': multiprocessing.set_start_method('forkserver', force=True) logging.info(f'Playing frequency {args.freq} [Hz]') - configs = DaqConfigurations.loadConfigs() + configs = DaqConfigurations.loadAllConfigs() config_keys = [key for key in configs.keys()] for i, key in enumerate(config_keys): From ea24459d4d1f06477144feb763bf2546cefbef5e Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sun, 16 May 2021 14:24:03 +0200 Subject: [PATCH 14/28] Signal generator adjustments to let it run more smoothly. Output buffer is initially filled with some blocks of 0's to give the generator some headstart --- lasp/device/lasp_cpprtaudio.cpp | 5 +- lasp/device/lasp_daq.pyx | 50 ++++++----- lasp/lasp_avstream.py | 147 ++++++++++++++++++++------------ lasp/lasp_siggen.py | 28 ++++-- 4 files changed, 150 insertions(+), 80 deletions(-) diff --git a/lasp/device/lasp_cpprtaudio.cpp b/lasp/device/lasp_cpprtaudio.cpp index 53f9e91..0994c24 100644 --- a/lasp/device/lasp_cpprtaudio.cpp +++ b/lasp/device/lasp_cpprtaudio.cpp @@ -287,7 +287,6 @@ int mycallback( AudioDaq* daq = (AudioDaq*) userData; DataType dtype = daq->dataType(); - /* us neninchannels = daq->neninchannels(); */ us neninchannels_inc_mon = daq->neninchannels(); us nenoutchannels = daq->nenoutchannels(); @@ -343,6 +342,7 @@ int mycallback( us j=0; // OUR buffer channel counter us i=0; // RtAudio channel counter for(us ch=0;ch<=daq->getHighestOutChannel();ch++) { + /* cerr << "Copying from queue... " << endl; */ if(enoutchannels[ch]) { memcpy( &(outputBuffer[i*bytesperchan]), @@ -351,6 +351,7 @@ int mycallback( j++; } else { + /* cerr << "unused output channel in list" << endl; */ memset( &(outputBuffer[i*bytesperchan]),0,bytesperchan); } @@ -364,7 +365,7 @@ int mycallback( } } else { - cerr << "Stream output buffer underflow, zero-ing buffer... " << endl; + cerr << "RtAudio backend: stream output buffer underflow!" << endl; } diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index 15adc23..12115cc 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -85,29 +85,41 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: # print(f'Number of out channels: {noutchannels}') # fprintf(stderr, 'Sleep time: %d us\n', sleeptime_us) + # Fill a couple of empty blocks ot the outQueue + if sd.outQueue: + for i in range(30): + outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) + memset(outbuffer, 0, sizeof(double)*nBytesPerChan*noutchannels) + sd.outQueue.enqueue( outbuffer) + + outbuffer = NULL + + while not sd.stopThread.load(): with gil: - if sd.outQueue and sd.outQueue.size() < 10: - outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) + if sd.outQueue: + while sd.outQueue.size() < 10: + outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) - npy_output = data_to_ndarray( - outbuffer, - nFramesPerBlock, - noutchannels, - sd.npy_format, - False, # Do not transfer ownership - True) # F-contiguous - try: - rval = callback(None, - npy_output, + npy_output = data_to_ndarray( + outbuffer, nFramesPerBlock, - ) + noutchannels, + sd.npy_format, + False, # Do not transfer ownership to the temporary + # Numpy container + True) # F-contiguous + try: + rval = callback(None, + npy_output, + nFramesPerBlock, + ) - except Exception as e: - logging.error('exception in Cython callback for audio output: ', str(e)) - return + except Exception as e: + logging.error('exception in Cython callback for audio output: ', str(e)) + return - sd.outQueue.enqueue( outbuffer) + sd.outQueue.enqueue( outbuffer) if sd.inQueue and not sd.inQueue.empty(): @@ -266,8 +278,8 @@ cdef class Daq: CPPsleep_ms(300) self.daq_device.start( - self.sd.inQueue, - self.sd.outQueue) + self.sd.inQueue, + self.sd.outQueue) return self.daq_device.samplerate() diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 293f9d0..709675f 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -23,7 +23,7 @@ from .lasp_multiprocessingpatch import apply_patch apply_patch() -__all__ = ["StreamManager", "ignoreSigInt"] +__all__ = ['StreamManager', 'ignoreSigInt', 'StreamStatus'] def ignoreSigInt(): @@ -144,6 +144,9 @@ class AudioStream: """ if not self.running(): return 1 + + self.aframectr += 1 + rv = self.processCallback(self, indata, outdata) if rv != 0: self.running <<= False @@ -163,18 +166,26 @@ class AudioStream: class AvStreamProcess(mp.Process): """ - Different process on which all audio streams are running + Different process on which all audio streams are running. """ - def __init__(self, pipe, in_qlist, outq): + def __init__(self, pipe, msg_qlist, indata_qlist, outq): """ Args: - device: DeviceInfo + pipe: Message control pipe on which commands are received. + msg_qlist: List of queues on which stream status and events are + sent. Here, everything is send, except for the captured data + itself. + indata_qlist: List of queues on which captured data from a DAQ is + send. This one gets all events, but also captured data. + outq: On this queue, the stream process receives data to be send as + output to the devices. """ self.pipe = pipe - self.in_qlist = in_qlist + self.msg_qlist = msg_qlist + self.indata_qlist = indata_qlist self.outq = outq self.devices = {} @@ -222,11 +233,12 @@ class AvStreamProcess(mp.Process): (avtype,) = data stream = self.streams[avtype] if stream is not None: - self.sendPipeAndAllQueues( + self.sendAllQueues( StreamMsg.streamMetaData, avtype, stream.streammetadata ) else: - self.sendPipeAndAllQueues(StreamMsg.streamMetaData, avtype, None) + self.sendAllQueues( + StreamMsg.streamMetaData, avtype, None) elif msg == StreamMsg.startStream: avtype, daqconfig = data @@ -243,16 +255,17 @@ class AvStreamProcess(mp.Process): """ self.stopRequiredExistingStreams(avtype) try: - stream = AudioStream(avtype, self.devices, daqconfig, self.streamCallback) + stream = AudioStream(avtype, self.devices, + daqconfig, self.streamCallback) self.streams[avtype] = stream except Exception as e: - self.sendPipeAndAllQueues( + self.sendAllQueues( StreamMsg.streamError, avtype, "Error starting stream {str(e)}" ) return - self.sendPipeAndAllQueues( + self.sendAllQueues( StreamMsg.streamStarted, avtype, stream.streammetadata ) @@ -268,9 +281,10 @@ class AvStreamProcess(mp.Process): if stream is not None: try: stream.stop() - self.sendPipeAndAllQueues(StreamMsg.streamStopped, stream.avtype) + self.sendAllQueues( + StreamMsg.streamStopped, stream.avtype) except Exception as e: - self.sendPipeAndAllQueues( + self.sendAllQueues( StreamMsg.streamError, stream.avtype, "Error occured in stopping stream: {str(e)}", @@ -331,7 +345,7 @@ class AvStreamProcess(mp.Process): """ if self.isStreamRunning(): - self.sendPipe( + self.sendAllQueues( StreamMsg.streamError, None, "A stream is running, cannot rescan DAQ devices.", @@ -339,7 +353,7 @@ class AvStreamProcess(mp.Process): return self.devices = Daq.getDeviceInfo() - self.sendPipe(StreamMsg.deviceList, self.devices) + self.sendAllQueues(StreamMsg.deviceList, self.devices) def streamCallback(self, audiostream, indata, outdata): """This is called (from a separate thread) for each audio block.""" @@ -351,18 +365,18 @@ class AvStreamProcess(mp.Process): if newdata.shape[0] != outdata.shape[0] or newdata.ndim != 1: msgtxt = "Invalid output data obtained from queue" logging.fatal(msgtxt) - self.sendPipeAndAllQueues( + self.sendAllQueues( StreamMsg.streamFatalError, audiostream.avtype, msgtxt ) return 1 outdata[:, :] = newdata[:, None] else: - outdata[:, :] = 0 msgtxt = "Output signal buffer underflow" - logging.error(msgtxt) - self.sendPipeAndAllQueues( + # logging.error(msgtxt) + self.sendAllQueues( StreamMsg.streamError, audiostream.avtype, msgtxt ) + outdata[:, :] = 0 # Siggen not activated else: @@ -370,26 +384,25 @@ class AvStreamProcess(mp.Process): outdata[:, :] = 0 if indata is not None: - self.putAllInQueues(StreamMsg.streamData, indata) + self.sendInQueues(StreamMsg.streamData, indata) return 0 - def putAllInQueues(self, msg, *data): - """ - Put a message and data on all input queues in the queue list - """ - for q in self.in_qlist: + # Wrapper functions that safe some typing, they do not require an + # explanation. + def sendInQueues(self, msg, *data): + for q in self.indata_qlist: # Fan out the input data to all queues in the queue list q.put((msg, data)) - # Wrapper functions that safe some typing, they do not require an - # explanation. - def sendPipe(self, msg, *data): - self.pipe.send((msg, data)) - - def sendPipeAndAllQueues(self, msg, *data): - self.sendPipe(msg, *data) - self.putAllInQueues(msg, *data) + def sendAllQueues(self, msg, *data): + """ + Destined for all queues, including capture data queues + """ + self.sendInQueues(msg, *data) + for q in self.msg_qlist: + # Fan out the input data to all queues in the queue list + q.put((msg, data)) @dataclass @@ -426,8 +439,11 @@ class StreamManager: # which are in the manager list get a new object id. The local list is # used to find the index in the manager queues list upon deletion by # 'removeListener()' - self.in_qlist = self.manager.list([]) - self.in_qlist_local = [] + self.indata_qlist = self.manager.list([]) + self.indata_qlist_local = [] + + self.msg_qlist = self.manager.list([]) + self.msg_qlist_local = [] # Queue used for signal generator data self.outq = self.manager.Queue() @@ -435,19 +451,23 @@ class StreamManager: # Messaging pipe self.pipe, child_pipe = mp.Pipe(duplex=True) + # This is the queue on which this class listens for stream process + # messages. + self.our_msgqueue = self.addMsgQueueListener() + # Create the stream process - self.streamProcess = AvStreamProcess(child_pipe, self.in_qlist, self.outq) + self.streamProcess = AvStreamProcess(child_pipe, + self.msg_qlist, + self.indata_qlist, self.outq) self.streamProcess.start() def handleMessages(self): """ - Handle messages that are still on the pipe. - """ # logging.debug('StreamManager::handleMessages()') - while self.pipe.poll(): - msg, data = self.pipe.recv() + while not self.our_msgqueue.empty(): + msg, data = self.our_msgqueue.get() logging.debug(f'StreamManager obtained message {msg}') if msg == StreamMsg.streamStarted: avtype, streammetadata = data @@ -466,7 +486,9 @@ class StreamManager: avtype, errorTxt = data if avtype is not None: self.streamstatus[avtype].lastStatus = msg - self.streamstatus[avtype].errorTxt = None + self.streamstatus[avtype].errorTxt = errorTxt + + logging.debug(f'Message: {errorTxt}') elif msg == StreamMsg.streamFatalError: avtype, errorTxt = data @@ -515,35 +537,51 @@ class StreamManager: def deactivateSiggen(self): self.handleMessages() - logging.debug("activateSiggen()") + logging.debug("deactivateSiggen()") self.sendPipe(StreamMsg.deactivateSiggen, None) - def addListener(self): + def addMsgQueueListener(self): + """ + Add a listener queue to the list of message queues, and return the + queue. + + Returns: + listener queue + """ + newqueue = self.manager.Queue() + self.msg_qlist.append(newqueue) + self.msg_qlist_local.append(newqueue) + return newqueue + + def removeMsgQueueListener(self, queue): + """ + Remove an input listener queue from the message queue list. + """ + # Uses a local queue list to find the index, based on the queue + idx = self.msg_qlist_local.index(queue) + del self.msg_qlist_local[idx] + del self.msg_qlist[idx] + + def addInQueueListener(self): """ Add a listener queue to the list of queues, and return the queue. Returns: listener queue """ - self.handleMessages() newqueue = self.manager.Queue() - self.in_qlist.append(newqueue) - self.in_qlist_local.append(newqueue) + self.indata_qlist.append(newqueue) + self.indata_qlist_local.append(newqueue) return newqueue - def removeListener(self, queue): + def removeInQueueListener(self, queue): """ Remove an input listener queue from the queue list. """ # Uses a local queue list to find the index, based on the queue - self.handleMessages() - idx = self.in_qlist_local.index(queue) - del self.in_qlist[idx] - del self.in_qlist_local[idx] - - def nListeners(self): - """Returns the current number of installed listeners.""" - return len(self.in_qlist) + idx = self.indata_qlist_local.index(queue) + del self.indata_qlist[idx] + del self.indata_qlist_local[idx] def startStream(self, avtype: AvType, daqconfig: DaqConfiguration, wait=False): """ @@ -592,6 +630,7 @@ class StreamManager: Stub, TODO: for future """ return False + def sendPipe(self, msg, *data): """ Send a message with data over the control pipe diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index 03cc6cf..48b5bdb 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -25,11 +25,12 @@ QUEUE_BUFFER_TIME = 0.5 # The amount of time used in the queues for buffering __all__ = ["SignalType", "NoiseType", "SiggenMessage", "SiggenData", "Siggen"] +@unique class SignalType(Enum): - Periodic = auto() - Noise = auto() - Sweep = auto() - Meas = auto() + Periodic = 0 + Noise = 1 + Sweep = 2 + Meas = 3 @unique @@ -64,6 +65,7 @@ class SiggenMessage(Enum): endProcess = auto() # Stop and quit the signal generator adjustVolume = auto() # Adjust the volume newEqSettings = auto() # Forward new equalizer settings + newSiggenData = auto() # Forward new equalizer settings ready = auto() # Send out once, once the signal generator is ready with # pre-generating data. @@ -132,6 +134,12 @@ class SiggenProcess(mp.Process): Args: siggendata: SiggenData. Metadata to create a new signal generator. """ + + logging.debug('newSiggen') + # Cleanup old data queue. + while not self.dataq.empty(): + self.dataq.get() + fs = siggendata.fs nframes_per_block = siggendata.nframes_per_block level_dB = siggendata.level_dB @@ -220,6 +228,7 @@ class SiggenProcess(mp.Process): self.pipe.send((SiggenMessage.ready, None)) while True: + # Wait here for a while, to check for messages to consume if self.pipe.poll(timeout=QUEUE_BUFFER_TIME / 2): msg, data = self.pipe.recv() if msg == SiggenMessage.endProcess: @@ -232,6 +241,9 @@ class SiggenProcess(mp.Process): elif msg == SiggenMessage.newEqSettings: eqdata = data eq = self.newEqualizer(eqdata) + elif msg == SiggenMessage.newSiggenData: + siggendata = data + self.siggen = self.newSiggen(siggendata) else: self.pipe.send( SiggenMessage.error, "BUG: Generator caught unknown message. Quiting" @@ -272,7 +284,7 @@ class Siggen: logging.debug('Signal generator ready') elif msg == SiggenMessage.error: e = data - raise RuntimeError('Signal generator exception: {str(e)}') + raise RuntimeError(f'Signal generator exception: {str(e)}') else: # Done, or something if msg == SiggenMessage.done: @@ -292,6 +304,12 @@ class Siggen: def setEqData(self, eqdata): self.pipe.send((SiggenMessage.newEqSettings, eqdata)) + def setSiggenData(self, siggendata: SiggenData): + """ + Updates the whole signal generator, based on new signal generator data. + """ + self.pipe.send((SiggenMessage.newSiggenData, siggendata)) + def handle_msgs(self): while self.pipe.poll(): msg, data = self.pipe.recv() From c792806fad5453866cdc4973b394f8dc7a4a4980 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sun, 16 May 2021 16:45:44 +0200 Subject: [PATCH 15/28] There is still a small tick at the start of the signal generator. Otherwise, it is working properly --- lasp/device/lasp_daq.pyx | 37 +++++++++++++++++++++---------------- lasp/lasp_avstream.py | 23 ++++++++++++++++++----- lasp/lasp_siggen.py | 10 ++++------ 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index 12115cc..ff9fcc5 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -37,6 +37,7 @@ cdef getNumpyDataType(DataType& dt): else: raise ValueError('Unknown data type') +DEF QUEUE_BUFFER_TIME = 0.5 ctypedef struct PyStreamData: PyObject* pyCallback @@ -44,6 +45,10 @@ ctypedef struct PyStreamData: # Flag used to pass the stopThread. atomic[bool] stopThread + # Flag to indicate that the signal generator queue has been filled for the + # first time. + atomic[bool] ready + # Number of frames per block unsigned nFramesPerBlock @@ -75,30 +80,29 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: unsigned nBytesPerChan= sd.nBytesPerChan unsigned nFramesPerBlock= sd.nFramesPerBlock - double sleeptime = ( sd.nFramesPerBlock)/(4*sd.samplerate); + double sleeptime = ( sd.nFramesPerBlock)/(8*sd.samplerate); + # Sleep time in microseconds us sleeptime_us = (sleeptime*1e6); + us nblocks_buffer = max(1, (QUEUE_BUFFER_TIME * sd.samplerate / + sd.nFramesPerBlock)) + with gil: npy_format = cnp.NPY_FLOAT64 callback = sd.pyCallback # print(f'Number of input channels: {ninchannels}') # print(f'Number of out channels: {noutchannels}') # fprintf(stderr, 'Sleep time: %d us\n', sleeptime_us) - - # Fill a couple of empty blocks ot the outQueue - if sd.outQueue: - for i in range(30): - outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) - memset(outbuffer, 0, sizeof(double)*nBytesPerChan*noutchannels) - sd.outQueue.enqueue( outbuffer) - - outbuffer = NULL - + for i in range(nblocks_buffer): + outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) + memset(outbuffer, 0, sizeof(double)*nBytesPerChan*noutchannels) + sd.outQueue.enqueue( outbuffer) + sd.ready.store(True) while not sd.stopThread.load(): with gil: if sd.outQueue: - while sd.outQueue.size() < 10: + while sd.outQueue.size() < nblocks_buffer: outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) npy_output = data_to_ndarray( @@ -121,7 +125,6 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: sd.outQueue.enqueue( outbuffer) - if sd.inQueue and not sd.inQueue.empty(): # Waiting indefinitely on the queue... inbuffer = sd.inQueue.dequeue() @@ -248,6 +251,8 @@ cdef class Daq: self.sd.stopThread.store(False) + self.sd.ready.store(False) + self.sd.inQueue = NULL self.sd.outQueue = NULL @@ -273,9 +278,9 @@ cdef class Daq: with nogil: self.sd.thread = new CPPThread[void*, void (*)(void*)](audioCallbackPythonThreadFunction, self.sd) - - # Allow stream stome time to start - CPPsleep_ms(300) + while not self.sd.ready.load(): + # Allow stream stome time to start + CPPsleep_ms(100) self.daq_device.start( self.sd.inQueue, diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 709675f..88299e6 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -78,7 +78,11 @@ class StreamMsg(Enum): streamData = auto() # Error messages + # Some error occured, which mostly leads to a stop of the stream streamError = auto() + # An error occured, but we recovered + streamTemporaryError = auto() + # A fatal error occured. This leads to serious errors in the application streamFatalError = auto() @@ -254,10 +258,18 @@ class AvStreamProcess(mp.Process): """ self.stopRequiredExistingStreams(avtype) + # Empty the queue from existing stuff (puts the signal generator + # directly in action!). + if avtype in (AvType.audio_duplex, AvType.audio_output): + while not self.outq.empty(): + self.outq.get() try: stream = AudioStream(avtype, self.devices, daqconfig, self.streamCallback) self.streams[avtype] = stream + self.sendAllQueues( + StreamMsg.streamStarted, avtype, stream.streammetadata + ) except Exception as e: self.sendAllQueues( @@ -265,9 +277,6 @@ class AvStreamProcess(mp.Process): ) return - self.sendAllQueues( - StreamMsg.streamStarted, avtype, stream.streammetadata - ) def stopStream(self, avtype: AvType): """ @@ -371,10 +380,10 @@ class AvStreamProcess(mp.Process): return 1 outdata[:, :] = newdata[:, None] else: - msgtxt = "Output signal buffer underflow" + msgtxt = "Signal generator buffer underflow. Signal generator cannot keep up with data generation." # logging.error(msgtxt) self.sendAllQueues( - StreamMsg.streamError, audiostream.avtype, msgtxt + StreamMsg.streamTemporaryError, audiostream.avtype, msgtxt ) outdata[:, :] = 0 @@ -487,7 +496,11 @@ class StreamManager: if avtype is not None: self.streamstatus[avtype].lastStatus = msg self.streamstatus[avtype].errorTxt = errorTxt + logging.debug(f'Message: {errorTxt}') + elif msg == StreamMsg.streamTemporaryError: + avtype, errorTxt = data + if avtype is not None: logging.debug(f'Message: {errorTxt}') elif msg == StreamMsg.streamFatalError: diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index 48b5bdb..34e95e5 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -136,9 +136,6 @@ class SiggenProcess(mp.Process): """ logging.debug('newSiggen') - # Cleanup old data queue. - while not self.dataq.empty(): - self.dataq.get() fs = siggendata.fs nframes_per_block = siggendata.nframes_per_block @@ -167,6 +164,7 @@ class SiggenProcess(mp.Process): else: raise ValueError(f"Not implemented signal type: {signaltype}") + logging.debug('newSiggen') return siggen def generate(self): @@ -229,18 +227,18 @@ class SiggenProcess(mp.Process): while True: # Wait here for a while, to check for messages to consume - if self.pipe.poll(timeout=QUEUE_BUFFER_TIME / 2): + if self.pipe.poll(timeout=QUEUE_BUFFER_TIME / 4): msg, data = self.pipe.recv() if msg == SiggenMessage.endProcess: logging.debug("Signal generator caught 'endProcess' message. Exiting.") return 0 elif msg == SiggenMessage.adjustVolume: - logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") level_dB = data + logging.debug(f"Signal generator caught 'adjustVolume' message. New volume = {level_dB:.1f} dB FS") self.siggen.setLevel(level_dB) elif msg == SiggenMessage.newEqSettings: eqdata = data - eq = self.newEqualizer(eqdata) + self.eq = self.newEqualizer(eqdata) elif msg == SiggenMessage.newSiggenData: siggendata = data self.siggen = self.newSiggen(siggendata) From 968a3d2b6a28d5505e3f979de303b0c1116296fc Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sun, 16 May 2021 21:56:00 +0200 Subject: [PATCH 16/28] Made eqdata optional in SiggenData --- lasp/lasp_siggen.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index 34e95e5..bbff602 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -87,9 +87,6 @@ class SiggenData: # The data type to output dtype: np.dtype - # Settings for the equalizer etc - eqdata: object # Equalizer data - # Level of output signal [dBFS]el level_dB: float @@ -97,6 +94,8 @@ class SiggenData: signaltype: SignalType signaltypedata: Tuple = None + # Settings for the equalizer etc + eqdata: object = None # Equalizer data class SiggenProcess(mp.Process): """ From f4e36882222bbcc45a554c557f366c9b129d3584 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Tue, 18 May 2021 14:51:41 +0200 Subject: [PATCH 17/28] Small interface changes for smoothing function --- lasp/__init__.py | 1 + lasp/lasp_common.py | 17 ++- lasp/tools/__init__.py | 5 +- lasp/tools/config.py | 52 --------- lasp/tools/plot.py | 210 ------------------------------------- lasp/tools/report_tools.py | 198 ---------------------------------- lasp/tools/tools.py | 120 ++++++++++++++------- lasp/wrappers.pyx | 3 +- 8 files changed, 94 insertions(+), 512 deletions(-) delete mode 100644 lasp/tools/config.py delete mode 100644 lasp/tools/plot.py delete mode 100644 lasp/tools/report_tools.py diff --git a/lasp/__init__.py b/lasp/__init__.py index fdabeb9..85eb2d1 100644 --- a/lasp/__init__.py +++ b/lasp/__init__.py @@ -9,3 +9,4 @@ from .lasp_slm import * from .lasp_record import * from .lasp_siggen import * from .lasp_weighcal import * +from .tools import * diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index e624830..2377a39 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -18,7 +18,7 @@ Common definitions used throughout the code. __all__ = [ 'P_REF', 'FreqWeighting', 'TimeWeighting', 'getTime', 'getFreq', 'Qty', - 'SIQtys', + 'SIQtys', 'Window', 'lasp_shelve', 'this_lasp_shelve', 'W_REF', 'U_REF', 'I_REF', 'dBFS_REF', 'AvType' ] @@ -263,16 +263,14 @@ class this_lasp_shelve(Shelve): return os.path.join(lasp_appdir, f'{node}_config.shelve') -class Window: +@unique +class Window(Enum): hann = (wWindow.hann, 'Hann') hamming = (wWindow.hamming, 'Hamming') rectangular = (wWindow.rectangular, 'Rectangular') bartlett = (wWindow.bartlett, 'Bartlett') blackman = (wWindow.blackman, 'Blackman') - types = (hann, hamming, rectangular, bartlett, blackman) - default = 0 - @staticmethod def fillComboBox(cb): """ @@ -282,12 +280,13 @@ class Window: cb: QComboBox to fill """ cb.clear() - for tw in Window.types: - cb.addItem(tw[1], tw) - cb.setCurrentIndex(Window.default) + for w in list(Window): + cb.addItem(w.value[1], w) + cb.setCurrentIndex(0) + @staticmethod def getCurrent(cb): - return Window.types[cb.currentIndex()] + return list(Window)[cb.currentIndex()] class TimeWeighting: diff --git a/lasp/tools/__init__.py b/lasp/tools/__init__.py index 04da443..5a820b5 100644 --- a/lasp/tools/__init__.py +++ b/lasp/tools/__init__.py @@ -1,6 +1,3 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from .config import init_backend -from lasp.lasp_common import FreqWeighting, TimeWeighting -__all__ = ['init_backend', - 'FreqWeighting', 'TimeWeighting'] +from .tools import * diff --git a/lasp/tools/config.py b/lasp/tools/config.py deleted file mode 100644 index 4e7a3f5..0000000 --- a/lasp/tools/config.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""! -Author: J.A. de Jong - ASCEE - -Description: -""" -__all__ = ['init_backend', 'getReportQuality'] - -_report_quality = False -_init = False - - -def init_matplotlib(report_quality=False): - global _init - if not _init: - _init = True - print('Initializing matplotlib...') - preamble = [ - r'\usepackage{libertine-type1}' - r'\usepackage[libertine]{newtxmath}' - # r'\usepackage{fontspec}', - # r'\setmainfont{Libertine}', - ] - params = { - 'font.family': 'serif', - 'text.usetex': True, - 'text.latex.unicode': True, - 'pgf.rcfonts': False, - 'pgf.texsystem': 'pdflatex', - 'pgf.preamble': preamble, - } - import matplotlib - matplotlib.rcParams.update(params) - global _report_quality - _report_quality = report_quality - - -def init_backend(report_quality=False): - global _init - if not _init: - import matplotlib - matplotlib.use('Qt5Agg', warn=False, force=True) - init_matplotlib(report_quality) - _init = True - import matplotlib.pyplot as plt - plt.ion() - - -def getReportQuality(): - global _report_quality - return _report_quality diff --git a/lasp/tools/plot.py b/lasp/tools/plot.py deleted file mode 100644 index 660edc9..0000000 --- a/lasp/tools/plot.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""! -Author: J.A. de Jong - ASCEE - -Description: -""" -__all__ = ['Figure', 'Bode', 'PS', 'PSD'] - -from .config import getReportQuality -import matplotlib.pyplot as plt -import numpy as np -from cycler import cycler -from lasp.lasp_common import (PLOT_COLORS_LIST, PLOT_NOCOLORS_LIST, - DEFAULT_FIGSIZE_H, DEFAULT_FIGSIZE_W) - - -class Figure: - def __init__(self, **kwargs): - ncols = kwargs.pop('ncols', 1) - nrows = kwargs.pop('nrows', 1) - color = kwargs.pop('color', (PLOT_NOCOLORS_LIST if getReportQuality() - else PLOT_COLORS_LIST)) - if isinstance(color, bool): - if color: - color = PLOT_COLORS_LIST - else: - color = PLOT_NOCOLORS_LIST - colors = cycler('color', color) - - figsize = kwargs.pop('figsize', (DEFAULT_FIGSIZE_W, DEFAULT_FIGSIZE_H)) - self._f = plt.figure(figsize=figsize) - - marker = kwargs.pop('marker', False) - if marker: - markers = cycler(marker=['o', 's', 'D', 'X', 'v', '^', '<', '>']) - else: - markers = cycler(marker=[None]*8) - - linewidths = cycler(linewidth=[1, 2, 1, 2, 2, 3, 2, 1]) - - linestyles = cycler( - linestyle=['-', '-', '--', ':', '-', '--', ':', '-.', ]) - - self._ax = [] - self._legend = {} - for row in range(nrows): - self._legend[row] = {} - for col in range(ncols): - self._legend[row][col] = [] - ax = self._f.add_subplot(100*nrows - + 10*ncols - + (row*ncols + col)+1) - ax.set_prop_cycle( - colors+linestyles+markers+linewidths) - self._ax.append(ax) - self._ncols = ncols - self._cur_ax = self._ax[0] - self._cur_col = 0 - self._cur_row = 0 - - self._zorder = -1 - - def setAx(self, row, col): - self._cur_ax = self._ax[row*self._ncols+col] - - @property - def fig(self): - return self._f - - def markup(self): - for ax in self._ax: - ax.grid(True, 'both') - self._zorder -= 1 - self.fig.show() - - def vline(self, x): - self._ax[0].axvline(x) - - def plot(self, *args, **kwargs): - line = self._cur_ax.plot(*args, **kwargs, zorder=self._zorder) - self.markup() - return line - - def loglog(self, *args, **kwargs): - line = self._cur_ax.loglog(*args, **kwargs, zorder=self._zorder) - self.markup() - return line - - def semilogx(self, *args, **kwargs): - line = self._cur_ax.semilogx(*args, **kwargs, zorder=self._zorder) - self.markup() - return line - - def xlabel(self, *args, **kwargs): - all_ax = kwargs.pop('all_ax', False) - if all_ax: - for ax in self._ax: - ax.set_xlabel(*args, **kwargs) - else: - self._cur_ax.set_xlabel(*args, **kwargs) - - def ylabel(self, *args, **kwargs): - all_ax = kwargs.pop('all_ax', False) - if all_ax: - for ax in self._ax: - ax.set_ylabel(*args, **kwargs) - else: - self._cur_ax.set_ylabel(*args, **kwargs) - - def legend(self, leg, *args, **kwargs): - # all_ax = kwargs.pop('all_ax', False) - if isinstance(leg, list) or isinstance(leg, tuple): - self._legend[self._cur_col][self._cur_col] = list(leg) - else: - self._legend[self._cur_col][self._cur_col].append(leg) - self._cur_ax.legend(self._legend[self._cur_col][self._cur_col]) - - def savefig(self, *args, **kwargs): - self.fig.savefig(*args, **kwargs) - - def xlim(self, *args, **kwargs): - all_ax = kwargs.pop('all_ax', False) - if all_ax: - for ax in self._ax: - ax.set_xlim(*args, **kwargs) - else: - self._cur_ax.set_xlim(*args, **kwargs) - - def ylim(self, *args, **kwargs): - all_ax = kwargs.pop('all_ax', False) - if all_ax: - for ax in self._ax: - ax.set_ylim(*args, **kwargs) - else: - self._cur_ax.set_ylim(*args, **kwargs) - - def title(self, *args, **kwargs): - self._cur_ax.set_title(*args, **kwargs) - - def xticks(self, ticks): - for ax in self._ax: - ax.set_xticks(ticks) - - def close(self): - plt.close(self._f) - - def xscale(self, scale): - for ax in self._ax: - ax.set_xscale(scale) - - -class Bode(Figure): - def __init__(self, *args, **kwargs): - super().__init__(naxes=2, *args, **kwargs) - - def add(self, freq, phasor, qtyname='G', **kwargs): - L = 20*np.log10(np.abs(phasor)) - phase = np.angle(phasor)*180/np.pi - self.semilogx(freq, L, axno=0, **kwargs) - self.semilogx(freq, phase, axno=1, **kwargs) - self.ylabel('$L$ [%s] [dB]' % qtyname, axno=0) - self.ylabel(fr'$\angle$ {qtyname} [$^\circ$]', axno=1) - self.xlabel('Frequency [Hz]', axno=1) - - -class PS(Figure): - def __init__(self, ref, *args, **kwargs): - super().__init__(naxes=1, *args, **kwargs) - self.ref = ref - - def add(self, fs, freq, ps, qtyname='C', **kwargs): - - overall = np.sum(ps) - print(overall) - overall_db = 10*np.log10(overall/self.ref**2) - L = 10*np.log10(np.abs(ps)/self.ref**2) - - self.semilogx(freq, L, **kwargs) - # self.plot(freq,L,**kwargs) - self.ylabel('Level [dB re 20$\\mu$Pa]') - self.xlabel('Frequency [Hz]') - self.legend('%s. Overall SPL = %0.1f dB SPL' % (qtyname, overall_db)) - - -class PSD(PS): - def __init__(self, ref, *args, **kwargs): - """ - Initialize a PSD plot - - Args: - ref: Reference value for level in dB's - - """ - super().__init__(ref, *args, **kwargs) - - def add(self, fs, freq, ps, qtyname='C', **kwargs): - df = freq[1]-freq[0] - nfft = fs/df - df = fs/nfft - psd = ps / df - - overall = np.sum(np.abs(ps), axis=0) - overall_db = 10*np.log10(overall/self.ref**2) - L = 10*np.log10(abs(psd)/self.ref**2) - - self.semilogx(freq, L, **kwargs) - self.ylabel('$L$ [%s] [dB re %0.0e]' % (qtyname, self.ref)) - self.xlabel('Frequency [Hz]') - self.legend('%s. Overall SPL = %0.1f dB SPL' % (qtyname, overall_db)) diff --git a/lasp/tools/report_tools.py b/lasp/tools/report_tools.py deleted file mode 100644 index e17db31..0000000 --- a/lasp/tools/report_tools.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Author: J.A. de Jong - ASCEE - -Description: backend tools for easy postprocessing of measurements -""" -from .plot import Figure -from lasp.wrappers import AvPowerSpectra -from lasp.lasp_measurement import Measurement -from lasp.lasp_common import (FreqWeighting, TimeWeighting, - getFreq, getTime, Window, P_REF) -from lasp.lasp_weighcal import WeighCal -from lasp.lasp_octavefilter import OctaveFilterBank, ThirdOctaveFilterBank -from lasp.lasp_figuredialog import FigureDialog -from lasp.lasp_figure import Plotable, PlotOptions -from lasp.lasp_slm import SLM -import numpy as np -import sys - - -def close(): - import matplotlib.pyplot as plt - plt.close('all') - - -def PSPlot(fn_list, **kwargs): - """ - Create a power spectral density plot, ASCEE style - - Args: - fn_list: list of measurement filenames to plot PSD for - fw: - fs: - nfft: - xscale: - yscale: - """ - - fw = kwargs.pop('fw', FreqWeighting.A) - nfft = kwargs.pop('nfft', 2048) - xscale = kwargs.pop('xscale', 'log') - yscale = kwargs.pop('yscale', 'PSD') - ylim = kwargs.pop('ylim', (0, 100)) - xlim = kwargs.pop('xlim', (100, 10000)) - f = Figure(**kwargs) - - print(kwargs) - if xscale == 'log': - pltfun = f.semilogx - else: - pltfun = f.plot - - for fn in fn_list: - meas = Measurement(fn) - fs = meas.samplerate - data = meas.praw() - aps = AvPowerSpectra(nfft, 1, 50.) - weighcal = WeighCal(fw, nchannels=1, - fs=fs) - weighted = weighcal.filter_(data) - ps = aps.addTimeData(weighted) - freq = getFreq(fs, nfft) - if yscale == 'PSD': - df = fs/nfft - type_str = '/$\\sqrt{\\mathrm{Hz}}$' - elif yscale == 'PS': - df = 1. - type_str = '' - else: - raise ValueError("'type' should be either 'PS' or 'PSD'") - - psd_log = 10*np.log10(ps[:, 0, 0].real/df/2e-5**2) - - pltfun(freq, psd_log) - - f.xlabel('Frequency [Hz]') - f.ylabel(f'Level [dB({fw[0]}) re (20$\\mu$ Pa){type_str}') - f.ylim(ylim) - f.xlim(xlim) - return f - - -def PowerSpectra(fn_list, **kwargs): - nfft = kwargs.pop('nfft', 2048) - window = kwargs.pop('window', Window.hann) - fw = kwargs.pop('fw', FreqWeighting.A) - overlap = kwargs.pop('overlap', 50.) - ptas = [] - for fn in fn_list: - meas = Measurement(fn) - fs = meas.samplerate - weighcal = WeighCal(fw, nchannels=1, - fs=fs, calfile=None) - praw = meas.praw() - weighted = weighcal.filter_(praw) - aps = AvPowerSpectra(nfft, 1, overlap, window[0]) - result = aps.addTimeData(weighted)[:, 0, 0].real - pwr = 10*np.log10(result/P_REF**2) - - freq = getFreq(fs, nfft) - ptas.append(Plotable(freq, pwr)) - - pto = PlotOptions.forPower() - pto.ylabel = f'L{fw[0]} [dB({fw[0]})]' - return ptas - - -def Levels(fn_list, **kwargs): - - bank = kwargs.pop('bank', 'third') - fw = kwargs.pop('fw', FreqWeighting.A) - tw = kwargs.pop('tw', TimeWeighting.fast) - xmin_txt = kwargs.pop('xmin', '100') - xmax_txt = kwargs.pop('xmax', '16k') - - levels = [] - leveltype = 'eq' - - for fn in fn_list: - meas = Measurement(fn) - fs = meas.samplerate - weighcal = WeighCal(fw, nchannels=1, - fs=fs, calfile=None) - praw = meas.praw() - weighted = weighcal.filter_(praw) - if bank == 'third': - filt = ThirdOctaveFilterBank(fs) - xmin = filt.nominal_txt_tox(xmin_txt) - xmax = filt.nominal_txt_tox(xmax_txt) - - elif bank == 'overall': - slm = SLM(meas.samplerate, tw) - slm.addData(weighted) - levels.append( - Plotable(' ', slm.Lmax if leveltype == 'max' else slm.Leq, - name=meas.name)) - continue - else: - raise NotImplementedError() - - # Octave bands - # filt = OctaveFilterBank(fs) - filtered_out = filt.filter_(weighted) - level = np.empty((xmax - xmin + 1)) - xlabels = [] - for i, x in enumerate(range(xmin, xmax+1)): - nom = filt.nominal_txt(x) - xlabels.append(nom) - filt_x = filtered_out[nom]['data'] - slm = SLM(filt.fs, tw) - slm.addData(filt_x) - leveli = slm.Lmax if leveltype == 'max' else slm.Leq - level[i] = leveli - levels.append(Plotable(xlabels, level, name=meas.name)) - return levels - - -def LevelDifference(levels): - assert len(levels) == 2 - return Plotable(name='Difference', x=levels[0].x, - y=levels[1].y-levels[0].y) - - -def LevelFigure(levels, show=True, **kwargs): - figtype = kwargs.pop('figtype', 'bar') - from PySide.QtGui import QApplication, QFont - app = QApplication.instance() - if not app: - app = QApplication(sys.argv) - app.setFont(QFont('Linux Libertine')) - size = kwargs.pop('size', (1200, 600)) - if figtype == 'bar': - opts = PlotOptions.forLevelBars() - elif figtype == 'line': - opts = PlotOptions.forPower() - else: - raise RuntimeError('figtype should be either line or bar') - opts.ylim = kwargs.pop('ylim', (0, 100)) - opts.ylabel = kwargs.pop('ylabel', 'LAeq [dB(A)]') - opts.xlabel = kwargs.pop('xlabel', None) - opts.legend = kwargs.pop('legend', [level.name for level in levels]) - opts.legendpos = kwargs.pop('legendpos', None) - opts.title = kwargs.pop('title', None) - - def Plotter(ptas, pto): - fig = FigureDialog(None, None, pto, figtype) - for pta in ptas: - fig.fig.add(pta) - return fig - - fig = Plotter(levels, opts) - if show: - fig.show() - fig.resize(*size) - if show: - app.exec_() - return fig diff --git a/lasp/tools/tools.py b/lasp/tools/tools.py index 08ff96d..57721ca 100644 --- a/lasp/tools/tools.py +++ b/lasp/tools/tools.py @@ -1,24 +1,58 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Created on Thu May 6 14:49:03 2021 +Author: C. Jansen, J.A. de Jong - ASCEE V.O.F. -@author: Casper - -Smooth data in the frequency domain +Smooth data in the frequency domain. TODO: This function is rather slow as it +used Python for loops. The implementations should be speed up in the near +future. """ +from enum import Enum, unique + +__all__ = ['SmoothingType', 'smoothSpectralData', 'SmoothingWidth'] + + +@unique +class SmoothingWidth(Enum): + none = (0, 'No smoothing') + # three = (3, '1/3th octave smoothing') + six = (6, '1/6th octave smoothing') + twelve = (12, '1/12th octave smoothing') + twfo = (24, '1/24th octave smoothing') + ftei = (48, '1/48th octave smoothing') + + @staticmethod + def fillComboBox(cb): + """ + Fill Windows to a combobox + + Args: + cb: QComboBox to fill + """ + cb.clear() + for w in list(SmoothingWidth): + cb.addItem(w.value[1], w) + cb.setCurrentIndex(0) + + @staticmethod + def getCurrent(cb): + return list(SmoothingWidth)[cb.currentIndex()] + +class SmoothingType: + levels = 'l', 'Levels' + # tf = 'tf', 'Transfer function', + ps = 'ps', '(Auto) powers' + # TO DO: check if everything is correct # TO DO: add possibility to insert data that is not lin spaced in frequency -import matplotlib.pyplot as plt import numpy as np from scipy.signal.windows import gaussian - -# %% Smoothing function -def oct_smooth(f, M, Noct, dB=False): +def smoothSpectralData(freq, M, sw: SmoothingWidth, + st: SmoothingType = SmoothingType.levels): """ Apply fractional octave smoothing to magnitude data in frequency domain. Smoothing is performed to power, using a sliding Gaussian window with @@ -30,49 +64,57 @@ def oct_smooth(f, M, Noct, dB=False): side. The deviation is largest when Noct is small (e.g. coarse smoothing). Casper Jansen, 07-05-2021 - Parameters - ---------- - f : float - frequencies of data points [Hz] - equally spaced - M : float - magnitude of data points [- or dB, specify in paramater 'dB'] - Noct : int - smoothing strength: Noct=12 means 1/12 octave smoothing - dB : Bool - True if [M]=dB, False if [M]=absolute + Args: + freq: array of frequencies of data points [Hz] - equally spaced + M: array of either power, transfer functin or dB points. Depending on + the smoothing type `st`, the smoothing is applied. - Returns - ------- - f : float - frequencies of data points [Hz] - Msm : float - smoothed magnitude of data points + Returns: + freq : array frequencies of data points [Hz] + Msm : float smoothed magnitude of data points """ + # TODO: Make this function multi-dimensional array aware. # Settings tr = 2 # truncate window after 2x std # Safety - assert Noct > 0, '\'Noct\' must be absolute positive' + Noct = sw.value[0] + assert Noct > 0, "'Noct' must be absolute positive" if Noct < 1: raise Warning('Check if \'Noct\' is entered correctly') - assert len(f)==len(M), 'f and M should have equal length' - if not dB: assert np.min(M) >= 0, 'absolute magnitude M cannot be negative' + assert len(freq)==len(M), 'f and M should have equal length' + if st == SmoothingType.ps: + assert np.min(M) >= 0, 'absolute magnitude M cannot be negative' + if st == SmoothingType.levels and isinstance(M.dtype, complex): + raise RuntimeError('Decibel input should be real-valued') # Initialize - L = len(M) # number of data points - P = 10**(M/10) if dB else M**2 # convert magnitude --> power - Psm = np.zeros(L) # smoothed power - to be calculated - x0 = 1 if f[0]==0 else 0 # skip first data point if zero frequency - df = f[1] - f[0] # frequency step + L = M.shape[0] # number of data points + + P = M + if st == SmoothingType.levels: + P = 10**(P/10) + # TODO: This does not work due to complex numbers. Should be split up in + # magnitude and phase. + # elif st == SmoothingType.tf: + # P = P**2 + + Psm = np.zeros_like(P) # smoothed power - to be calculated + x0 = 1 if freq[0]==0 else 0 # skip first data point if zero frequency + df = freq[1] - freq[0] # frequency step # Loop through data points for x in range(x0, L): # Find indices of data points to calculate current (smoothed) magnitude - fc = f[x] # center freq. of smoothing window + fc = freq[x] # center freq. of smoothing window Df = tr * fc / Noct # freq. range of smoothing window - xl = int(np.ceil(x - 0.5*Df/df)) # desired lower index of frequency array to be used during smoothing - xu = int(np.floor(x + 0.5*Df/df)) + 1 # upper index + 1 (because half-open interval) + + # desired lower index of frequency array to be used during smoothing + xl = int(np.ceil(x - 0.5*Df/df)) + + # upper index + 1 (because half-open interval) + xu = int(np.floor(x + 0.5*Df/df)) + 1 # Create window Np = xu - xl # number of points @@ -110,13 +152,17 @@ def oct_smooth(f, M, Noct, dB=False): wind_int = np.sum(wind) # integral Psm[x] = np.dot(wind, P[xl:xu]) / wind_int # apply window - Msm = 10*np.log10(Psm) if dB else Psm**0.5 # convert power --> magnitude + if st == SmoothingType.levels: + Psm = 10*np.log10(Psm) + elif st == SmoothingType.tf: + Psm = np.sqrt(Psm) - return Msm + return Psm # %% Test if __name__ == "__main__": + import matplotlib.pyplot as plt # Initialize Noct = 6 # 1/6 oct. smoothing diff --git a/lasp/wrappers.pyx b/lasp/wrappers.pyx index 536c396..8d7d350 100644 --- a/lasp/wrappers.pyx +++ b/lasp/wrappers.pyx @@ -74,8 +74,7 @@ cdef extern from "lasp_python.h": __all__ = ['AvPowerSpectra', 'SosFilterBank', 'FilterBank', 'Siggen', 'sweep_flag_forward', 'sweep_flag_backward', 'sweep_flag_linear', 'sweep_flag_exponential', - 'load_fft_wisdom', 'store_fft_wisdom', 'Window'] - + 'load_fft_wisdom', 'store_fft_wisdom'] setTracerLevel(15) From 7153096552bf797fb686ff31d9bb7971390cc57f Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Wed, 19 May 2021 16:33:27 +0200 Subject: [PATCH 18/28] Fixed several bugs. Most serious one is a segfault for a stream without input. --- CMakeLists.txt | 2 +- lasp/device/lasp_daq.pyx | 10 ++++++---- lasp/lasp_avstream.py | 5 +++++ test/CMakeLists.txt | 6 ++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f229c0..74082fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ option(LASP_RTAUDIO "Compile with RtAudio Daq backend" ON) option(LASP_ULDAQ "Compile with UlDaq backend" ON) option(LASP_DEBUG "Compile in debug mode" ON) option(LASP_FFTW_BACKEND "Compile with FFTW fft backend" ON) -option(LAS_FFTPACK_BACKEND "Compile with Fftpack fft backend" OFF) +option(LASP_FFTPACK_BACKEND "Compile with Fftpack fft backend" OFF) if(LASP_PARALLEL) add_definitions(-DLASP_MAX_NUM_THREADS=30) diff --git a/lasp/device/lasp_daq.pyx b/lasp/device/lasp_daq.pyx index ff9fcc5..4200153 100644 --- a/lasp/device/lasp_daq.pyx +++ b/lasp/device/lasp_daq.pyx @@ -93,10 +93,12 @@ cdef void audioCallbackPythonThreadFunction(void* voidsd) nogil: # print(f'Number of input channels: {ninchannels}') # print(f'Number of out channels: {noutchannels}') # fprintf(stderr, 'Sleep time: %d us\n', sleeptime_us) - for i in range(nblocks_buffer): - outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) - memset(outbuffer, 0, sizeof(double)*nBytesPerChan*noutchannels) - sd.outQueue.enqueue( outbuffer) + + if sd.outQueue: + for i in range(nblocks_buffer): + outbuffer = malloc(sizeof(double)*nBytesPerChan*noutchannels) + memset(outbuffer, 0, sizeof(double)*nBytesPerChan*noutchannels) + sd.outQueue.enqueue( outbuffer) sd.ready.store(True) while not sd.stopThread.load(): diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 88299e6..6c1253c 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -107,6 +107,7 @@ class AudioStream: processCallback: callback function that will be called from a different thread, with arguments (AudioStream, in """ + logging.debug('AudioStream()') self.running = Atomic(False) self.aframectr = Atomic(0) @@ -130,6 +131,10 @@ class AudioStream: self.daq = Daq(device, daqconfig) en_in_ch = daqconfig.getEnabledInChannels(include_monitor=True) en_out_ch = daqconfig.getEnabledOutChannels() + if en_in_ch == 0 and en_out_ch == 0: + raise RuntimeError('No enabled input / output channels') + + logging.debug('Ready to start device...') samplerate = self.daq.start(self.streamCallback) self.streammetadata = StreamMetaData( diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 15442c7..f077ccc 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -11,5 +11,7 @@ target_link_libraries(test_fft lasp_lib) target_link_libraries(test_workers lasp_lib) target_link_libraries(test_math lasp_lib) -add_executable(test_uldaq test_uldaq.cpp) -target_link_libraries(test_uldaq cpp_daq) +if(LASP_ULDAQ) + add_executable(test_uldaq test_uldaq.cpp) + target_link_libraries(test_uldaq cpp_daq) +endif(LASP_ULDAQ) From 377291ccf49d710b6ae35b4ad780f68c702f9bdb Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Wed, 19 May 2021 21:19:31 +0200 Subject: [PATCH 19/28] Cleaned up recording code. Added start delay to recording --- lasp/lasp_avstream.py | 1 + lasp/lasp_common.py | 8 +-- lasp/lasp_record.py | 125 ++++++++++++++++++++++++++---------------- 3 files changed, 84 insertions(+), 50 deletions(-) diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 6c1253c..6b9c0ed 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -405,6 +405,7 @@ class AvStreamProcess(mp.Process): # Wrapper functions that safe some typing, they do not require an # explanation. def sendInQueues(self, msg, *data): + # logging.debug('sendInQueues()') for q in self.indata_qlist: # Fan out the input data to all queues in the queue list q.put((msg, data)) diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index 2377a39..f6003c9 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -49,14 +49,14 @@ class AvType(Enum): the stream.""" # Input stream - audio_input = auto() + audio_input = (0, 'input') # Output stream - audio_output = auto() + audio_output = (1, 'output') # Both input as well as output - audio_duplex = auto() - video = 4 + audio_duplex = (2, 'duplex') + # video = 4 @dataclass_json diff --git a/lasp/lasp_record.py b/lasp/lasp_record.py index 3974f95..6407e5a 100644 --- a/lasp/lasp_record.py +++ b/lasp/lasp_record.py @@ -15,10 +15,13 @@ class RecordStatus: class Recording: - + """ + Class used to perform a recording. + """ def __init__(self, fn: str, streammgr: StreamManager, rectime: float = None, wait: bool = True, - progressCallback=None): + progressCallback=None, + startDelay: float=0): """ Start a recording. Blocks if wait is set to True. @@ -31,6 +34,8 @@ class Recording: to None, or np.inf, the recording continues indefintely. progressCallback: callable that is called with an instance of RecordStatus instance as argument. + startDelay: Optional delay added before the recording is *actually* + started in [s]. """ ext = '.h5' if ext not in fn: @@ -39,33 +44,37 @@ class Recording: self.smgr = streammgr self.metadata = None - self.rectime = rectime - self._fn = fn + assert startDelay >= 0 + self.startDelay = startDelay + # Flag used to indicate that we have passed the start delay + self.startDelay_passed = False + self.rectime = rectime + self.fn = fn - self._video_frame_positions = [] - self._curT_rounded_to_seconds = 0 + self.video_frame_positions = [] + self.curT_rounded_to_seconds = 0 # Counter of the number of blocks - self._ablockno = 0 - self._vframeno = 0 + self.ablockno = 0 + self.vframeno = 0 - self._progressCallback = progressCallback - self._wait = wait + self.progressCallback = progressCallback + self.wait = wait - self._f = h5py.File(self._fn, 'w') + self.f = h5py.File(self.fn, 'w') # This flag is used to delete the file on finish(), and can be used # when a recording is canceled. - self._deleteFile = False + self.deleteFile = False try: # Input queue - self.inq = streammgr.addListener() + self.inq = streammgr.addInQueueListener() except RuntimeError: # Cleanup stuff, something is going wrong when starting the stream try: - self._f.close() + self.f.close() except Exception as e: logging.error( 'Error preliminary closing measurement file {fn}: {str(e)}') @@ -77,15 +86,15 @@ class Recording: streammgr.getStreamStatus(AvType.audio_input) streammgr.getStreamStatus(AvType.audio_duplex) - self._ad = None + self.ad = None logging.debug('Starting record....') # TODO: Fix this later when we want video # if stream.hasVideo(): - # stream.addCallback(self._aCallback, AvType.audio_input) + # stream.addCallback(self.aCallback, AvType.audio_input) self.stop = False - if self._wait: + if self.wait: logging.debug('Stop recording with CTRL-C') try: while not self.stop: @@ -107,8 +116,10 @@ class Recording: """ + # logging.debug('handleQueue()') while self.inq.qsize() > 0: msg, data = self.inq.get() + # logging.debug(f'Obtained message: {msg}') if msg == StreamMsg.streamData: samples, = data self.__addTimeData(samples) @@ -117,12 +128,15 @@ class Recording: avtype, metadata = data if metadata is None: raise RuntimeError('BUG: no stream metadata') - self.processStreamMetaData(metadata) + if avtype in (AvType.audio_duplex, AvType.audio_input): + self.processStreamMetaData(metadata) elif msg == StreamMsg.streamMetaData: logging.debug(f'handleQueue obtained message {msg}') avtype, metadata = data if metadata is not None: self.processStreamMetaData(metadata) + elif msg == StreamMsg.streamTemporaryError: + pass else: logging.debug(f'handleQueue obtained message {msg}') # An error occured, we do not remove the file, but we stop. @@ -138,13 +152,20 @@ class Recording: """ logging.debug('Recording::processStreamMetaData()') + if self.metadata is not None: + # Metadata already obtained. We check whether the new metadata is + # compatible. Otherwise an error occurs + if md != self.metadata: + raise RuntimeError('BUG: Incompatible stream metadata!') + return + # The 'Audio' dataset as specified in lasp_measurement, where data is # send to. We use gzip as compression, this gives moderate a moderate # compression to the data. - f = self._f + f = self.f blocksize = md.blocksize nchannels = len(md.in_ch) - self._ad = f.create_dataset('audio', + self.ad = f.create_dataset('audio', (1, blocksize, nchannels), dtype=md.dtype, maxshape=( @@ -160,7 +181,7 @@ class Recording: # with audio. # if smgr.hasVideo(): # video_x, video_y = smgr.video_x, smgr.video_y - # self._vd = f.create_dataset('video', + # self.vd = f.create_dataset('video', # (1, video_y, video_x, 3), # dtype='uint8', # maxshape=( @@ -188,7 +209,7 @@ class Recording: the recording. Typically used for cleaning up after canceling a recording. """ - self._deleteFile = val + self.deleteFile = val def finish(self): """ @@ -201,22 +222,22 @@ class Recording: # TODO: Fix when video # if smgr.hasVideo(): - # smgr.removeCallback(self._vCallback, AvType.video_input) - # self._f['video_frame_positions'] = self._video_frame_positions + # smgr.removeCallback(self.vCallback, AvType.video_input) + # self.f['video_frame_positions'] = self.video_frame_positions try: - smgr.removeListener(self.inq) + smgr.removeInQueueListener(self.inq) except Exception as e: logging.error(f'Could not remove queue from smgr: {e}') try: # Close the recording file - self._f.close() + self.f.close() except Exception as e: logging.error(f'Error closing file: {e}') logging.debug('Recording ended') - if self._deleteFile: + if self.deleteFile: self.__deleteFile() def __deleteFile(self): @@ -224,9 +245,9 @@ class Recording: Cleanup the recording file. """ try: - os.remove(self._fn) + os.remove(self.fn) except Exception as e: - logging.error(f'Error deleting file: {self._fn}') + logging.error(f'Error deleting file: {self.fn}') def __addTimeData(self, indata): """ @@ -248,39 +269,51 @@ class Recording: self.smgr.getStreamStatus(AvType.audio_duplex) return - curT = self._ablockno*self.blocksize/self.fs + curT = self.ablockno*self.blocksize/self.fs + + # Increase the block counter + self.ablockno += 1 + + if curT < self.startDelay and not self.startDelay_passed: + # Start delay has not been passed + return + elif curT >= 0 and not self.startDelay_passed: + # Start delay passed, switch the flag! + self.startDelay_passed = True + # Reset the audio block counter and the time + self.ablockno = 1 + curT = 0 + recstatus = RecordStatus( curT=curT, done=False) - if self._progressCallback is not None: - self._progressCallback(recstatus) + if self.progressCallback is not None: + self.progressCallback(recstatus) curT_rounded_to_seconds = int(curT) - if curT_rounded_to_seconds > self._curT_rounded_to_seconds: - self._curT_rounded_to_seconds = curT_rounded_to_seconds + if curT_rounded_to_seconds > self.curT_rounded_to_seconds: + self.curT_rounded_to_seconds = curT_rounded_to_seconds print(f'{curT_rounded_to_seconds}', end='', flush=True) else: print('.', end='', flush=True) if self.rectime is not None and curT > self.rectime: # We are done! - if self._progressCallback is not None: + if self.progressCallback is not None: recstatus.done = True - self._progressCallback(recstatus) + self.progressCallback(recstatus) self.stop = True return - # Add the data to the file - self._ad.resize(self._ablockno+1, axis=0) - self._ad[self._ablockno, :, :] = indata + # Add the data to the file, and resize the audio data blocks + self.ad.resize(self.ablockno, axis=0) + self.ad[self.ablockno-1, :, :] = indata - # Increase the block counter - self._ablockno += 1 # def _vCallback(self, frame, framectr): - # self._video_frame_positions.append(self._ablockno()) - # vframeno = self._vframeno - # self._vd.resize(vframeno+1, axis=0) - # self._vd[vframeno, :, :] = frame - # self._vframeno += 1 + # self.video_frame_positions.append(self.ablockno()) + # vframeno = self.vframeno + # self.vd.resize(vframeno+1, axis=0) + # self.vd[vframeno, :, :] = frame + # self.vframeno += 1 From b3ce37591fc116c6aba237def7c36321aadc6346 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Thu, 20 May 2021 22:17:22 +0200 Subject: [PATCH 20/28] Bugfix of channel names in measurement. Set default frequency weighting to Z, in stead of A, on advice of Casper --- lasp/lasp_common.py | 4 ++-- lasp/lasp_measurement.py | 20 ++++++++++++++------ setup.py | 8 ++++---- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index f6003c9..10a1b31 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -336,11 +336,11 @@ class FreqWeighting: """ Frequency weighting types """ + Z = ('Z', 'Z-weighting') A = ('A', 'A-weighting') C = ('C', 'C-weighting') - Z = ('Z', 'Z-weighting') types = (A, C, Z) - default = A + default = Z default_index = 0 @staticmethod diff --git a/lasp/lasp_measurement.py b/lasp/lasp_measurement.py index 5f96d0d..3373a26 100644 --- a/lasp/lasp_measurement.py +++ b/lasp/lasp_measurement.py @@ -208,11 +208,16 @@ class Measurement: self.N = (self.nblocks * self.blocksize) self.T = self.N / self.samplerate + # Due to a previous bug, the channel names were not stored + # consistently, i.e. as 'channel_names' and later camelcase. try: - self._channel_names = f.attrs['channel_names'] + self._channelNames = f.attrs['channel_names'] except KeyError: - # No channel names found in measurement file - self._channel_names = [f'Unnamed {i}' for i in range(self.nchannels)] + try: + self._channelNames = f.attrs['channelNames'] + except KeyError: + # No channel names found in measurement file + self._channelNames = [f'Unnamed {i}' for i in range(self.nchannels)] # comment = read-write thing try: @@ -232,7 +237,6 @@ class Measurement: self._time = f.attrs['time'] - try: qtys_json = f.attrs['qtys'] # Load quantity data @@ -244,10 +248,14 @@ class Measurement: self._qtys = [SIQtys.default for i in range(self.nchannels)] def setAttribute(self, atrname, value): + """ + Set an attribute in the measurement file, and keep a local copy in + memory for efficient accessing. + """ with self.file('r+') as f: # Update comment attribute in the file f.attrs[atrname] = value - setattr(self, '_' + atrname, value) + setattr(self, '_' + atrname, value) @property def name(self): @@ -256,7 +264,7 @@ class Measurement: @property def channelNames(self): - return self._channel_names + return self._channelNames @channelNames.setter def channelNames(self, newchnames): diff --git a/setup.py b/setup.py index 322e3f6..409e566 100644 --- a/setup.py +++ b/setup.py @@ -27,16 +27,16 @@ setup( long_description_content_type="text/markdown", # ext_modules=[CMakeExtension('lasp/wrappers.so'), # ], - package_data={'lasp': ['wrappers.so']}, + #package_data={'lasp': ['wrappers.so']}, author='J.A. de Jong - ASCEE', author_email="j.a.dejong@ascee.nl", install_requires=['matplotlib>=1.0', - 'scipy>=1.0', 'numpy>=1.0', 'h5py', - 'dataclasses_json', + 'scipy>=1.0', 'numpy>=1.0', 'h5py==3.2.0', + 'dataclasses_json', 'cython', ], license='MIT', description="Library for Acoustic Signal Processing", keywords="", - url="https://www.ascee.nl/lasp/", # project home page, if any + url="https://www.ascee.nl/lasp/", # project home page ) From 2d05019f61ac91d43444545d365c265de3d99539 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sun, 23 May 2021 15:13:11 +0200 Subject: [PATCH 21/28] Bugfixes: smoothing, measurement file attribute channelNames is now definite. Improved error messages --- lasp/lasp_avstream.py | 6 +++++- lasp/lasp_common.py | 4 ++-- lasp/lasp_measurement.py | 6 ++++-- lasp/lasp_record.py | 2 +- lasp/lasp_siggen.py | 6 +++++- lasp/tools/tools.py | 2 -- setup.py | 16 +--------------- 7 files changed, 18 insertions(+), 24 deletions(-) diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 6b9c0ed..8beb4b4 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -192,6 +192,8 @@ class AvStreamProcess(mp.Process): output to the devices. """ + super().__init__() + self.pipe = pipe self.msg_qlist = msg_qlist self.indata_qlist = indata_qlist @@ -203,7 +205,9 @@ class AvStreamProcess(mp.Process): # In, out, duplex self.streams = {t: None for t in list(AvType)} - super().__init__() + # When this is set, a kill on the main process will also kill the + # siggen process. Highly wanted feature + self.daemon = True def run(self): """ diff --git a/lasp/lasp_common.py b/lasp/lasp_common.py index 10a1b31..3490e5e 100644 --- a/lasp/lasp_common.py +++ b/lasp/lasp_common.py @@ -79,7 +79,7 @@ class SIQtys: unit_name='No unit / full scale', unit_symb='-', level_unit=('dBFS',), - level_ref_name=('Full scale sine wave',), + level_ref_name=('Relative to full scale sine wave',), level_ref_value=(dBFS_REF,) ) AP = Qty(name='Acoustic Pressure', @@ -98,7 +98,7 @@ class SIQtys: level_ref_value=(1.0,), ) types = (N, AP, V) - default = AP + default = N default_index = 0 @staticmethod diff --git a/lasp/lasp_measurement.py b/lasp/lasp_measurement.py index 3373a26..76d0c25 100644 --- a/lasp/lasp_measurement.py +++ b/lasp/lasp_measurement.py @@ -211,12 +211,14 @@ class Measurement: # Due to a previous bug, the channel names were not stored # consistently, i.e. as 'channel_names' and later camelcase. try: - self._channelNames = f.attrs['channel_names'] + self._channelNames = f.attrs['channelNames'] except KeyError: try: - self._channelNames = f.attrs['channelNames'] + self._channelNames = f.attrs['channel_names'] + logging.info("Measurement file obtained which stores channel names with *old* attribute 'channel_names'") except KeyError: # No channel names found in measurement file + logging.info('No channel name data found in measurement') self._channelNames = [f'Unnamed {i}' for i in range(self.nchannels)] # comment = read-write thing diff --git a/lasp/lasp_record.py b/lasp/lasp_record.py index 6407e5a..e94f748 100644 --- a/lasp/lasp_record.py +++ b/lasp/lasp_record.py @@ -194,7 +194,7 @@ class Recording: f.attrs['nchannels'] = nchannels f.attrs['blocksize'] = blocksize f.attrs['sensitivity'] = [ch.sensitivity for ch in md.in_ch] - f.attrs['channel_names'] = [ch.channel_name for ch in md.in_ch] + f.attrs['channelNames'] = [ch.channel_name for ch in md.in_ch] f.attrs['time'] = time.time() self.blocksize = blocksize self.fs = md.fs diff --git a/lasp/lasp_siggen.py b/lasp/lasp_siggen.py index bbff602..1f73647 100644 --- a/lasp/lasp_siggen.py +++ b/lasp/lasp_siggen.py @@ -111,6 +111,11 @@ class SiggenProcess(mp.Process): dataq: The queue to put generated signal on pipe: Control and status messaging pipe """ + super().__init__() + + # When this is set, a kill on the main process will also kill the + # siggen process. Highly wanted feature + self.daemon = True self.dataq = dataq self.siggendata = siggendata @@ -123,7 +128,6 @@ class SiggenProcess(mp.Process): self.nblocks_buffer = max( 1, int(QUEUE_BUFFER_TIME * fs/ nframes_per_block) ) - super().__init__() def newSiggen(self, siggendata: SiggenData): """ diff --git a/lasp/tools/tools.py b/lasp/tools/tools.py index 57721ca..07b88ca 100644 --- a/lasp/tools/tools.py +++ b/lasp/tools/tools.py @@ -154,8 +154,6 @@ def smoothSpectralData(freq, M, sw: SmoothingWidth, if st == SmoothingType.levels: Psm = 10*np.log10(Psm) - elif st == SmoothingType.tf: - Psm = np.sqrt(Psm) return Psm diff --git a/setup.py b/setup.py index 409e566..902c565 100644 --- a/setup.py +++ b/setup.py @@ -1,24 +1,10 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3.8 # -*- coding: utf-8 -*- """ @author: J.A. de Jong - ASCEE """ from setuptools import setup, find_packages - -# class CMakeExtension(Extension): -# """ -# An extension to run the cmake build -# -# This simply overrides the base extension class so that setuptools -# doesn't try to build your sources for you -# """ -# -# def __init__(self, name, sources=[]): -# -# super().__init__(name=name, sources=sources) - - setup( name="LASP", version="1.0", From 1678a0767ad3a247066c02a50c1f25f294e2f37e Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - ASCEE" Date: Sun, 23 May 2021 07:38:41 -0700 Subject: [PATCH 22/28] Fixed some bugs to let it work in MinGW compilation --- CMakeLists.txt | 6 ++++-- lasp/c/lasp_pyarray.h | 4 ++-- lasp/device/lasp_cpprtaudio.cpp | 6 ++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 74082fd..89785c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,10 +88,12 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") link_directories(..\\rtaudio) link_directories(C:\\Users\\User\\Miniconda3) add_definitions(-DHAS_RTAUDIO_WIN_WASAPI_API) -else() +else() # Linux compile set(win32 false) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fPIC -std=c11 \ -Werror=incompatible-pointer-types") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC") + set(CMAKE_CXX_FLAGS "${CMAKE_C_FLAGS} -fPIC") include_directories(/usr/local/include/rtaudio) include_directories(/usr/include/rtaudio) link_directories(/usr/local/lib) @@ -136,7 +138,7 @@ set(CYTHON_EXTRA_C_FLAGS "-Wno-sign-compare -Wno-cpp -Wno-implicit-fallthrough - set(CYTHON_EXTRA_CXX_FLAGS "-Wno-sign-compare -Wno-cpp -Wno-implicit-fallthrough -Wno-strict-aliasing") -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC -std=c++11 -Wall -Wextra \ +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wall -Wextra \ -Wno-type-limits") # Debug make flags diff --git a/lasp/c/lasp_pyarray.h b/lasp/c/lasp_pyarray.h index 8a0169a..6690c9c 100644 --- a/lasp/c/lasp_pyarray.h +++ b/lasp/c/lasp_pyarray.h @@ -22,7 +22,7 @@ * Function passed to Python to use for cleanup of * foreignly obtained data. **/ -static inline void capsule_cleanup(void *capsule) { +static inline void capsule_cleanup(PyObject *capsule) { void *memory = PyCapsule_GetPointer(capsule, NULL); free(memory); } @@ -67,7 +67,7 @@ static inline PyObject *data_to_ndarray(void *data, int n_rows, int n_cols, // https://stackoverflow.com/questions/54269956/crash-of-jupyter-due-to-the-use-of-pyarray-enableflags/54278170#54278170 // Note that in general it was disadvised to build all C code with MinGW on // Windows. We do it anyway, see if we find any problems on the way. - void *capsule = PyCapsule_New(mat->_data, NULL, capsule_cleanup); + PyObject *capsule = PyCapsule_New(data, "data destructor", capsule_cleanup); PyArray_SetBaseObject(arr, capsule); #endif /* fprintf(stderr, "============Ownership transfer================\n"); */ diff --git a/lasp/device/lasp_cpprtaudio.cpp b/lasp/device/lasp_cpprtaudio.cpp index 0994c24..4805252 100644 --- a/lasp/device/lasp_cpprtaudio.cpp +++ b/lasp/device/lasp_cpprtaudio.cpp @@ -4,6 +4,12 @@ #include #include #include +#if MS_WIN64 +// #include +// #include +typedef uint8_t u_int8_t; + +#endif using std::atomic; From 9005bda017515c8698e4dc7c8d33df18a12b1bcd Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - ASCEE" Date: Sun, 23 May 2021 10:15:48 -0700 Subject: [PATCH 23/28] Fixed some bugs. Lrftubes is temporarily disabled on this branch --- CMakeLists.txt | 10 +++++++--- lasp/CMakeLists.txt | 2 +- lasp/c/CMakeLists.txt | 1 + lasp/c/lasp_pyarray.h | 6 +++++- lasp/c/lasp_sosfilterbank.c | 5 +++-- lasp/device/CMakeLists.txt | 2 +- lasp/device/lasp_cppdaq.h | 3 ++- lasp/device/lasp_cpprtaudio.cpp | 14 ++++++++++---- lasp/lasp_avstream.py | 15 ++++++++------- lasp/lasp_imptube.py | 6 +++--- 10 files changed, 41 insertions(+), 23 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 89785c6..790bcf1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,7 @@ endif(LASP_FLOAT STREQUAL "double") # ##################### END Cmake variables converted to a macro set(Python_ADDITIONAL_VERSIONS "3.8") +set(python_version_windll "38") # #################### Setting definitions and debug-specific compilation flags # General make flags @@ -80,11 +81,14 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") message("Building for Windows") include_directories( ..\\rtaudio - C:\\mingw\\include\\OpenBLAS + C:\\mingw\\mingw64\\include\\OpenBLAS + link_directories(C:\\Users\\User\\miniconda3\\Library\\include) ) add_definitions(-DMS_WIN64) - link_directories(C:\\mingw\\lib) - link_directories(C:\\mingw\\bin) + link_directories(C:\\mingw\\mingw64\\lib) + LINK_DIRECTORIES(C:\\Users\\User\\miniconda3) + link_directories(C:\\mingw\\mingw64\\bin) + link_directories(C:\\mingw\\mingw64\\bin) link_directories(..\\rtaudio) link_directories(C:\\Users\\User\\Miniconda3) add_definitions(-DHAS_RTAUDIO_WIN_WASAPI_API) diff --git a/lasp/CMakeLists.txt b/lasp/CMakeLists.txt index 8a827ad..5760574 100644 --- a/lasp/CMakeLists.txt +++ b/lasp/CMakeLists.txt @@ -17,5 +17,5 @@ set_source_files_properties(wrappers.c PROPERTIES COMPILE_FLAGS "${CMAKE_C_FLAGS cython_add_module(wrappers wrappers.pyx) target_link_libraries(wrappers lasp_lib) if(win32) -target_link_libraries(wrappers python37) +target_link_libraries(wrappers python${python_version_windll}) endif(win32) diff --git a/lasp/c/CMakeLists.txt b/lasp/c/CMakeLists.txt index a68d4ef..d1d5b3d 100644 --- a/lasp/c/CMakeLists.txt +++ b/lasp/c/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(lasp_lib lasp_mq.c lasp_siggen.c lasp_worker.c + lasp_nprocs.c lasp_dfifo.c lasp_firfilterbank.c lasp_sosfilterbank.c diff --git a/lasp/c/lasp_pyarray.h b/lasp/c/lasp_pyarray.h index 6690c9c..cbe8792 100644 --- a/lasp/c/lasp_pyarray.h +++ b/lasp/c/lasp_pyarray.h @@ -68,7 +68,11 @@ static inline PyObject *data_to_ndarray(void *data, int n_rows, int n_cols, // Note that in general it was disadvised to build all C code with MinGW on // Windows. We do it anyway, see if we find any problems on the way. PyObject *capsule = PyCapsule_New(data, "data destructor", capsule_cleanup); - PyArray_SetBaseObject(arr, capsule); + int res = PyArray_SetBaseObject(arr, capsule); + if(res != 0) { + fprintf(stderr, "Failed to set base object of array!"); + return NULL; + } #endif /* fprintf(stderr, "============Ownership transfer================\n"); */ PyArray_ENABLEFLAGS(arr, NPY_OWNDATA); diff --git a/lasp/c/lasp_sosfilterbank.c b/lasp/c/lasp_sosfilterbank.c index 7e237cb..e3c652d 100644 --- a/lasp/c/lasp_sosfilterbank.c +++ b/lasp/c/lasp_sosfilterbank.c @@ -2,7 +2,8 @@ #include "lasp_sosfilterbank.h" #include "lasp_mq.h" #include "lasp_worker.h" -#include +#include "lasp_nprocs.h" + typedef struct Sosfilterbank { @@ -89,7 +90,7 @@ Sosfilterbank* Sosfilterbank_create( vd_free(&imp_response); us nthreads; - us nprocs = (us) get_nprocs(); + us nprocs = getNumberOfProcs(); if(nthreads_ == 0) { nthreads = min(max(nprocs/2,1), filterbank_size); diff --git a/lasp/device/CMakeLists.txt b/lasp/device/CMakeLists.txt index 320d528..cec4915 100644 --- a/lasp/device/CMakeLists.txt +++ b/lasp/device/CMakeLists.txt @@ -11,7 +11,7 @@ if(LASP_ULDAQ) list(PREPEND cpp_daq_linklibs uldaq) endif() if(win32) - list(APPEND cpp_daq_linklibs python) + list(APPEND cpp_daq_linklibs python${python_version_windll}) endif(win32) add_library(cpp_daq ${cpp_daq_files}) diff --git a/lasp/device/lasp_cppdaq.h b/lasp/device/lasp_cppdaq.h index 2264dad..c238c12 100644 --- a/lasp/device/lasp_cppdaq.h +++ b/lasp/device/lasp_cppdaq.h @@ -50,11 +50,12 @@ const DataType dtype_invalid; const DataType dtype_fl32("32-bits floating point", 4, true); const DataType dtype_fl64("64-bits floating point", 8, true); const DataType dtype_int8("8-bits integers", 1, false); +const DataType dtype_int24("24-bits integers", 1, false); const DataType dtype_int16("16-bits integers", 2, false); const DataType dtype_int32("32-bits integers", 4, false); const std::vector dataTypes = { - dtype_int8, dtype_int16, dtype_int32, dtype_fl32, dtype_fl64, + dtype_int8, dtype_int16,dtype_int24, dtype_int32, dtype_fl32, dtype_fl64, }; class DaqApi { diff --git a/lasp/device/lasp_cpprtaudio.cpp b/lasp/device/lasp_cpprtaudio.cpp index 4805252..8a77d01 100644 --- a/lasp/device/lasp_cpprtaudio.cpp +++ b/lasp/device/lasp_cpprtaudio.cpp @@ -5,10 +5,7 @@ #include #include #if MS_WIN64 -// #include -// #include typedef uint8_t u_int8_t; - #endif using std::atomic; @@ -24,7 +21,10 @@ void fillRtAudioDeviceInfo(vector &devinfolist) { for(us devno = 0; devno< count;devno++) { RtAudio::DeviceInfo devinfo = rtaudio.getDeviceInfo(devno); - + if(!devinfo.probed) { + // Device capabilities not successfully probed. Continue to next + continue; + } DeviceInfo d; switch(api){ case RtAudio::LINUX_ALSA: @@ -71,12 +71,18 @@ void fillRtAudioDeviceInfo(vector &devinfolist) { if(formats & RTAUDIO_SINT16) { d.availableDataTypes.push_back(dtype_int16); } + if(formats & RTAUDIO_SINT32) { + d.availableDataTypes.push_back(dtype_int24); + } if(formats & RTAUDIO_SINT32) { d.availableDataTypes.push_back(dtype_fl32); } if(formats & RTAUDIO_FLOAT64) { d.availableDataTypes.push_back(dtype_fl64); } + if(d.availableDataTypes.size() == 0) { + std::cerr << "RtAudio: No data types found in device!" << endl; + } d.prefDataTypeIndex = d.availableDataTypes.size() - 1; diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 8beb4b4..58f24d9 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -109,8 +109,10 @@ class AudioStream: """ logging.debug('AudioStream()') - self.running = Atomic(False) - self.aframectr = Atomic(0) + # self.running = Atomic(False) + # self.aframectr = Atomic(0) + self.running = False + self.aframectr = 0 self.avtype = avtype self.siggen_activated = Atomic(False) @@ -144,17 +146,16 @@ class AudioStream: blocksize=self.daq.nFramesPerBlock, dtype=self.daq.getNumpyDataType(), ) - self.running <<= True + self.running = True def streamCallback(self, indata, outdata, nframes): """ This is called (from a separate thread) for each block of audio data. """ - if not self.running(): + if not self.running: return 1 - - self.aframectr += 1 + self.aframectr += 1 rv = self.processCallback(self, indata, outdata) if rv != 0: @@ -282,7 +283,7 @@ class AvStreamProcess(mp.Process): except Exception as e: self.sendAllQueues( - StreamMsg.streamError, avtype, "Error starting stream {str(e)}" + StreamMsg.streamError, avtype, f"Error starting stream {str(e)}" ) return diff --git a/lasp/lasp_imptube.py b/lasp/lasp_imptube.py index f32ec44..8a00a89 100644 --- a/lasp/lasp_imptube.py +++ b/lasp/lasp_imptube.py @@ -6,12 +6,12 @@ Author: J.A. de Jong - ASCEE Description: Two-microphone impedance tube methods """ __all__ = ['TwoMicImpedanceTube'] -from lrftubes import Air +# from lrftubes import Air from .lasp_measurement import Measurement from numpy import pi, sqrt, exp import numpy as np from scipy.interpolate import UnivariateSpline -from lrftubes import PrsDuct +# from lrftubes import PrsDuct from functools import lru_cache class TwoMicImpedanceTube: @@ -23,7 +23,7 @@ class TwoMicImpedanceTube: fl: float = None, fu: float = None, periodic_method=False, - mat= Air(), + # mat= Air(), D_imptube = 50e-3, thermoviscous = True, **kwargs): From a9d7183cd45cb1496d6091e027160ae4bbaf9b72 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - ASCEE" Date: Sun, 23 May 2021 10:19:30 -0700 Subject: [PATCH 24/28] Added lasp_nprocs --- lasp/c/lasp_nprocs.c | 18 ++++++++++++++++++ lasp/c/lasp_nprocs.h | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 lasp/c/lasp_nprocs.c create mode 100644 lasp/c/lasp_nprocs.h diff --git a/lasp/c/lasp_nprocs.c b/lasp/c/lasp_nprocs.c new file mode 100644 index 0000000..706bec6 --- /dev/null +++ b/lasp/c/lasp_nprocs.c @@ -0,0 +1,18 @@ +#ifdef MS_WIN64 +#include +#else +// Used for obtaining the number of processors +#include +#endif +#include "lasp_nprocs.h" + +us getNumberOfProcs() { +#if MS_WIN64 +// https://stackoverflow.com/questions/150355/programmatically-find-the-number-of-cores-on-a-machine + SYSTEM_INFO sysinfo; + GetSystemInfo(&sysinfo); + return sysinfo.dwNumberOfProcessors; +#else + return get_nprocs(); +#endif +} \ No newline at end of file diff --git a/lasp/c/lasp_nprocs.h b/lasp/c/lasp_nprocs.h new file mode 100644 index 0000000..7ba5458 --- /dev/null +++ b/lasp/c/lasp_nprocs.h @@ -0,0 +1,18 @@ +// lasp_nprocs.h +// +// Author: J.A. de Jong - ASCEE +// +// Description: Implemententation of a function to determine the number +// of processors. +////////////////////////////////////////////////////////////////////// +#pragma once +#ifndef LASP_NPROCS_H +#define LASP_NPROCS_H +#include "lasp_types.h" + +/** + * @return The number of SMP processors + */ +us getNumberOfProcs(); + +#endif // LASP_NPROCS_H \ No newline at end of file From ab965815fbec1187b43a505d0ddfa4d26b2f910d Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sun, 23 May 2021 19:21:15 +0200 Subject: [PATCH 25/28] Merged upstream --- lasp/lasp_avstream.py | 8 ++++++++ lasp/lasp_measurement.py | 2 +- lasp/tools/tools.py | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 58f24d9..283b25b 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -157,6 +157,14 @@ class AudioStream: return 1 self.aframectr += 1 + # TODO: Fix this. This gives bug on Windows, the threading lock does + # give a strange erro. + try: + if not self.running(): + return 1 + except Exception as e: + print(e) + rv = self.processCallback(self, indata, outdata) if rv != 0: self.running <<= False diff --git a/lasp/lasp_measurement.py b/lasp/lasp_measurement.py index 76d0c25..94120ee 100644 --- a/lasp/lasp_measurement.py +++ b/lasp/lasp_measurement.py @@ -246,7 +246,7 @@ class Measurement: except KeyError: # If quantity data is not available, this is an 'old' # measurement file. - logging.debug('Physical quantity data not available in measurement file. Assuming {SIQtys.default}') + logging.debug(f'Physical quantity data not available in measurement file. Assuming {SIQtys.default}') self._qtys = [SIQtys.default for i in range(self.nchannels)] def setAttribute(self, atrname, value): diff --git a/lasp/tools/tools.py b/lasp/tools/tools.py index 07b88ca..742f7b0 100644 --- a/lasp/tools/tools.py +++ b/lasp/tools/tools.py @@ -84,6 +84,7 @@ def smoothSpectralData(freq, M, sw: SmoothingWidth, assert Noct > 0, "'Noct' must be absolute positive" if Noct < 1: raise Warning('Check if \'Noct\' is entered correctly') assert len(freq)==len(M), 'f and M should have equal length' + if st == SmoothingType.ps: assert np.min(M) >= 0, 'absolute magnitude M cannot be negative' if st == SmoothingType.levels and isinstance(M.dtype, complex): From 0c1eef5388d69c7fe91d31d401c40ca8ff4f3506 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Sun, 23 May 2021 19:23:53 +0200 Subject: [PATCH 26/28] Fixed error in build flags on Linux --- CMakeLists.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 790bcf1..c8050d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,10 +94,9 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") add_definitions(-DHAS_RTAUDIO_WIN_WASAPI_API) else() # Linux compile set(win32 false) - set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fPIC -std=c11 \ - -Werror=incompatible-pointer-types") - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC") - set(CMAKE_CXX_FLAGS "${CMAKE_C_FLAGS} -fPIC") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c11 -Werror=incompatible-pointer-types") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fPIC") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC") include_directories(/usr/local/include/rtaudio) include_directories(/usr/include/rtaudio) link_directories(/usr/local/lib) From 7ae0de3a06dbd4be4eecf37449e617a83f0401f0 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong" Date: Sun, 23 May 2021 22:53:48 +0200 Subject: [PATCH 27/28] Some improvements in build, some comments added --- CMakeLists.txt | 19 ++++++++++++------- README.md | 8 ++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 790bcf1..ef99a47 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,10 +39,11 @@ add_definitions(-DLASP_MAX_NUM_CHANNELS=80) add_definitions(-DLASP_MAX_NFFT=33554432) # 2**25 # ####################################### End of user-adjustable variables section - +set(CMAKE_CXX_STANDARD 11) +set(CMAKE_C_STANDARD 11) # ############### Choose an fft backend here -if(LASP_FFTW_BACKEND AND LASP_FFTPACK_BACKEND) +if(((LASP_FFTW_BACKEND AND LASP_FFTPACK_BACKEND) OR ((NOT (LASP_FFTPACK_BACKEND) AND (NOT LASP_FFTW_BACKEND))))) message(FATAL_ERROR "Either FFTW or Fftpack backend should be chosen. Please disable one of them") endif() @@ -61,6 +62,7 @@ if(LASP_FLOAT STREQUAL "double") add_definitions(-DLASP_FLOAT=64) add_definitions(-DLASP_DOUBLE_PRECISION) else() + # TODO: This has not been tested for a long time. add_definitions(-DLASP_FLOAT=32) add_definitions(-DLASP_SINGLE_PRECISION) endif(LASP_FLOAT STREQUAL "double") @@ -78,19 +80,22 @@ set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c11 -Wall -Wextra -Wno-type-limits \ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(win32 true) + set(home $ENV{USERPROFILE}) + # set(miniconda_dir ${home}\\Miniconda3) + message("Building for Windows") include_directories( ..\\rtaudio C:\\mingw\\mingw64\\include\\OpenBLAS - link_directories(C:\\Users\\User\\miniconda3\\Library\\include) + link_directories(${home}\\miniconda3\\Library\\include) ) + set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH} $miniconda_dir\\Lib\\cmake") + # include( add_definitions(-DMS_WIN64) link_directories(C:\\mingw\\mingw64\\lib) - LINK_DIRECTORIES(C:\\Users\\User\\miniconda3) - link_directories(C:\\mingw\\mingw64\\bin) link_directories(C:\\mingw\\mingw64\\bin) link_directories(..\\rtaudio) - link_directories(C:\\Users\\User\\Miniconda3) + link_directories(${home}\\Miniconda3) add_definitions(-DHAS_RTAUDIO_WIN_WASAPI_API) else() # Linux compile set(win32 false) @@ -142,7 +147,7 @@ set(CYTHON_EXTRA_C_FLAGS "-Wno-sign-compare -Wno-cpp -Wno-implicit-fallthrough - set(CYTHON_EXTRA_CXX_FLAGS "-Wno-sign-compare -Wno-cpp -Wno-implicit-fallthrough -Wno-strict-aliasing") -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wall -Wextra \ +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra \ -Wno-type-limits") # Debug make flags diff --git a/README.md b/README.md index be7c5ba..cdb46df 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,14 @@ compilation: - libopenblas-base - libopenblas-dev +#### Windows specific + +Tested using a Anacond / Miniconda Python environment. Please first run the following command: + +`conda install fftw` + +in case you want the *FFTW* fft backend. + ### Dependencies #### Ubuntu / Linux Mint From 47bcd369b6d81e05729b112c9fbaaca9320ad464 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong - Redu-Sone B.V., ASCEE V.O.F" Date: Mon, 24 May 2021 16:08:11 +0200 Subject: [PATCH 28/28] Bugfix in message, added a handle for returning a list of the stream messages from handleMessages() --- lasp/lasp_avstream.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lasp/lasp_avstream.py b/lasp/lasp_avstream.py index 283b25b..03a8071 100644 --- a/lasp/lasp_avstream.py +++ b/lasp/lasp_avstream.py @@ -124,7 +124,7 @@ class AudioStream: ] if len(matching_devices) == 0: - raise RuntimeError("Could not find device {daqconfig.device_name}") + raise RuntimeError(f"Could not find device {daqconfig.device_name}") # TODO: We pick te first one, what to do if we have multiple matches? # Is that even possible? @@ -160,7 +160,7 @@ class AudioStream: # TODO: Fix this. This gives bug on Windows, the threading lock does # give a strange erro. try: - if not self.running(): + if not self.running: return 1 except Exception as e: print(e) @@ -291,7 +291,7 @@ class AvStreamProcess(mp.Process): except Exception as e: self.sendAllQueues( - StreamMsg.streamError, avtype, f"Error starting stream {str(e)}" + StreamMsg.streamError, avtype, f"Error starting stream: {str(e)}" ) return @@ -494,6 +494,7 @@ class StreamManager: Handle messages that are still on the pipe. """ # logging.debug('StreamManager::handleMessages()') + msgs = [] while not self.our_msgqueue.empty(): msg, data = self.our_msgqueue.get() logging.debug(f'StreamManager obtained message {msg}') @@ -535,6 +536,9 @@ class StreamManager: devices, = data # logging.debug(devices) self.devices = devices + msgs.append((msg, data)) + + return msgs def getDeviceList(self): self.handleMessages()