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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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): """