From b1d48f09dec0ce96aa42bd71c297881f3642c393 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 22 Oct 2024 21:29:15 +0300 Subject: [PATCH 01/44] Research on how to best encode data within audio stream: audio FSK-based barcode generator and parser draft prototypes. --- tools/audio-codes.py | 295 +++++++++++++++++++++++++++++++++++++++++ tools/requirements.txt | 13 ++ 2 files changed, 308 insertions(+) create mode 100644 tools/audio-codes.py create mode 100644 tools/requirements.txt diff --git a/tools/audio-codes.py b/tools/audio-codes.py new file mode 100644 index 0000000..b1f7346 --- /dev/null +++ b/tools/audio-codes.py @@ -0,0 +1,295 @@ +import logging +import sys +import time + +import numpy as np +import sounddevice as sd +from scipy.io.wavfile import write +from scipy.io import wavfile + +logger = logging.getLogger(__name__) +logging.getLogger().addHandler(logging.StreamHandler(sys.stderr)) +logger.setLevel(logging.DEBUG) + +# audio barcode/qr helper functions + +def bit_enumerator(data): + if isinstance(data, AudioFrame): + data = data.encode() + if isinstance(data, str): + # If data is a string, iterate over each character + for char in data: + if char not in ('0', '1'): + raise ValueError("String must only contain '0' and '1'.") + yield int(char) # Yield the bit as an integer + elif isinstance(data, bytes): + # If data is bytes, iterate over each byte + for byte in data: + for i in range(7, -1, -1): # Iterate from MSB to LSB + yield (byte >> i) & 1 # Extract and yield the bit + else: + raise TypeError("Data must be either a string or bytes.") + + +# Convert a list of bits to bytes +def bits_to_bytes(detected_bits): + # Check if the length of detected_bits is a multiple of 8 + if len(detected_bits) % 8 != 0: + raise ValueError(f"Detected bits array must be aligned to 8 bits. Length={len(detected_bits)}") + + byte_array = bytearray() # Use bytearray for mutable bytes + for i in range(0, len(detected_bits), 8): + # Get the next 8 bits + byte_bits = detected_bits[i:i + 8] + + # Convert the list of bits to a byte (big-endian) + byte_value = 0 + for bit in byte_bits: + byte_value = (byte_value << 1) | bit # Shift left and add the bit + + byte_array.append(byte_value) # Append the byte to the bytearray + + return bytes(byte_array) # Convert bytearray to bytes + + +def crc8(data: bytes, polynomial: int = 0x31, init_value: int = 0x00) -> int: + crc = init_value + for byte in data: + crc ^= byte # XOR byte into the CRC + for _ in range(8): # Process each bit + if crc & 0x80: # If the highest bit is set + crc = (crc << 1) ^ polynomial # Shift left and XOR with polynomial + else: + crc <<= 1 # Just shift left + crc &= 0xFF # Keep CRC to 8 bits + return crc + + +def list_audio_devices(): + logger.debug("list_audio_devices()") + devices = sd.query_devices() # Query all devices + for i, device in enumerate(devices): + logger.debug(f"device [{i}] : {device['name']}") + + default_device = sd.default.device # Get the current default input/output devices + logger.debug(f"default in : {default_device[0]}") + logger.debug(f"default out : {default_device[1]}") + + +# class representing audio data frame +# where the first byte is CRC-8 checksum +# second byte is the length of the data +# and the rest is the data itself +class AudioFrame: + def __init__(self): + self.value: bytes = b'' + self.length: int = 0 + self.crc8: int = 0 + + def decode(self, data: bytes): + self.crc8 = data[0] + self.length = data[1] + self.value = data[2:] + n = crc8(self.value) + if self.crc8 != n: + raise ValueError(f"CRC-8 checksum mismatch: {self.crc8} <-> {n}") + + def encode(self) -> bytes: + # encode the frame as bytes in big-endian order + # where the first byte is CRC-8 checksum + # second byte is the length of the data + # and the rest is the data itself + return bytes([self.crc8, self.length]) + self.value + + def get_uint16(self) -> int: + return int.from_bytes(self.value, 'big') + + def set_uint16(self, i: int): + self.value = i.to_bytes(2, 'big') + self.length = 2 + self.crc8 = crc8(self.value) + + +# class to generate/parse QR-like code with FSK modulation +class AudioFsk: + def __init__(self, + f1=1000, + f0=5000, + sample_rate=44100, + duration=0.0078, + volume=0.75 + ): + self.f1 = f1 + self.f0 = f0 + self.sample_rate = sample_rate + self.duration = duration + if volume < 0.0 or volume > 1.0: + raise ValueError("Volume must be between 0.0 and 1.0.") + self.volume = volume + + def generate(self, data): + logger.debug(f"audio config : f1={self.f1} Hz, f0={self.f0} Hz, rate={self.sample_rate} Hz, bit duration={self.duration} sec, volume={self.volume}") + t = np.linspace(0, self.duration, + int(self.sample_rate * self.duration), + endpoint=False) + + # Create FSK signal + fsk_signal = np.array([]) + + c: int = 0 + sb: str = '' + for bit in bit_enumerator(data): + c += 1 + sb += str(bit) + if bit == 1: + fsk_signal = np.concatenate((fsk_signal, + self.volume * np.sin(2 * np.pi * self.f1 * t))) + else: + fsk_signal = np.concatenate((fsk_signal, + self.volume * np.sin(2 * np.pi * self.f0 * t))) + + # Normalize the signal for 100% volume + if self.volume==1.0: + fsk_signal /= np.max(np.abs(fsk_signal)) + logger.debug(f"audio raw bits: count={c}, {sb}") + logger.debug(f"audio duration: {c * self.duration:.6f} seconds") + return fsk_signal + + def play(self, data): + ts = time.perf_counter() + fsk_signal = self.generate(data) + ts = time.perf_counter() - ts + logger.debug(f"generate time : {ts:.6f} seconds") + + ts = time.perf_counter() + # Play the FSK signal + sd.play(fsk_signal, samplerate=self.sample_rate) + + # Wait until sound is finished playing + sd.wait() + ts = time.perf_counter() - ts + logger.debug(f"play time : {ts:.6f} seconds") + + + def save(self, data, filename): + fsk_signal = self.generate(data) + + # Save the signal to a WAV file + write(filename, self.sample_rate, + (fsk_signal * 32767).astype(np.int16)) + + def parse(self, filename): + # Read the WAV file + rate, data = wavfile.read(filename) + + # Check if audio is stereo and convert to mono if necessary + if len(data.shape) > 1: + data = data.mean(axis=1) + + # Calculate the number of samples for each bit duration + samples_per_bit = int(self.sample_rate * self.duration) + + # Prepare a list to hold the detected bits + detected_bits = [] + + # Analyze the audio in chunks + for i in range(0, len(data), samples_per_bit): + if i + samples_per_bit > len(data): + break # Avoid out of bounds + + # Extract the current chunk of audio + chunk = data[i:i + samples_per_bit] + + # Perform FFT on the chunk + fourier = np.fft.fft(chunk) + frequencies = np.fft.fftfreq(len(fourier), 1 / rate) + + # Get the magnitudes and filter out positive frequencies + magnitudes = np.abs(fourier) + positive_frequencies = frequencies[:len(frequencies) // 2] + positive_magnitudes = magnitudes[:len(magnitudes) // 2] + + # Find the peak frequency + peak_freq = positive_frequencies[np.argmax(positive_magnitudes)] + + # Determine if the peak frequency corresponds to a '1' or '0' + if abs(peak_freq - self.f1) < 50: # Frequency for '1' + detected_bits.append(1) + elif abs(peak_freq - self.f0) < 50: # Frequency for '0' + detected_bits.append(0) + + dbg_bits: str = ''.join([str(bit) for bit in detected_bits]) + logger.debug(f"detected bits : count={len(dbg_bits)}, {dbg_bits}") + return bits_to_bytes(detected_bits) + + +def beep_1(): + logger.debug("beep_1()") + logger.debug("play sound with sounddevice") + # Parameters + duration = 1.0 # seconds + frequency = 440 # Hz + sample_rate = 44100 # samples per second + + logger.debug(f"listen {frequency} Hz tone for {duration} seconds...") + + # Generate the sound wave + t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False) # Time array + wave = 0.95 * np.sin(2 * np.pi * frequency * t) # Generate sine wave + + # Play the sound + sd.play(wave, samplerate=sample_rate) + sd.wait() # Wait until the sound has finished playing + logger.debug("done") + + logger.debug("save sound as beep_001.wav...") + filename = 'beep_001.wav' + write(filename, sample_rate, wave.astype(np.float32)) # Save as float32 + logger.debug("beep_1() done") + + +def beep_2(): + logger.debug("beep_2()") + af: AudioFsk = AudioFsk() + data = '1010111101011010' + '1010111101011010' + data = b'\x0A\xB1\xF5\xAA' + data = b'\x00\x00\x00\x00' + data = b'\xFF\xFF\xFF\xFF' + data = b'\xF1\xF2\xF3\xF4' + data = AudioFrame() + n = 0xA001 + n = 1234 + logger.debug(f"encoded uint16: {n}") + data.set_uint16(n) + af.play(data) + logger.debug("save audio as : beep_002.wav...") + af.save(data, 'beep_002.wav') + logger.debug("beep_2() done") + + +def parse_beep_2(): + logger.debug("parse_beep_2()") + af: AudioFsk = AudioFsk() + detected_data = af.parse('beep_002.wav') + af: AudioFrame = AudioFrame() + af.decode(detected_data) + u16 = af.get_uint16() + logger.debug(f'parsed data : {detected_data}') + logger.debug(f'parsed uint16 : {u16}') + logger.debug("parse_beep_2() done") + + +def main(): + logger.debug("----------------------------------------------------") + list_audio_devices() + logger.debug("----------------------------------------------------") + beep_1() + logger.debug("----------------------------------------------------") + beep_2() + logger.debug("----------------------------------------------------") + parse_beep_2() + logger.debug("audio-codes.py done") + + +if __name__ == "__main__": + main() diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..ea82ec3 --- /dev/null +++ b/tools/requirements.txt @@ -0,0 +1,13 @@ +# draft requirements for audio-codes.py +pyzbar>=0.1.9 +opencv-python>=4.9.0.80 +numpy>=1.26.4 +click>=8.1.7 +pydantic>=2.7.1 +sounddevice>=0.5.1 +scipy>=1.14.1 +pydub>=0.25.1 +pyaudio>=0.2.14 + +#pyobjc-framework-Quartz>=10.3.1 +#psychopy \ No newline at end of file From 9383e80e9ea632029c5b4331d46bea058e648fdb Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Wed, 23 Oct 2024 16:56:35 +0300 Subject: [PATCH 02/44] Research on how to best encode data within audio stream: audio FSK-based barcode generator and parser draft prototypes. --- tools/audio-codes-notes.txt | 39 +++++++++++++++++++ ...ments.txt => audio-codes-requirements.txt} | 0 2 files changed, 39 insertions(+) create mode 100644 tools/audio-codes-notes.txt rename tools/{requirements.txt => audio-codes-requirements.txt} (100%) diff --git a/tools/audio-codes-notes.txt b/tools/audio-codes-notes.txt new file mode 100644 index 0000000..f5b2baf --- /dev/null +++ b/tools/audio-codes-notes.txt @@ -0,0 +1,39 @@ +[Install] + + python3 -m venv venv + source venv/bin/activate + pip install -r audio-codes-requirements.txt + + On MacOS: + brew install portaudio + pip install pyaudio + + +[TODO] + + Review `psychopy` and sound API and possibly use PTB's + facilities in PsychoPy for precise audio placement in time: + https://www.psychopy.org/download.html + https://psychopy.org/api/sound/playback.html + +--- +[Summary] + - `PyDub` allows you to generate simple tones easily. + - FSK modulation can be achieved using `numpy` and + `scipy` to create varying frequency tones based on + binary input. + - Optionally `DTMF` encoding is implemented using + predefined frequency pairs, and you can detect + DTMF tones by analyzing the audio input. + + - Chirp SDK: + Chirp.io / https://www.sonos.com/en/home + https://github.com/chirp + + - GNU Radio - can be used to encode/modulate/demodulate. + https://www.gnuradio.org/ + Supports Frequency Shift Keying (FSK), + Phase Shift Keying (PSK), or Amplitude Modulation (AM). + + - `reedsolo` can be used for ECC (Error Correction Codes). + diff --git a/tools/requirements.txt b/tools/audio-codes-requirements.txt similarity index 100% rename from tools/requirements.txt rename to tools/audio-codes-requirements.txt From 22c917d7457667f57f9c65da7e005d37ad7f7871 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Wed, 23 Oct 2024 16:59:08 +0300 Subject: [PATCH 03/44] Research on how to best encode data within audio stream: added reed-solomon ECC, #115. --- tools/audio-codes-requirements.txt | 3 +- tools/audio-codes.py | 110 ++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/tools/audio-codes-requirements.txt b/tools/audio-codes-requirements.txt index ea82ec3..3ea6a15 100644 --- a/tools/audio-codes-requirements.txt +++ b/tools/audio-codes-requirements.txt @@ -8,6 +8,7 @@ sounddevice>=0.5.1 scipy>=1.14.1 pydub>=0.25.1 pyaudio>=0.2.14 +reedsolo>=1.7.0 #pyobjc-framework-Quartz>=10.3.1 -#psychopy \ No newline at end of file +#psychopy diff --git a/tools/audio-codes.py b/tools/audio-codes.py index b1f7346..abf1273 100644 --- a/tools/audio-codes.py +++ b/tools/audio-codes.py @@ -1,11 +1,13 @@ import logging import sys import time +from datetime import datetime import numpy as np import sounddevice as sd from scipy.io.wavfile import write from scipy.io import wavfile +from reedsolo import RSCodec logger = logging.getLogger(__name__) logging.getLogger().addHandler(logging.StreamHandler(sys.stderr)) @@ -28,7 +30,7 @@ def bit_enumerator(data): for i in range(7, -1, -1): # Iterate from MSB to LSB yield (byte >> i) & 1 # Extract and yield the bit else: - raise TypeError("Data must be either a string or bytes.") + raise TypeError("Data must be either a string or bytes. Got: " + str(type(data))) # Convert a list of bits to bytes @@ -76,17 +78,26 @@ def list_audio_devices(): logger.debug(f"default out : {default_device[1]}") -# class representing audio data frame -# where the first byte is CRC-8 checksum -# second byte is the length of the data -# and the rest is the data itself +# Class representing audio data frame in big-endian +# format and encoded with Reed-Solomon error correction +# where the frame is structured as follows: +# - 1st byte is CRC-8 checksum +# - 2nd byte is the length of the data +# - 3+ and the rest is the data itself class AudioFrame: def __init__(self): self.value: bytes = b'' self.length: int = 0 self.crc8: int = 0 + self.use_ecc: bool = True + self.rsc = RSCodec(4) def decode(self, data: bytes): + if self.use_ecc: + dec, dec_full, errata_pos_all = self.rsc.decode(data) + data = bytes(dec) + + #logger.debug(f"decoded data : {data}") self.crc8 = data[0] self.length = data[1] self.value = data[2:] @@ -95,28 +106,64 @@ def decode(self, data: bytes): raise ValueError(f"CRC-8 checksum mismatch: {self.crc8} <-> {n}") def encode(self) -> bytes: - # encode the frame as bytes in big-endian order - # where the first byte is CRC-8 checksum - # second byte is the length of the data - # and the rest is the data itself - return bytes([self.crc8, self.length]) + self.value + logger.debug("size info") + logger.debug(f" - data : {len(self.value)} bytes, {self.value}") + b: bytes = bytes([self.crc8, self.length]) + self.value + logger.debug(f" - frame : {len(b)} bytes, {b}") + if self.use_ecc: + b = bytes(self.rsc.encode(b)) + logger.debug(f" - ecc : {len(b)} bytes, {b}") + return b + + def get_bytes(self) -> bytes: + return self.value + + def get_str(self) -> str: + return self.value.decode("utf-8") def get_uint16(self) -> int: + if len(self.value) != 2: + raise ValueError(f"Data length for uint16 must be 2 bytes, " + f"but was {len(self.value)}") return int.from_bytes(self.value, 'big') + def get_uint32(self) -> int: + if len(self.value) != 4: + raise ValueError(f"Data length for uint32 must be 4 bytes, " + f"but was {len(self.value)}") + return int.from_bytes(self.value, 'big') + + def get_uint64(self) -> int: + if len(self.value) != 8: + raise ValueError(f"Data length for uint64 must be 8 bytes, " + f"but was {len(self.value)}") + return int.from_bytes(self.value, 'big') + + def set_bytes(self, data: bytes): + self.value = data + self.length = len(data) + self.crc8 = crc8(data) + + def set_str(self, s: str): + self.set_bytes(s.encode("utf-8")) + def set_uint16(self, i: int): - self.value = i.to_bytes(2, 'big') - self.length = 2 - self.crc8 = crc8(self.value) + self.set_bytes(i.to_bytes(2, 'big')) + + def set_uint32(self, i: int): + self.set_bytes(i.to_bytes(4, 'big')) + + def set_uint64(self, i: int): + self.set_bytes(i.to_bytes(8, 'big')) -# class to generate/parse QR-like code with FSK modulation +# Class to generate/parse QR-like codes with FSK modulation class AudioFsk: def __init__(self, f1=1000, f0=5000, sample_rate=44100, - duration=0.0078, + duration=0.0070, volume=0.75 ): self.f1 = f1 @@ -274,10 +321,37 @@ def parse_beep_2(): af: AudioFrame = AudioFrame() af.decode(detected_data) u16 = af.get_uint16() - logger.debug(f'parsed data : {detected_data}') + ab: bytes = af.get_bytes() + logger.debug(f'detected data : {detected_data}') + logger.debug(f'parsed bytes : count={len(ab)}, {ab}') logger.debug(f'parsed uint16 : {u16}') logger.debug("parse_beep_2() done") +def beep_3(): + logger.debug("beep_3()") + af: AudioFsk = AudioFsk() + data = AudioFrame() + s: str = "Hello World! "+datetime.now().strftime("%Y-%m-%d %H:%M:%S") + logger.debug(f"encoded str : {s}") + data.set_str(s) + af.play(data) + logger.debug("save audio as : beep_003.wav...") + af.save(data, 'beep_003.wav') + logger.debug("beep_3() done") + +def parse_beep_3(): + logger.debug("parse_beep_3()") + af: AudioFsk = AudioFsk() + detected_data = af.parse('beep_003.wav') + af: AudioFrame = AudioFrame() + af.decode(detected_data) + ab = af.get_bytes() + s = af.get_str() + logger.debug(f'detected data : {detected_data}') + logger.debug(f'parsed bytes : count={len(ab)}, {ab}') + logger.debug(f'parsed str : {s}') + logger.debug("parse_beep_3() done") + def main(): logger.debug("----------------------------------------------------") @@ -288,6 +362,10 @@ def main(): beep_2() logger.debug("----------------------------------------------------") parse_beep_2() + logger.debug("----------------------------------------------------") + beep_3() + logger.debug("----------------------------------------------------") + parse_beep_3() logger.debug("audio-codes.py done") From e253b15278d94857a83cf68d70a29d707dd2d900 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Thu, 24 Oct 2024 20:54:52 +0300 Subject: [PATCH 04/44] PsychoPy PTB sound installation/configuration example and notes for MacOS, #115. --- tools/audio-codes-notes.txt | 32 ++++++++++++++++++++++++++++-- tools/audio-codes-requirements.txt | 4 +--- tools/audio-codes.py | 20 ++++++++++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/tools/audio-codes-notes.txt b/tools/audio-codes-notes.txt index f5b2baf..f64d070 100644 --- a/tools/audio-codes-notes.txt +++ b/tools/audio-codes-notes.txt @@ -1,13 +1,17 @@ [Install] - python3 -m venv venv + python3.10 -m venv venv source venv/bin/activate + pip install --upgrade pip pip install -r audio-codes-requirements.txt On MacOS: brew install portaudio pip install pyaudio + On Linux: + sudo apt-get install portaudio19-dev + [TODO] @@ -16,7 +20,31 @@ https://www.psychopy.org/download.html https://psychopy.org/api/sound/playback.html ---- + Look at watermark in audio. + +[psychopy, sound, ptb] + +Note: PsychoPy (2024.2.3) current requirements limits/suggests + to Python version 3.10. + + On MacOS: + Download and install the standalone package: + PsychoPy 2024.2.3 modern (py3.10) + https://github.com/psychopy/psychopy/releases/download/2024.2.3/StandalonePsychoPy-2024.2.3-macOS-py3.10.dmg + + Run PsychoPy and check the sound settings that `pbt` is + set as `Audio Library`. + + Make sure Python 3.10 is installed and venv is explicitly created with it: + python3.10 -m venv venv + source venv/bin/activate + pip install --upgrade pip + pip install -r audio-codes-requirements.txt + + Note: first time psychoPy is run, it may take a long time to setup audio + download additional dependencies. + + [Summary] - `PyDub` allows you to generate simple tones easily. - FSK modulation can be achieved using `numpy` and diff --git a/tools/audio-codes-requirements.txt b/tools/audio-codes-requirements.txt index 3ea6a15..1f3db0c 100644 --- a/tools/audio-codes-requirements.txt +++ b/tools/audio-codes-requirements.txt @@ -9,6 +9,4 @@ scipy>=1.14.1 pydub>=0.25.1 pyaudio>=0.2.14 reedsolo>=1.7.0 - -#pyobjc-framework-Quartz>=10.3.1 -#psychopy +psychopy diff --git a/tools/audio-codes.py b/tools/audio-codes.py index abf1273..9738750 100644 --- a/tools/audio-codes.py +++ b/tools/audio-codes.py @@ -8,9 +8,14 @@ from scipy.io.wavfile import write from scipy.io import wavfile from reedsolo import RSCodec +from psychopy import core, sound, prefs + logger = logging.getLogger(__name__) -logging.getLogger().addHandler(logging.StreamHandler(sys.stderr)) +handler = logging.StreamHandler(sys.stderr) +formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') +handler.setFormatter(formatter) +logging.getLogger().addHandler(handler) logger.setLevel(logging.DEBUG) # audio barcode/qr helper functions @@ -352,6 +357,17 @@ def parse_beep_3(): logger.debug(f'parsed str : {s}') logger.debug("parse_beep_3() done") +def beep_4(): + logger.debug("beep_4()") + logger.debug("play sound with psychopy ptb") + snd = sound.Sound('D', secs=0.5, stereo=True) + #snd = sound.Sound('beep_003.wav') + + snd.play() + core.wait(snd.duration) + logger.debug(f'Sound "{snd.sound}" has finished playing.') + + def main(): logger.debug("----------------------------------------------------") @@ -366,6 +382,8 @@ def main(): beep_3() logger.debug("----------------------------------------------------") parse_beep_3() + logger.debug("----------------------------------------------------") + beep_4() logger.debug("audio-codes.py done") From 3398a84e91f741a1d37f2bd91d8b16c4bb29c392 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Wed, 30 Oct 2024 17:27:52 +0200 Subject: [PATCH 05/44] PsychoPy PTB sound installation/configuration example and notes for Linux/Ubuntu 22.04, #115. --- tools/audio-codes-notes.md | 111 ++++++++++++++++++++++++++++++++++++ tools/audio-codes-notes.txt | 67 ---------------------- 2 files changed, 111 insertions(+), 67 deletions(-) create mode 100644 tools/audio-codes-notes.md delete mode 100644 tools/audio-codes-notes.txt diff --git a/tools/audio-codes-notes.md b/tools/audio-codes-notes.md new file mode 100644 index 0000000..d9dc886 --- /dev/null +++ b/tools/audio-codes-notes.md @@ -0,0 +1,111 @@ +# Audio Codes Notes + +## Installation + +``` + python3.10 -m venv venv + source venv/bin/activate + pip install --upgrade pip + pip install -r audio-codes-requirements.txt +``` + + On MacOS: +``` + brew install portaudio + pip install pyaudio +``` + + On Linux: +``` + sudo apt-get install portaudio19-dev +``` + +## TODO: + + Review `psychopy` and sound API and possibly use PTB's + facilities in PsychoPy for precise audio placement in time: + https://www.psychopy.org/download.html + https://psychopy.org/api/sound/playback.html + + Look at watermark in audio. + +## PsychoPy, Sound, PTB + +### On MacOS: + +Note: PsychoPy (2024.2.3) current requirements limits/suggests + to Python version 3.10. + +Download and install the standalone package: +PsychoPy 2024.2.3 modern (py3.10) +https://github.com/psychopy/psychopy/releases/download/2024.2.3/StandalonePsychoPy-2024.2.3-macOS-py3.10.dmg + +Run PsychoPy and check the sound settings that `pbt` is +set as `Audio Library`. + +Make sure Python 3.10 is installed and venv is explicitly created with it: +``` + python3.10 -m venv venv + source venv/bin/activate + pip install --upgrade pip + pip install -r audio-codes-requirements.txt +``` + +Note: first time PsychoPy is run, it may takes a long time to setup audio + download additional dependencies. + +### On Linux (Ubuntu 22.04): + +Create some folder for `psychopy` installation and init Python 3.10 venv: +``` + python3.10 -m venv venv + source venv/bin/activate +``` +Then fetch a wxPython wheel for your platform from: +https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ and +download it locally to your machine. +In my case it was `https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04/wxPython-4.2.1-cp310-cp310-linux_x86_64.whl` +downloaded to `wxPython/wxPython-4.2.1-cp310-cp310-linux_x86_64.whl` . + +Install sound dependencies (1st one may fail): +``` + sudo apt-get install libusb-1.0-0-dev portaudio19-dev libasound2-dev + pip install psychtoolbox +``` + +Install wxPython in `venv` with, e.g.: +``` + pip install wxPython/wxPython-4.2.1-cp310-cp310-linux_x86_64.whl +``` + +Then install `psychopy`: +``` + pip install psychopy +``` + +Run psychopy: +``` + psychopy +``` + + +## Summary + - `PyDub` allows you to generate simple tones easily. + - FSK modulation can be achieved using `numpy` and + `scipy` to create varying frequency tones based on + binary input. + - Optionally `DTMF` encoding is implemented using + predefined frequency pairs, and you can detect + DTMF tones by analyzing the audio input. + + - Chirp SDK: + Chirp.io / https://www.sonos.com/en/home + https://github.com/chirp + + - GNU Radio - can be used to encode/modulate/demodulate. + https://www.gnuradio.org/ + Supports Frequency Shift Keying (FSK), + Phase Shift Keying (PSK), or Amplitude Modulation (AM). + + - `reedsolo` can be used for ECC (Error Correction Codes). + diff --git a/tools/audio-codes-notes.txt b/tools/audio-codes-notes.txt deleted file mode 100644 index f64d070..0000000 --- a/tools/audio-codes-notes.txt +++ /dev/null @@ -1,67 +0,0 @@ -[Install] - - python3.10 -m venv venv - source venv/bin/activate - pip install --upgrade pip - pip install -r audio-codes-requirements.txt - - On MacOS: - brew install portaudio - pip install pyaudio - - On Linux: - sudo apt-get install portaudio19-dev - - -[TODO] - - Review `psychopy` and sound API and possibly use PTB's - facilities in PsychoPy for precise audio placement in time: - https://www.psychopy.org/download.html - https://psychopy.org/api/sound/playback.html - - Look at watermark in audio. - -[psychopy, sound, ptb] - -Note: PsychoPy (2024.2.3) current requirements limits/suggests - to Python version 3.10. - - On MacOS: - Download and install the standalone package: - PsychoPy 2024.2.3 modern (py3.10) - https://github.com/psychopy/psychopy/releases/download/2024.2.3/StandalonePsychoPy-2024.2.3-macOS-py3.10.dmg - - Run PsychoPy and check the sound settings that `pbt` is - set as `Audio Library`. - - Make sure Python 3.10 is installed and venv is explicitly created with it: - python3.10 -m venv venv - source venv/bin/activate - pip install --upgrade pip - pip install -r audio-codes-requirements.txt - - Note: first time psychoPy is run, it may take a long time to setup audio - download additional dependencies. - - -[Summary] - - `PyDub` allows you to generate simple tones easily. - - FSK modulation can be achieved using `numpy` and - `scipy` to create varying frequency tones based on - binary input. - - Optionally `DTMF` encoding is implemented using - predefined frequency pairs, and you can detect - DTMF tones by analyzing the audio input. - - - Chirp SDK: - Chirp.io / https://www.sonos.com/en/home - https://github.com/chirp - - - GNU Radio - can be used to encode/modulate/demodulate. - https://www.gnuradio.org/ - Supports Frequency Shift Keying (FSK), - Phase Shift Keying (PSK), or Amplitude Modulation (AM). - - - `reedsolo` can be used for ECC (Error Correction Codes). - From ec0ea05c23fe1f14b232370c77e3848e501696da Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 1 Nov 2024 16:20:21 +0200 Subject: [PATCH 06/44] PsychoPy PTB sound installation/configuration example and notes for Linux/Ubuntu 24.04, #115. --- tools/audio-codes-notes.md | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tools/audio-codes-notes.md b/tools/audio-codes-notes.md index d9dc886..b551656 100644 --- a/tools/audio-codes-notes.md +++ b/tools/audio-codes-notes.md @@ -88,6 +88,71 @@ Run psychopy: psychopy ``` +Note: PsychoPy PTB sound was non tested on Ubuntu 22.04, due to upgrade to 24.04. + +### On Linux (Ubuntu 24.04): + +Somehow was unable to run `python3.10` directly so installed it manually together with venv: + +``` +sudo add-apt-repository ppa:deadsnakes/ppa +sudo apt update +sudo apt install python3.10 +sudo apt install python3.10-venv +``` + +After this `python3.10` failed to create `venv` so used following commans to create it: + +``` +python3.10 -m venv --without-pip venv +source venv/bin/activate +curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py +python get-pip.py +``` + +Then installed `wxPython` it can take up to 1 hour to compile it (there is no pre-buuilt wheels for Ubuntu 24.04 ATM): + +``` +sudo apt update +sudo apt install python3-dev python3-pip libgtk-3-dev +pip install wxPython +``` + +Finally installed `psychopy`: + +``` +pip install psychopy +``` + +Applied security fix for audio scripts user, by adding current user to `audio` group: + +``` +sudo usermod -a -G audio $USER +``` + +and to adjust real-time permissions by creating a file `/etc/security/limits.d/99-realtime.conf` + +``` +sudo vi /etc/security/limits.d/99-realtime.conf +``` + +with the following content: + +``` +@audio - rtprio 99 +@audio - memlock unlimited +``` + +Then rebooted the system. + +NOTE: PsychoPy PTB still doesn't work on Ubuntu 24.04 and script produces error, TBD: + +``` +[DEBUG] play sound with psychopy ptb +Failure: No such entity +Failure: No such entity +``` + ## Summary - `PyDub` allows you to generate simple tones easily. From 72a045fe5ab3b7e64811c6d986deafe78efc6d40 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 1 Nov 2024 16:24:43 +0200 Subject: [PATCH 07/44] PsychoPy PTB sound installation/configuration example and notes for Linux/Ubuntu 24.04, #115. --- tools/audio-codes-notes.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/audio-codes-notes.md b/tools/audio-codes-notes.md index b551656..014570a 100644 --- a/tools/audio-codes-notes.md +++ b/tools/audio-codes-notes.md @@ -33,7 +33,7 @@ ### On MacOS: -Note: PsychoPy (2024.2.3) current requirements limits/suggests +NOTE: PsychoPy (2024.2.3) current requirements limits/suggests to Python version 3.10. Download and install the standalone package: @@ -51,7 +51,7 @@ Make sure Python 3.10 is installed and venv is explicitly created with it: pip install -r audio-codes-requirements.txt ``` -Note: first time PsychoPy is run, it may takes a long time to setup audio +NOTE: first time PsychoPy is run, it may takes a long time to setup audio download additional dependencies. ### On Linux (Ubuntu 22.04): @@ -88,7 +88,7 @@ Run psychopy: psychopy ``` -Note: PsychoPy PTB sound was non tested on Ubuntu 22.04, due to upgrade to 24.04. +NOTE: PsychoPy PTB sound was non tested on Ubuntu 22.04, due to upgrade to 24.04. ### On Linux (Ubuntu 24.04): @@ -110,7 +110,7 @@ curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py python get-pip.py ``` -Then installed `wxPython` it can take up to 1 hour to compile it (there is no pre-buuilt wheels for Ubuntu 24.04 ATM): +Then installed `wxPython` it can take up to 1 hour to compile it (there is no pre-built wheels for Ubuntu 24.04 ATM): ``` sudo apt update From eb3893a8e333ce30de835eb5c747d0ff6486a86a Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 1 Nov 2024 22:48:26 +0200 Subject: [PATCH 08/44] Singularity setup at Linux/Ubuntu 24.04, #115. --- tools/audio-codes-notes.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tools/audio-codes-notes.md b/tools/audio-codes-notes.md index 014570a..9bac0be 100644 --- a/tools/audio-codes-notes.md +++ b/tools/audio-codes-notes.md @@ -153,6 +153,28 @@ Failure: No such entity Failure: No such entity ``` +## NeuroDebian/Singularity + +On Linux (Ubuntu 22.04) : + +Was unable to install `singularity-container` from `neurodebian` repository after multiple attempts, so finally upgraded Linux 22.04 to 24.04. + + +On Linux (Ubuntu 24.04) : + +``` +wget -O- http://neuro.debian.net/lists/noble.de-m.libre | sudo tee /etc/apt/sources.list.d/neurodebian.sources.list +sudo apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 + +sudo apt-get update + +sudo apt-get install singularity-container +``` + +``` +singularity --version + singularity-ce version 4.1.1 +``` ## Summary - `PyDub` allows you to generate simple tones easily. From 687351a5655d1682e0c689a91354dd871abff6f6 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Mon, 4 Nov 2024 23:48:04 +0200 Subject: [PATCH 09/44] ReproStim tools readme, draft, #115. --- tools/README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tools/README.md diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..584cacb --- /dev/null +++ b/tools/README.md @@ -0,0 +1,73 @@ +# ReproStim Tools + +## Overview + +### A. Install Singularity + +#### On Linux (Ubuntu 24.04) : + +``` +wget -O- http://neuro.debian.net/lists/noble.de-m.libre | sudo tee /etc/apt/sources.list.d/neurodebian.sources.list +sudo apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 + +sudo apt-get update + +sudo apt-get install singularity-container +``` + +``` +singularity --version + singularity-ce version 4.1.1 +``` + +### B. Install ReproNim Containers + +#### On Linux (Ubuntu 24.04) : + +Ensure DataLad is installed: + +``` +sudo apt-get install datalad +``` + +As next step install and download ReproNim containers: + +``` +datalad install https://datasets.datalad.org/repronim/containers +datalad update +cd ./containers/images/repronim +datalad get . +``` + +Check that X11 system is used by default in Linux (Ubuntu 24.04), +psychopy will not work well with Wayland: + +``` +echo $XDG_SESSION_TYPE +``` + +It should return `x11`. If not, switch to X11: + + - At the login screen, click on your username. + - Before entering your password, look for a gear icon or "Options" button at the bottom right corner of the screen. + - Click the gear icon, and a menu should appear where you can select either "Ubuntu on Xorg" or "Ubuntu on Wayland." + - Choose "Ubuntu on Xorg" to switch to X11. + - Enter your password and log in. You should now be running the X11 session. + +### C. Run ReproNim TimeSync Script + +Make sure the current directory is one under singularity container +path created in the previous step B: + +``` +cd ./containers/images/repronim +``` + +Run the script: + +``` +singularity exec ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +``` +Where `REPROSTIM_PATH` is the local clone of https://github.com/ReproNim/reprostim repository. + +Last script parameter is the display ID, which is `1` in this case. \ No newline at end of file From 393d049fb79da408de6f73d86f9dbb16253471d5 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 5 Nov 2024 20:44:26 +0200 Subject: [PATCH 10/44] Added logging to reprostim-timesync-stimuli script, #115. --- tools/reprostim-timesync-stimuli | 37 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 3022abb..4f19083 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -5,12 +5,22 @@ from time import time, sleep t0 = time() +import logging import glob import sys import os import json from datetime import datetime +# setup logging +logger = logging.getLogger(__name__) +handler = logging.StreamHandler(sys.stderr) +formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') +handler.setFormatter(formatter) +logging.getLogger().addHandler(handler) +logger.setLevel(logging.DEBUG) +logger.info("reprostim-timesync-stimuli script started") + import qrcode from psychopy import prefs prefs.hardware['audioLib'] = ['ptb', 'pyo','pygame'] @@ -24,19 +34,30 @@ mode = 'event' interval = 2 +# setup file JSON logger logfn = sys.argv[1] # logfn = "../data/{0}/run{1}_logfile.csv".format(acqNum, runNum) if os.path.exists(logfn): + logger.error(f"Log file {logfn} already exists") raise RuntimeError(f"Log file {logfn} already exists") + +####################################################### +# Functions + def get_iso_time(t): return datetime.fromtimestamp(t).astimezone().isoformat() + def get_times(): t = time() return t, get_iso_time(t) +def log(rec): + f.write(json.dumps(rec).rstrip() + os.linesep) + + def mkrec(**kwargs): t, tstr = get_times() kwargs.update({ @@ -50,10 +71,9 @@ def mkrec(**kwargs): return kwargs -from psychopy import sound, core - # Function to play a beep at a specific frequency and duration def play_beep(frequency, duration, volume=1.0): + logger.debug(f'play_beep({frequency}, duration={duration}, volume={volume})') # Create a sound object with the specified frequency beep = sound.Sound(frequency, secs=duration, volume=volume) beep.play() @@ -62,13 +82,14 @@ def play_beep(frequency, duration, volume=1.0): return sound_time +####################################################### +# Main script code + +logger.info("main script started") # print(json.dumps(mkrec(blah=123), indent=4)) f = open(logfn, "w") -def log(rec): - f.write(json.dumps(rec).rstrip() + os.linesep) - win = visual.Window(fullscr=True, screen=int(sys.argv[2])) win.mouseVisible = False # hides the mouse pointer @@ -100,8 +121,12 @@ keys = [] # None received/expected clk = clock.Clock() t_start = time() +logger.debug(f"warming time: {(t_start-t0):.6f} sec") +logger.debug(f"mode: {mode}, interval: {interval}") +logger.info(f"starting loop with {ntrials} trials...") for acqNum in range(ntrials): + logger.debug(f"trial {acqNum}") rec = mkrec( event="trigger", @@ -153,3 +178,5 @@ for acqNum in range(ntrials): break f.close() +logger.info("main script finished") +logger.info("reprostim-timesync-stimuli script finished") From ae0da8c03e65d91c1b4016b5baff572f39c4871d Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Wed, 6 Nov 2024 19:52:10 +0200 Subject: [PATCH 11/44] PsychoPy PTB sound test temporary, #115. --- tools/reprostim-timesync-stimuli | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 4f19083..a2cceec 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -122,9 +122,12 @@ clk = clock.Clock() t_start = time() logger.debug(f"warming time: {(t_start-t0):.6f} sec") +play_beep(480, 120, 1.0) logger.debug(f"mode: {mode}, interval: {interval}") logger.info(f"starting loop with {ntrials} trials...") + + for acqNum in range(ntrials): logger.debug(f"trial {acqNum}") From 0a08534a4f0272e2a0463f29cbe91329c81400fa Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Wed, 6 Nov 2024 20:11:52 +0200 Subject: [PATCH 12/44] PsychoPy PTB sound test temporary, #115. --- tools/reprostim-timesync-stimuli | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index a2cceec..2d1cd25 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -82,6 +82,21 @@ def play_beep(frequency, duration, volume=1.0): return sound_time +def beep_4(): + logger.debug("beep_4()") + + #prefs.hardware['audioLib'] = ['sounddevice'] + prefs.hardware['audioLib'] = ['ptb'] + #prefs.hardware['audioDevice'] = 'HDA Intel PCH: ALC892 Digital (hw:0,1)' + logger.debug("play sound with psychopy ptb") + snd = sound.Sound('D', secs=120.0, stereo=True) + #snd = sound.Sound('beep_003.wav') + + snd.play() + core.wait(snd.duration) + logger.debug(f'Sound "{snd.sound}" has finished playing.') + + ####################################################### # Main script code @@ -122,7 +137,8 @@ clk = clock.Clock() t_start = time() logger.debug(f"warming time: {(t_start-t0):.6f} sec") -play_beep(480, 120, 1.0) +#play_beep(480, 120, 1.0) +beep_4() logger.debug(f"mode: {mode}, interval: {interval}") logger.info(f"starting loop with {ntrials} trials...") From 05834d84b94d511e42f81a68f02c809bcaaaefff Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Thu, 7 Nov 2024 20:45:37 +0200 Subject: [PATCH 13/44] Notes how to pacth reprostim-psychopy container for dev purposes, #115. --- tools/README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 584cacb..80d361f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -70,4 +70,30 @@ singularity exec ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/repr ``` Where `REPROSTIM_PATH` is the local clone of https://github.com/ReproNim/reprostim repository. -Last script parameter is the display ID, which is `1` in this case. \ No newline at end of file +Last script parameter is the display ID, which is `1` in this case. + +### D. Update Singularity Container Locally (Optionally) + +Optionally, you can update the container locally for development +and debugging purposes (with overlay): + +``` +singularity overlay create --size 1024 overlay.img +sudo singularity exec --overlay overlay.img repronim-psychopy--2024.1.4.sing bash +``` +As sample install some package: + +``` +apt-get update +apt-get install pulseaudio-utils +pactl +exit +``` + +And now run the script with overlay: + +``` +singularity exec -B /run/user/321 --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +``` + +Where `/run/user/321` is sample external pulseaudio device path bound to the container. \ No newline at end of file From ebb7b2b5a35d40503b879d36153d8687c43cc656 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Mon, 11 Nov 2024 16:28:02 +0200 Subject: [PATCH 14/44] Notes how to pacth reprostim-psychopy container for dev purposes, #115. --- tools/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 80d361f..14ba46b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -96,4 +96,9 @@ And now run the script with overlay: singularity exec -B /run/user/321 --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` -Where `/run/user/321` is sample external pulseaudio device path bound to the container. \ No newline at end of file +Where `/run/user/321` is sample external pulseaudio device path bound to the container. Usually +when you run the script w/o binding it will report error like: + +``` +Failed to create secure directory (/run/user/321/pulse): No such file or directory +``` \ No newline at end of file From ee40e5ee71c380a922183345edc506834e93fd9d Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 12 Nov 2024 22:39:11 +0200 Subject: [PATCH 15/44] PsychoPy-sounddevice dependency, #115. --- tools/audio-codes-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/audio-codes-requirements.txt b/tools/audio-codes-requirements.txt index 1f3db0c..5119c84 100644 --- a/tools/audio-codes-requirements.txt +++ b/tools/audio-codes-requirements.txt @@ -10,3 +10,4 @@ pydub>=0.25.1 pyaudio>=0.2.14 reedsolo>=1.7.0 psychopy +psychopy-sounddevice From 9c329e49427aef9000841d7f6267e452c5b51037 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 12 Nov 2024 22:49:08 +0200 Subject: [PATCH 16/44] Fallback to sounddevice from PTB one in psychopy, #115. --- tools/audio-codes.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tools/audio-codes.py b/tools/audio-codes.py index 9738750..3d0d112 100644 --- a/tools/audio-codes.py +++ b/tools/audio-codes.py @@ -3,12 +3,24 @@ import time from datetime import datetime +# NOTE: Early initialization of audio prefs is required otherwise +# use ~/.psychopy3/userPrefs.cfg [hardware] section, or load directly from +# some customPrefs.cfg file with prefs.loadFromFile API. +from psychopy import prefs + +#prefs.hardware['audioDevice'] = 'HDA Intel PCH: ALC892 Digital (hw:0,1)' +#prefs.hardware['audioLib'] = ['PTB'] +prefs.hardware['audioLib'] = ['sounddevice'] +# + import numpy as np import sounddevice as sd from scipy.io.wavfile import write from scipy.io import wavfile from reedsolo import RSCodec + from psychopy import core, sound, prefs +from psychtoolbox import audio logger = logging.getLogger(__name__) @@ -74,14 +86,23 @@ def crc8(data: bytes, polynomial: int = 0x31, init_value: int = 0x00) -> int: def list_audio_devices(): logger.debug("list_audio_devices()") + + logger.debug("[psychopy]") + logger.debug(f"audioLib : {prefs.hardware['audioLib']}") + logger.debug(f"audioDevice : {prefs.hardware['audioDevice']}") + + logger.debug("[sounddevice]") devices = sd.query_devices() # Query all devices for i, device in enumerate(devices): logger.debug(f"device [{i}] : {device['name']}") - default_device = sd.default.device # Get the current default input/output devices logger.debug(f"default in : {default_device[0]}") logger.debug(f"default out : {default_device[1]}") + logger.debug("[psytoolbox]") + for i, device in enumerate(audio.get_devices()): + logger.debug(f"device [{i}] : {device}") + # Class representing audio data frame in big-endian # format and encoded with Reed-Solomon error correction @@ -359,9 +380,10 @@ def parse_beep_3(): def beep_4(): logger.debug("beep_4()") - logger.debug("play sound with psychopy ptb") - snd = sound.Sound('D', secs=0.5, stereo=True) - #snd = sound.Sound('beep_003.wav') + + logger.debug(f"play sound with psychopy {prefs.hardware['audioLib']}") + #snd = sound.Sound('D', secs=500.0, stereo=True) + snd = sound.Sound('beep_003.wav') snd.play() core.wait(snd.duration) From e9b9c334ad77f3f1537349eb0046d8ad9da62b6a Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 12 Nov 2024 23:30:54 +0200 Subject: [PATCH 17/44] Notes for singularity container and PULSE_SERVER env variable, #115. --- tools/README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/README.md b/tools/README.md index 14ba46b..8742448 100644 --- a/tools/README.md +++ b/tools/README.md @@ -93,12 +93,19 @@ exit And now run the script with overlay: ``` -singularity exec -B /run/user/321 --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +singularity exec -B /run/user/321/pulse:/run/user/321/pulse --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` -Where `/run/user/321` is sample external pulseaudio device path bound to the container. Usually +Where `/run/user/321/pulse` is sample external pulseaudio device path bound to the container. Usually when you run the script w/o binding it will report error like: ``` Failed to create secure directory (/run/user/321/pulse): No such file or directory -``` \ No newline at end of file +``` + +NOTE: Make sure `PULSE_SERVER` is specified in the container environment and +points to the host pulseaudio server. e.g.: + +``` +export PULSE_SERVER=unix:/run/user/321/pulse/native +``` From dd94679bb4313b39ed6a77a24922b8b6d070f4ae Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Thu, 14 Nov 2024 17:26:05 +0200 Subject: [PATCH 18/44] Force psychopy sound prefs to be set early, #115. --- tools/reprostim-timesync-stimuli | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 2d1cd25..e589287 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -12,6 +12,11 @@ import os import json from datetime import datetime +from psychopy import prefs +#prefs.hardware['audioLib'] = ['ptb', 'pyo','pygame'] +prefs.hardware['audioLib'] = ['sounddevice'] + + # setup logging logger = logging.getLogger(__name__) handler = logging.StreamHandler(sys.stderr) @@ -22,10 +27,9 @@ logger.setLevel(logging.DEBUG) logger.info("reprostim-timesync-stimuli script started") import qrcode -from psychopy import prefs -prefs.hardware['audioLib'] = ['ptb', 'pyo','pygame'] -from psychopy import sound from psychopy import visual, core, event, clock +from psychopy import sound + import numpy as np @@ -85,10 +89,7 @@ def play_beep(frequency, duration, volume=1.0): def beep_4(): logger.debug("beep_4()") - #prefs.hardware['audioLib'] = ['sounddevice'] - prefs.hardware['audioLib'] = ['ptb'] - #prefs.hardware['audioDevice'] = 'HDA Intel PCH: ALC892 Digital (hw:0,1)' - logger.debug("play sound with psychopy ptb") + logger.debug(f"play sound with psychopy {prefs.hardware['audioLib']}") snd = sound.Sound('D', secs=120.0, stereo=True) #snd = sound.Sound('beep_003.wav') From 4ee70f18d75ca28ea4a2124dddd3d8dd55b9d944 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 15:59:58 +0200 Subject: [PATCH 19/44] Added psychopy logs, #115. --- tools/audio-codes.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/audio-codes.py b/tools/audio-codes.py index 3d0d112..fe8f8b3 100644 --- a/tools/audio-codes.py +++ b/tools/audio-codes.py @@ -8,11 +8,18 @@ # some customPrefs.cfg file with prefs.loadFromFile API. from psychopy import prefs +#prefs.general['audioLib'] = 'sounddevice' #prefs.hardware['audioDevice'] = 'HDA Intel PCH: ALC892 Digital (hw:0,1)' #prefs.hardware['audioLib'] = ['PTB'] prefs.hardware['audioLib'] = ['sounddevice'] # +# provide psychopy logs +from psychopy import logging as pl +#pl.console.setLevel(pl.NOTSET) +pl.console.setLevel(pl.DEBUG) + + import numpy as np import sounddevice as sd from scipy.io.wavfile import write @@ -23,6 +30,7 @@ from psychtoolbox import audio +# init std logger logger = logging.getLogger(__name__) handler = logging.StreamHandler(sys.stderr) formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') @@ -30,6 +38,7 @@ logging.getLogger().addHandler(handler) logger.setLevel(logging.DEBUG) + # audio barcode/qr helper functions def bit_enumerator(data): @@ -103,6 +112,12 @@ def list_audio_devices(): for i, device in enumerate(audio.get_devices()): logger.debug(f"device [{i}] : {device}") + logger.debug("[psychopy.backend_ptb]") + # TODO: investigate why only single out device listed from + # USB capture but defult one is not shown + # logger.debug(sound.backend_ptb.getDevices()) + + # Class representing audio data frame in big-endian # format and encoded with Reed-Solomon error correction @@ -382,7 +397,7 @@ def beep_4(): logger.debug("beep_4()") logger.debug(f"play sound with psychopy {prefs.hardware['audioLib']}") - #snd = sound.Sound('D', secs=500.0, stereo=True) + #snd = sound.Sound('D', secs=10.0, stereo=True) snd = sound.Sound('beep_003.wav') snd.play() From c13de26ba3c83b96f68675b29158cbc496e8c5c0 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 16:07:14 +0200 Subject: [PATCH 20/44] Added psychopy logs, #115. --- tools/reprostim-timesync-stimuli | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index e589287..736d5d7 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -16,6 +16,11 @@ from psychopy import prefs #prefs.hardware['audioLib'] = ['ptb', 'pyo','pygame'] prefs.hardware['audioLib'] = ['sounddevice'] +# provide psychopy logs +from psychopy import logging as pl +#pl.console.setLevel(pl.NOTSET) +pl.console.setLevel(pl.DEBUG) + # setup logging logger = logging.getLogger(__name__) From 0158e10153cdf84316e4244503521934eef81a09 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 21:45:11 +0200 Subject: [PATCH 21/44] Update tools/README.md Co-authored-by: Yaroslav Halchenko --- tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 8742448..cf515d2 100644 --- a/tools/README.md +++ b/tools/README.md @@ -6,7 +6,7 @@ #### On Linux (Ubuntu 24.04) : -``` +```shell wget -O- http://neuro.debian.net/lists/noble.de-m.libre | sudo tee /etc/apt/sources.list.d/neurodebian.sources.list sudo apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 From 8b2f3dc23e6243c024b3aebdcbcd9ace17c23420 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 21:45:22 +0200 Subject: [PATCH 22/44] Update tools/README.md Co-authored-by: Yaroslav Halchenko --- tools/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/README.md b/tools/README.md index cf515d2..97be24a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -15,8 +15,8 @@ sudo apt-get update sudo apt-get install singularity-container ``` -``` -singularity --version +```shell +$ singularity --version singularity-ce version 4.1.1 ``` From bf0f399b8f86b9779bcdee3f239b7257a8b479bb Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 21:46:41 +0200 Subject: [PATCH 23/44] Update tools/README.md Co-authored-by: Yaroslav Halchenko --- tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 97be24a..050634e 100644 --- a/tools/README.md +++ b/tools/README.md @@ -93,7 +93,7 @@ exit And now run the script with overlay: ``` -singularity exec -B /run/user/321/pulse:/run/user/321/pulse --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +singularity exec -B /run/user/$(id -u)/pulse --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` Where `/run/user/321/pulse` is sample external pulseaudio device path bound to the container. Usually From 7adaf8d2d083bb23c3e368da4170e7e964e25d73 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 21:47:01 +0200 Subject: [PATCH 24/44] Update tools/README.md Co-authored-by: Yaroslav Halchenko --- tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 050634e..e4be470 100644 --- a/tools/README.md +++ b/tools/README.md @@ -108,4 +108,4 @@ points to the host pulseaudio server. e.g.: ``` export PULSE_SERVER=unix:/run/user/321/pulse/native -``` +--env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native From 904ec2123ee61860fe4bb8a6bddaee4fc0e3dc85 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 15 Nov 2024 22:03:48 +0200 Subject: [PATCH 25/44] Notes singularity container, #115. --- tools/README.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tools/README.md b/tools/README.md index e4be470..868fe1f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -59,13 +59,13 @@ It should return `x11`. If not, switch to X11: Make sure the current directory is one under singularity container path created in the previous step B: -``` +```shell cd ./containers/images/repronim ``` Run the script: -``` +```shell singularity exec ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` Where `REPROSTIM_PATH` is the local clone of https://github.com/ReproNim/reprostim repository. @@ -77,13 +77,13 @@ Last script parameter is the display ID, which is `1` in this case. Optionally, you can update the container locally for development and debugging purposes (with overlay): -``` -singularity overlay create --size 1024 overlay.img -sudo singularity exec --overlay overlay.img repronim-psychopy--2024.1.4.sing bash +```shell +singularity overlay create --size 1024 repronim-psychopy--2024.1.4.overlay +sudo singularity exec --overlay repronim-psychopy--2024.1.4.overlay repronim-psychopy--2024.1.4.sing bash ``` As sample install some package: -``` +```shell apt-get update apt-get install pulseaudio-utils pactl @@ -92,20 +92,22 @@ exit And now run the script with overlay: -``` -singularity exec -B /run/user/$(id -u)/pulse --overlay overlay.img ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +```shell +singularity exec -B /run/user/$(id -u)/pulse --env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native --overlay ./repronim-psychopy--2024.1.4.overlay ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` Where `/run/user/321/pulse` is sample external pulseaudio device path bound to the container. Usually when you run the script w/o binding it will report error like: -``` +```shell Failed to create secure directory (/run/user/321/pulse): No such file or directory ``` NOTE: Make sure `PULSE_SERVER` is specified in the container environment and points to the host pulseaudio server. e.g.: -``` +```shell export PULSE_SERVER=unix:/run/user/321/pulse/native ---env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native +``` + +TODO: Also limit container resource -c \ No newline at end of file From 9be9428d266b88de31c644ac753986d45e8979b6 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 19 Nov 2024 17:59:13 +0200 Subject: [PATCH 26/44] Dev notes for singularity container, #115. --- tools/README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 868fe1f..b8fd3f9 100644 --- a/tools/README.md +++ b/tools/README.md @@ -110,4 +110,30 @@ points to the host pulseaudio server. e.g.: export PULSE_SERVER=unix:/run/user/321/pulse/native ``` -TODO: Also limit container resource -c \ No newline at end of file +TODO: Also limit container resource -c + +#### Dev Notes (for local PC altogether) + +```shell +cd ~/Projects/Dartmouth/branches/datalad/containers/images/repronim +export REPROSTIM_PATH=~/Projects/Dartmouth/branches/reprostim + +singularity overlay create --size 1024 repronim-psychopy--2024.1.4.overlay +sudo singularity exec --overlay repronim-psychopy--2024.1.4.overlay repronim-psychopy--2024.1.4.sing bash + +# execute in shell +apt-get update +apt-get install portaudio19-dev pulseaudio pavucontrol pulseaudio-utils +pactl +exit + +# make sure all python packages are installed +sudo singularity exec --overlay repronim-psychopy--2024.1.4.overlay repronim-psychopy--2024.1.4.sing python3 -m pip install pyzbar opencv-python numpy click pydantic sounddevice scipy pydub pyaudio reedsolo psychopy-sounddevice + +# and run the script +rm output.log +singularity exec -B /run/user/$(id -u)/pulse --env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native --overlay ./repronim-psychopy--2024.1.4.overlay ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 + +``` + + From 26b62dabb46c62f61de5f50f89d0db19bf10375f Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 19 Nov 2024 18:27:32 +0200 Subject: [PATCH 27/44] Use clean env to execute psychopy singularity container, #115. --- tools/README.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tools/README.md b/tools/README.md index b8fd3f9..e6e1d2b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -118,8 +118,14 @@ TODO: Also limit container resource -c cd ~/Projects/Dartmouth/branches/datalad/containers/images/repronim export REPROSTIM_PATH=~/Projects/Dartmouth/branches/reprostim -singularity overlay create --size 1024 repronim-psychopy--2024.1.4.overlay -sudo singularity exec --overlay repronim-psychopy--2024.1.4.overlay repronim-psychopy--2024.1.4.sing bash +singularity overlay create \ + --size 1024 \ + repronim-psychopy--2024.1.4.overlay + +sudo singularity exec \ + --overlay repronim-psychopy--2024.1.4.overlay \ + repronim-psychopy--2024.1.4.sing \ + bash # execute in shell apt-get update @@ -128,11 +134,22 @@ pactl exit # make sure all python packages are installed -sudo singularity exec --overlay repronim-psychopy--2024.1.4.overlay repronim-psychopy--2024.1.4.sing python3 -m pip install pyzbar opencv-python numpy click pydantic sounddevice scipy pydub pyaudio reedsolo psychopy-sounddevice +sudo singularity exec \ + --overlay repronim-psychopy--2024.1.4.overlay \ + repronim-psychopy--2024.1.4.sing \ + python3 -m pip install pyzbar opencv-python numpy click pydantic sounddevice scipy pydub pyaudio reedsolo psychopy-sounddevice # and run the script rm output.log -singularity exec -B /run/user/$(id -u)/pulse --env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native --overlay ./repronim-psychopy--2024.1.4.overlay ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +singularity exec \ + --cleanenv --contain \ + -B /run/user/$(id -u)/pulse \ + -B ${REPROSTIM_PATH} \ + --env DISPLAY=$DISPLAY \ + --env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native \ + --overlay ./repronim-psychopy--2024.1.4.overlay \ + ./repronim-psychopy--2024.1.4.sing \ + ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` From dd39f776d27df0d973f4371ae4005dba17744b87 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 19 Nov 2024 18:33:47 +0200 Subject: [PATCH 28/44] Use clean env to execute psychopy singularity container, #115. --- tools/README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tools/README.md b/tools/README.md index e6e1d2b..1e85c5d 100644 --- a/tools/README.md +++ b/tools/README.md @@ -78,8 +78,14 @@ Optionally, you can update the container locally for development and debugging purposes (with overlay): ```shell -singularity overlay create --size 1024 repronim-psychopy--2024.1.4.overlay -sudo singularity exec --overlay repronim-psychopy--2024.1.4.overlay repronim-psychopy--2024.1.4.sing bash +singularity overlay create \ + --size 1024 \ + repronim-psychopy--2024.1.4.overlay + +sudo singularity exec \ + --overlay repronim-psychopy--2024.1.4.overlay \ + repronim-psychopy--2024.1.4.sing \ + bash ``` As sample install some package: @@ -93,7 +99,15 @@ exit And now run the script with overlay: ```shell -singularity exec -B /run/user/$(id -u)/pulse --env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native --overlay ./repronim-psychopy--2024.1.4.overlay ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 +singularity exec \ + --cleanenv --contain \ + -B /run/user/$(id -u)/pulse \ + -B ${REPROSTIM_PATH} \ + --env DISPLAY=$DISPLAY \ + --env PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native \ + --overlay ./repronim-psychopy--2024.1.4.overlay \ + ./repronim-psychopy--2024.1.4.sing \ + ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` Where `/run/user/321/pulse` is sample external pulseaudio device path bound to the container. Usually From 09082aa9db682f08546ef4051f7caa444cdf9f83 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 19 Nov 2024 19:33:20 +0200 Subject: [PATCH 29/44] Use clean env to execute psychopy singularity container, #115. --- tools/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/README.md b/tools/README.md index 1e85c5d..244f231 100644 --- a/tools/README.md +++ b/tools/README.md @@ -124,8 +124,6 @@ points to the host pulseaudio server. e.g.: export PULSE_SERVER=unix:/run/user/321/pulse/native ``` -TODO: Also limit container resource -c - #### Dev Notes (for local PC altogether) ```shell From f2020b7b2ba544989a0577c47e9beb4136e2e3d3 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 22 Nov 2024 15:25:27 +0200 Subject: [PATCH 30/44] Update reprostim-timesync-stimuli script with click, WiP, #115. --- tools/reprostim-timesync-stimuli | 268 ++++++++++++++++++------------- 1 file changed, 160 insertions(+), 108 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 736d5d7..1dc08dd 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -# TODO: review https://psychopy.org/api/sound/playback.html and possibly use PTB's -# facilities in PsychoPy for precise audio placement in time. -# from time import time, sleep t0 = time() +import click +from enum import Enum import logging import glob import sys @@ -38,17 +37,13 @@ from psychopy import sound import numpy as np -# 'interval' -mode = 'event' -interval = 2 - -# setup file JSON logger -logfn = sys.argv[1] -# logfn = "../data/{0}/run{1}_logfile.csv".format(acqNum, runNum) -if os.path.exists(logfn): - logger.error(f"Log file {logfn} already exists") - raise RuntimeError(f"Log file {logfn} already exists") +####################################################### +# Constants +class Mode(str, Enum): + EVENT = "event" + INTERVAL = "interval" + BEEP = "beep" ####################################################### @@ -67,7 +62,7 @@ def log(rec): f.write(json.dumps(rec).rstrip() + os.linesep) -def mkrec(**kwargs): +def mkrec(mode, interval, **kwargs): t, tstr = get_times() kwargs.update({ "logfn": logfn, @@ -91,117 +86,174 @@ def play_beep(frequency, duration, volume=1.0): return sound_time -def beep_4(): - logger.debug("beep_4()") - - logger.debug(f"play sound with psychopy {prefs.hardware['audioLib']}") - snd = sound.Sound('D', secs=120.0, stereo=True) - #snd = sound.Sound('beep_003.wav') - - snd.play() - core.wait(snd.duration) - logger.debug(f'Sound "{snd.sound}" has finished playing.') - ####################################################### # Main script code -logger.info("main script started") -# print(json.dumps(mkrec(blah=123), indent=4)) - -f = open(logfn, "w") +def do_beep(): + logger.debug("do_beep()") -win = visual.Window(fullscr=True, screen=int(sys.argv[2])) -win.mouseVisible = False # hides the mouse pointer - -log(mkrec(event="started", start_time=t0, start_time_formatted=get_iso_time(t0))) + logger.debug(f"Play sound with psychopy {prefs.hardware['audioLib']}") + snd = sound.Sound('A', secs=120.0, stereo=True) + #snd = sound.Sound('beep_003.wav') -message = visual.TextStim(win, text="""Waiting for scanner trigger.\nInstructions - for Participant...""") -message.draw() + snd.play() + core.wait(snd.duration) + logger.debug(f'Sound "{snd.sound}" has finished playing.') -fixation = visual.TextStim(win, text='+') -reproinMessage = visual.TextStim(win, text="", pos=(0, -.7), - height=.05) -win.flip() +def do_init(logfn: str) -> bool: + if os.path.exists(logfn): + logger.error(f"Log file {logfn} already exists") + return False + return True -fixation.draw() # Change properties of existing stim -win.flip() +def do_main(mode: Mode, logfn: str, display: int, interval: float) -> int: + logger.info("main script started") + # print(json.dumps(mkrec(blah=123), indent=4)) -spd = 0.500 # Stimulus Presentation Duration -soa = 6.000 # Stimulus Onset Asynchrony -ntrials = 300 -iwt = 5 # Initial Wait Time between scanner trigger and first stimulus + if mode == Mode.BEEP: + do_beep() + return 0 -stim_images = [] -stim_names = [] -keys = [] # None received/expected + f = open(logfn, "w") -clk = clock.Clock() -t_start = time() + win = visual.Window(fullscr=True, screen=display) + win.mouseVisible = False # hides the mouse pointer -logger.debug(f"warming time: {(t_start-t0):.6f} sec") -#play_beep(480, 120, 1.0) -beep_4() -logger.debug(f"mode: {mode}, interval: {interval}") -logger.info(f"starting loop with {ntrials} trials...") + log(mkrec(mode, interval, + event="started", start_time=t0, start_time_formatted=get_iso_time(t0))) + message = visual.TextStim(win, text="""Waiting for scanner trigger.\nInstructions + for Participant...""") + message.draw() + fixation = visual.TextStim(win, text='+') + reproinMessage = visual.TextStim(win, text="", pos=(0, -.7), + height=.05) -for acqNum in range(ntrials): - logger.debug(f"trial {acqNum}") + win.flip() - rec = mkrec( - event="trigger", - acqNum=acqNum - ) - if mode == 'event': - print("Waiting for an event") - keys = event.waitKeys(maxWait=120) # keyList=['5']) - elif mode == 'interval': - target_time = t_start + acqNum * interval - to_wait = target_time - time() - # sleep some part of it if long enough - if to_wait >= .2: - sleep(to_wait * 0.7) - # busy loop without sleep to not miss it - while time() < target_time: - pass - else: - raise ValueError(mode) - - freq = 2000 + (100*acqNum) - beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) - beep.play() - rec['sound_time'] = get_times() - rec['sound_freq'] = freq - rec['keys'] = keys - tkeys, tkeys_str = get_times() - rec["keys_time"] = tkeys - rec["keys_time_str"] = tkeys_str - qr = visual.ImageStim(win, - qrcode.make(json.dumps(rec)), - pos=(0, 0) - ) - qr.size = qr.size *1 - qr.draw() + fixation.draw() # Change properties of existing stim win.flip() - tflip, tflip_str = get_times() - rec['time_flip'] = tflip - rec['time_flip_formatted'] = tflip_str - core.wait(0.5) - fixation.draw() - win.flip() - toff, toff_str = get_times() - rec['prior_time_off'] = toff - rec['prior_time_off_str'] = toff_str - log(rec) - if 'q' in keys: - break - -f.close() -logger.info("main script finished") -logger.info("reprostim-timesync-stimuli script finished") + + spd = 0.500 # Stimulus Presentation Duration + soa = 6.000 # Stimulus Onset Asynchrony + ntrials = 300 + iwt = 5 # Initial Wait Time between scanner trigger and first stimulus + + stim_images = [] + stim_names = [] + keys = [] # None received/expected + + clk = clock.Clock() + t_start = time() + + logger.debug(f"warming time: {(t_start-t0):.6f} sec") + #play_beep(480, 120, 1.0) + beep_4() + logger.debug(f"mode: {mode}, interval: {interval}") + logger.info(f"starting loop with {ntrials} trials...") + + + + for acqNum in range(ntrials): + logger.debug(f"trial {acqNum}") + + rec = mkrec( + mode, + interval, + event="trigger", + acqNum=acqNum + ) + + if mode == Mode.EVENT: + print("Waiting for an event") + keys = event.waitKeys(maxWait=120) # keyList=['5']) + elif mode == Mode.INTERVAL: + target_time = t_start + acqNum * interval + to_wait = target_time - time() + # sleep some part of it if long enough + if to_wait >= .2: + sleep(to_wait * 0.7) + # busy loop without sleep to not miss it + while time() < target_time: + pass + else: + raise ValueError(mode) + + freq = 2000 + (100*acqNum) + beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) + beep.play() + rec['sound_time'] = get_times() + rec['sound_freq'] = freq + rec['keys'] = keys + tkeys, tkeys_str = get_times() + rec["keys_time"] = tkeys + rec["keys_time_str"] = tkeys_str + qr = visual.ImageStim(win, + qrcode.make(json.dumps(rec)), + pos=(0, 0) + ) + qr.size = qr.size *1 + qr.draw() + win.flip() + tflip, tflip_str = get_times() + rec['time_flip'] = tflip + rec['time_flip_formatted'] = tflip_str + core.wait(0.5) + fixation.draw() + win.flip() + toff, toff_str = get_times() + rec['prior_time_off'] = toff + rec['prior_time_off_str'] = toff_str + log(rec) + if 'q' in keys: + break + + f.close() + logger.info("main script finished") + logger.info("reprostim-timesync-stimuli script finished") + + +@click.command(help='PsychoPy reprostim-timesync-stimuli script.') +@click.option('-m', '--mode', type=click.Choice([mode.value for mode in Mode], + case_sensitive=False), + default=Mode.EVENT, + help='Mode of operation: event, interval, or beep.') +@click.option('-o', '--output', default="output.log", type=str, + help='Output log file name.') +@click.option('-d', '--display', default=1, type=int, + help='Display number as an integer (default: 1).') +@click.option('-i', '--interval', default=2, type=float, + help='Specifies interval value (default: 2.0).') +@click.option('--log-level', default='DEBUG', + type=click.Choice(['DEBUG', 'INFO', + 'WARNING', 'ERROR', + 'CRITICAL']), + help='Set the logging level') +@click.pass_context +def main(ctx, mode: str, output: str, display: int, + interval: float, + log_level): + logger.setLevel(log_level) + # psychopy has similar logging levels like + # default logging module + #pl.console.setLevel(log_level) + logger.debug("reprostim-timesync-stimuli script started") + logger.debug(f" Started on : {datetime.now()}") + logger.debug(f" Mode : {mode}") + logger.debug(f" Output : {output}") + logger.debug(f" Display : {display}") + if not do_init(output): + return -1 + return do_main(mode, output, display, interval) + + +if __name__ == '__main__': + code = main() + logger.info(f"Exit on : {datetime.now()}") + logger.info(f"Exit code : {code}") + sys.exit(code) From 1547944ca2a1e78b7115ca7fdd52f93a16e10464 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 22 Nov 2024 16:59:21 +0200 Subject: [PATCH 31/44] Update script with duration opts, WiP, #115. --- tools/reprostim-timesync-stimuli | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 1dc08dd..595a45c 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -90,16 +90,16 @@ def play_beep(frequency, duration, volume=1.0): ####################################################### # Main script code -def do_beep(): +def do_beep(duration: float): logger.debug("do_beep()") - logger.debug(f"Play sound with psychopy {prefs.hardware['audioLib']}") - snd = sound.Sound('A', secs=120.0, stereo=True) + snd = sound.Sound('A', secs=duration, stereo=True) #snd = sound.Sound('beep_003.wav') + logger.debug(f"Play sound '{snd.sound}' with psychopy {prefs.hardware['audioLib']}") snd.play() core.wait(snd.duration) - logger.debug(f'Sound "{snd.sound}" has finished playing.') + logger.debug(f"Sound '{snd.sound}' has finished playing.") def do_init(logfn: str) -> bool: @@ -109,12 +109,13 @@ def do_init(logfn: str) -> bool: return True -def do_main(mode: Mode, logfn: str, display: int, interval: float) -> int: +def do_main(mode: Mode, logfn: str, display: int, + duration: float, interval: float) -> int: logger.info("main script started") # print(json.dumps(mkrec(blah=123), indent=4)) if mode == Mode.BEEP: - do_beep() + do_beep(duration) return 0 f = open(logfn, "w") @@ -227,6 +228,8 @@ def do_main(mode: Mode, logfn: str, display: int, interval: float) -> int: help='Output log file name.') @click.option('-d', '--display', default=1, type=int, help='Display number as an integer (default: 1).') +@click.option('-d', '--duration', default=2, type=float, + help='Specifies script duration in seconds.') @click.option('-i', '--interval', default=2, type=float, help='Specifies interval value (default: 2.0).') @click.option('--log-level', default='DEBUG', @@ -236,7 +239,7 @@ def do_main(mode: Mode, logfn: str, display: int, interval: float) -> int: help='Set the logging level') @click.pass_context def main(ctx, mode: str, output: str, display: int, - interval: float, + duration: float, interval: float, log_level): logger.setLevel(log_level) # psychopy has similar logging levels like @@ -244,12 +247,14 @@ def main(ctx, mode: str, output: str, display: int, #pl.console.setLevel(log_level) logger.debug("reprostim-timesync-stimuli script started") logger.debug(f" Started on : {datetime.now()}") - logger.debug(f" Mode : {mode}") - logger.debug(f" Output : {output}") - logger.debug(f" Display : {display}") + logger.debug(f" mode : {mode}") + logger.debug(f" output : {output}") + logger.debug(f" display : {display}") + logger.debug(f" duration : {duration}") + logger.debug(f" interval : {interval}") if not do_init(output): return -1 - return do_main(mode, output, display, interval) + return do_main(mode, output, display, duration, interval) if __name__ == '__main__': From b7515aad9c02f0794202b0293dd6bc1d6e6aa104 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 22 Nov 2024 16:59:34 +0200 Subject: [PATCH 32/44] Update script with duration opts, WiP, #115. --- tools/audio-codes-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/audio-codes-requirements.txt b/tools/audio-codes-requirements.txt index 5119c84..28f5f95 100644 --- a/tools/audio-codes-requirements.txt +++ b/tools/audio-codes-requirements.txt @@ -11,3 +11,4 @@ pyaudio>=0.2.14 reedsolo>=1.7.0 psychopy psychopy-sounddevice +qrcode From d62b775c83b094c9aed5c562daee5f28b2cefd54 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Fri, 22 Nov 2024 17:14:19 +0200 Subject: [PATCH 33/44] Update script and fixes, WiP, #115. --- tools/reprostim-timesync-stimuli | 35 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 595a45c..ea2bcbb 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -58,11 +58,11 @@ def get_times(): return t, get_iso_time(t) -def log(rec): +def log(f, rec): f.write(json.dumps(rec).rstrip() + os.linesep) -def mkrec(mode, interval, **kwargs): +def mkrec(mode, logfn, interval, **kwargs): t, tstr = get_times() kwargs.update({ "logfn": logfn, @@ -110,9 +110,9 @@ def do_init(logfn: str) -> bool: def do_main(mode: Mode, logfn: str, display: int, - duration: float, interval: float) -> int: + ntrials: int, duration: float, interval: float) -> int: logger.info("main script started") - # print(json.dumps(mkrec(blah=123), indent=4)) + # print(json.dumps(mkrec(mode, logfn, interval, blah=123), indent=4)) if mode == Mode.BEEP: do_beep(duration) @@ -123,7 +123,7 @@ def do_main(mode: Mode, logfn: str, display: int, win = visual.Window(fullscr=True, screen=display) win.mouseVisible = False # hides the mouse pointer - log(mkrec(mode, interval, + log(f, mkrec(mode, logfn, interval, event="started", start_time=t0, start_time_formatted=get_iso_time(t0))) message = visual.TextStim(win, text="""Waiting for scanner trigger.\nInstructions @@ -142,7 +142,6 @@ def do_main(mode: Mode, logfn: str, display: int, spd = 0.500 # Stimulus Presentation Duration soa = 6.000 # Stimulus Onset Asynchrony - ntrials = 300 iwt = 5 # Initial Wait Time between scanner trigger and first stimulus stim_images = [] @@ -153,18 +152,15 @@ def do_main(mode: Mode, logfn: str, display: int, t_start = time() logger.debug(f"warming time: {(t_start-t0):.6f} sec") - #play_beep(480, 120, 1.0) - beep_4() - logger.debug(f"mode: {mode}, interval: {interval}") logger.info(f"starting loop with {ntrials} trials...") - for acqNum in range(ntrials): logger.debug(f"trial {acqNum}") rec = mkrec( mode, + logfn, interval, event="trigger", acqNum=acqNum @@ -185,11 +181,11 @@ def do_main(mode: Mode, logfn: str, display: int, else: raise ValueError(mode) - freq = 2000 + (100*acqNum) - beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) - beep.play() + #freq = 2000 + (100*acqNum) + #beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) + #beep.play() rec['sound_time'] = get_times() - rec['sound_freq'] = freq + #rec['sound_freq'] = freq rec['keys'] = keys tkeys, tkeys_str = get_times() rec["keys_time"] = tkeys @@ -210,7 +206,7 @@ def do_main(mode: Mode, logfn: str, display: int, toff, toff_str = get_times() rec['prior_time_off'] = toff rec['prior_time_off_str'] = toff_str - log(rec) + log(f, rec) if 'q' in keys: break @@ -228,6 +224,8 @@ def do_main(mode: Mode, logfn: str, display: int, help='Output log file name.') @click.option('-d', '--display', default=1, type=int, help='Display number as an integer (default: 1).') +@click.option('-t', '--trials', default=300, type=int, + help='Specifies number of trials.') @click.option('-d', '--duration', default=2, type=float, help='Specifies script duration in seconds.') @click.option('-i', '--interval', default=2, type=float, @@ -238,7 +236,9 @@ def do_main(mode: Mode, logfn: str, display: int, 'CRITICAL']), help='Set the logging level') @click.pass_context -def main(ctx, mode: str, output: str, display: int, +def main(ctx, mode: str, + output: str, display: int, + trials: int, duration: float, interval: float, log_level): logger.setLevel(log_level) @@ -254,7 +254,8 @@ def main(ctx, mode: str, output: str, display: int, logger.debug(f" interval : {interval}") if not do_init(output): return -1 - return do_main(mode, output, display, duration, interval) + return do_main(mode, output, display, + trials, duration, interval) if __name__ == '__main__': From 20bf48ceb3a7ab3ddbbc8bfdeedef1ac2aeaefdd Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Sun, 24 Nov 2024 13:27:51 +0200 Subject: [PATCH 34/44] Normalize names in audio-codes, #115. --- tools/audio-codes.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/audio-codes.py b/tools/audio-codes.py index fe8f8b3..bab5a87 100644 --- a/tools/audio-codes.py +++ b/tools/audio-codes.py @@ -42,7 +42,7 @@ # audio barcode/qr helper functions def bit_enumerator(data): - if isinstance(data, AudioFrame): + if isinstance(data, DataMessage): data = data.encode() if isinstance(data, str): # If data is a string, iterate over each character @@ -119,13 +119,13 @@ def list_audio_devices(): -# Class representing audio data frame in big-endian +# Class representing audio data message in big-endian # format and encoded with Reed-Solomon error correction -# where the frame is structured as follows: +# where the message is structured as follows: # - 1st byte is CRC-8 checksum # - 2nd byte is the length of the data # - 3+ and the rest is the data itself -class AudioFrame: +class DataMessage: def __init__(self): self.value: bytes = b'' self.length: int = 0 @@ -150,7 +150,7 @@ def encode(self) -> bytes: logger.debug("size info") logger.debug(f" - data : {len(self.value)} bytes, {self.value}") b: bytes = bytes([self.crc8, self.length]) + self.value - logger.debug(f" - frame : {len(b)} bytes, {b}") + logger.debug(f" - message : {len(b)} bytes, {b}") if self.use_ecc: b = bytes(self.rsc.encode(b)) logger.debug(f" - ecc : {len(b)} bytes, {b}") @@ -344,7 +344,7 @@ def beep_2(): data = b'\x00\x00\x00\x00' data = b'\xFF\xFF\xFF\xFF' data = b'\xF1\xF2\xF3\xF4' - data = AudioFrame() + data = DataMessage() n = 0xA001 n = 1234 logger.debug(f"encoded uint16: {n}") @@ -359,7 +359,7 @@ def parse_beep_2(): logger.debug("parse_beep_2()") af: AudioFsk = AudioFsk() detected_data = af.parse('beep_002.wav') - af: AudioFrame = AudioFrame() + af: DataMessage = DataMessage() af.decode(detected_data) u16 = af.get_uint16() ab: bytes = af.get_bytes() @@ -371,7 +371,7 @@ def parse_beep_2(): def beep_3(): logger.debug("beep_3()") af: AudioFsk = AudioFsk() - data = AudioFrame() + data = DataMessage() s: str = "Hello World! "+datetime.now().strftime("%Y-%m-%d %H:%M:%S") logger.debug(f"encoded str : {s}") data.set_str(s) @@ -384,7 +384,7 @@ def parse_beep_3(): logger.debug("parse_beep_3()") af: AudioFsk = AudioFsk() detected_data = af.parse('beep_003.wav') - af: AudioFrame = AudioFrame() + af: DataMessage = DataMessage() af.decode(detected_data) ab = af.get_bytes() s = af.get_str() From 7f1d8c6c1a42d768597d491fbc5e07f369fc4e27 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Sun, 24 Nov 2024 14:26:07 +0200 Subject: [PATCH 35/44] Fix for keboard events and generate output log filename, #115. --- tools/reprostim-timesync-stimuli | 37 ++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index ea2bcbb..005fc9e 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -33,7 +33,7 @@ logger.info("reprostim-timesync-stimuli script started") import qrcode from psychopy import visual, core, event, clock from psychopy import sound - +from psychopy.hardware import keyboard import numpy as np @@ -85,7 +85,14 @@ def play_beep(frequency, duration, volume=1.0): core.wait(duration) # Wait for the beep to finish return sound_time - +def check_keys(max_wait: float=0) -> list[str]: + keys: list[str] + if max_wait > 0: + keys = event.waitKeys(maxWait=max_wait) + else: + keys = event.getKeys() + logger.debug(f"keys={keys}") + return keys if keys else [] ####################################################### # Main script code @@ -148,6 +155,8 @@ def do_main(mode: Mode, logfn: str, display: int, stim_names = [] keys = [] # None received/expected + #kb = keyboard.Keyboard() + clk = clock.Clock() t_start = time() @@ -168,8 +177,10 @@ def do_main(mode: Mode, logfn: str, display: int, if mode == Mode.EVENT: print("Waiting for an event") - keys = event.waitKeys(maxWait=120) # keyList=['5']) + keys = check_keys(120) # keyList=['5']) elif mode == Mode.INTERVAL: + #keys = kb.getKeys(waitRelease=False) + keys = check_keys() target_time = t_start + acqNum * interval to_wait = target_time - time() # sleep some part of it if long enough @@ -220,8 +231,8 @@ def do_main(mode: Mode, logfn: str, display: int, case_sensitive=False), default=Mode.EVENT, help='Mode of operation: event, interval, or beep.') -@click.option('-o', '--output', default="output.log", type=str, - help='Output log file name.') +@click.option('-o', '--output-prefix', default="output_", type=str, + help='Output log file name prefix.') @click.option('-d', '--display', default=1, type=int, help='Display number as an integer (default: 1).') @click.option('-t', '--trials', default=300, type=int, @@ -237,21 +248,29 @@ def do_main(mode: Mode, logfn: str, display: int, help='Set the logging level') @click.pass_context def main(ctx, mode: str, - output: str, display: int, + output_prefix: str, + display: int, trials: int, - duration: float, interval: float, + duration: float, + interval: float, log_level): logger.setLevel(log_level) # psychopy has similar logging levels like # default logging module #pl.console.setLevel(log_level) + started_on: datetime = datetime.now() logger.debug("reprostim-timesync-stimuli script started") - logger.debug(f" Started on : {datetime.now()}") + logger.debug(f" Started on : {started_on}") logger.debug(f" mode : {mode}") - logger.debug(f" output : {output}") + logger.debug(f" prefix : {output_prefix}") logger.debug(f" display : {display}") logger.debug(f" duration : {duration}") logger.debug(f" interval : {interval}") + + ts_format = "%Y.%m.%d-%H.%M.%S.%f" + output: str = f"{output_prefix}{started_on.strftime(ts_format)}.log" + logger.debug(f" output : {output}") + if not do_init(output): return -1 return do_main(mode, output, display, From 703ad93be7ad9b026b69a54468815ccb89922390 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Sun, 24 Nov 2024 15:55:46 +0200 Subject: [PATCH 36/44] Log file renaming at the end, --mute switch, quite keboard events fixes, #115. --- tools/reprostim-timesync-stimuli | 71 +++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 005fc9e..f83bab1 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -40,9 +40,14 @@ import numpy as np ####################################################### # Constants + +# Enum for the mode of the script operation class Mode(str, Enum): + # Listen for keyboard events to show codes EVENT = "event" + # Show codes at regular intervals INTERVAL = "interval" + # Just play a beep BEEP = "beep" @@ -53,13 +58,27 @@ def get_iso_time(t): return datetime.fromtimestamp(t).astimezone().isoformat() +def get_output_file_name(prefix: str, + start_ts: datetime, + end_ts: datetime=None) -> str: + start_str: str = get_ts_str(start_ts) + end_str: str = get_ts_str(end_ts) if end_ts else "" + return f"{prefix}{start_str}--{end_str}.log" + def get_times(): t = time() return t, get_iso_time(t) +def get_ts_str(ts: datetime) -> str: + ts_format = "%Y.%m.%d-%H.%M.%S.%f" + return f"{ts.strftime(ts_format)[:-3]}" + + def log(f, rec): - f.write(json.dumps(rec).rstrip() + os.linesep) + s = json.dumps(rec).rstrip() + f.write(s + os.linesep) + logger.debug(f"LOG {s}") def mkrec(mode, logfn, interval, **kwargs): @@ -116,7 +135,7 @@ def do_init(logfn: str) -> bool: return True -def do_main(mode: Mode, logfn: str, display: int, +def do_main(mode: Mode, logfn: str, display: int, mute: bool, ntrials: int, duration: float, interval: float) -> int: logger.info("main script started") # print(json.dumps(mkrec(mode, logfn, interval, blah=123), indent=4)) @@ -188,15 +207,19 @@ def do_main(mode: Mode, logfn: str, display: int, sleep(to_wait * 0.7) # busy loop without sleep to not miss it while time() < target_time: - pass + sleep(0) # pass CPU to other threads else: raise ValueError(mode) - #freq = 2000 + (100*acqNum) - #beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) - #beep.play() - rec['sound_time'] = get_times() - #rec['sound_freq'] = freq + if not mute: + #freq = 2000 + (100*acqNum) + #beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) + #beep.play() + sound_time, sound_time_str = get_times() + rec['sound_time'] = sound_time + rec['sound_time_str'] = sound_time_str + rec['sound_data'] = acqNum + rec['keys'] = keys tkeys, tkeys_str = get_times() rec["keys_time"] = tkeys @@ -218,12 +241,12 @@ def do_main(mode: Mode, logfn: str, display: int, rec['prior_time_off'] = toff rec['prior_time_off_str'] = toff_str log(f, rec) - if 'q' in keys: + if 'q' in keys or 'escape' in keys: break f.close() logger.info("main script finished") - logger.info("reprostim-timesync-stimuli script finished") + return 0 @click.command(help='PsychoPy reprostim-timesync-stimuli script.') @@ -235,6 +258,8 @@ def do_main(mode: Mode, logfn: str, display: int, help='Output log file name prefix.') @click.option('-d', '--display', default=1, type=int, help='Display number as an integer (default: 1).') +@click.option('-m', '--mute', is_flag=True, default=False, + help="Disable sound codes generation (default is False).") @click.option('-t', '--trials', default=300, type=int, help='Specifies number of trials.') @click.option('-d', '--duration', default=2, type=float, @@ -250,6 +275,7 @@ def do_main(mode: Mode, logfn: str, display: int, def main(ctx, mode: str, output_prefix: str, display: int, + mute: bool, trials: int, duration: float, interval: float, @@ -258,24 +284,39 @@ def main(ctx, mode: str, # psychopy has similar logging levels like # default logging module #pl.console.setLevel(log_level) - started_on: datetime = datetime.now() + start_ts: datetime = datetime.now() logger.debug("reprostim-timesync-stimuli script started") - logger.debug(f" Started on : {started_on}") + logger.debug(f" Started on : {start_ts}") logger.debug(f" mode : {mode}") logger.debug(f" prefix : {output_prefix}") logger.debug(f" display : {display}") + logger.debug(f" mute : {mute}") logger.debug(f" duration : {duration}") logger.debug(f" interval : {interval}") - ts_format = "%Y.%m.%d-%H.%M.%S.%f" - output: str = f"{output_prefix}{started_on.strftime(ts_format)}.log" + output: str = get_output_file_name(output_prefix, start_ts) logger.debug(f" output : {output}") if not do_init(output): + logger.error() return -1 - return do_main(mode, output, display, + + res = do_main(mode, output, display, mute, trials, duration, interval) + end_ts: datetime = datetime.now() + logger.debug(f" Finished on: {end_ts}") + + # rename log file if any + output2: str = get_output_file_name(output_prefix, start_ts, end_ts) + if os.path.exists(output): + os.rename(output, output2) + logger.info(f"Output log renamed: {output} -> {output2}") + + logger.info(f"reprostim-timesync-stimuli script finished: {res}") + return res + + if __name__ == '__main__': code = main() From 1fba79484ab3cb71250548d6bd3ba7548c776752 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Mon, 25 Nov 2024 14:54:11 +0200 Subject: [PATCH 37/44] Created `soundcode` module prototype and integrated with `reprostim-timesync-stimuli` script, #115. --- tools/reprostim-timesync-stimuli | 107 ++++++---- tools/soundcode.py | 354 +++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+), 43 deletions(-) create mode 100644 tools/soundcode.py diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index f83bab1..21345a3 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -5,20 +5,11 @@ t0 = time() import click from enum import Enum import logging -import glob import sys import os import json from datetime import datetime - -from psychopy import prefs -#prefs.hardware['audioLib'] = ['ptb', 'pyo','pygame'] -prefs.hardware['audioLib'] = ['sounddevice'] - -# provide psychopy logs -from psychopy import logging as pl -#pl.console.setLevel(pl.NOTSET) -pl.console.setLevel(pl.DEBUG) +import qrcode # setup logging @@ -30,17 +21,25 @@ logging.getLogger().addHandler(handler) logger.setLevel(logging.DEBUG) logger.info("reprostim-timesync-stimuli script started") -import qrcode -from psychopy import visual, core, event, clock -from psychopy import sound -from psychopy.hardware import keyboard +# setup psychopy logs +from psychopy import logging as pl +#pl.console.setLevel(pl.NOTSET) +pl.console.setLevel(pl.DEBUG) -import numpy as np + +from psychopy import visual, core, event, clock +#from psychopy.hardware import keyboard ####################################################### # Constants +# Enum for the audio libs +class AudioLib(str, Enum): + SOUNDDEVICE = "sounddevice" + PTB = "ptb" + + # Enum for the mode of the script operation class Mode(str, Enum): # Listen for keyboard events to show codes @@ -49,6 +48,8 @@ class Mode(str, Enum): INTERVAL = "interval" # Just play a beep BEEP = "beep" + # List audio/video devices + DEVICES = "devices" ####################################################### @@ -94,16 +95,6 @@ def mkrec(mode, logfn, interval, **kwargs): return kwargs -# Function to play a beep at a specific frequency and duration -def play_beep(frequency, duration, volume=1.0): - logger.debug(f'play_beep({frequency}, duration={duration}, volume={volume})') - # Create a sound object with the specified frequency - beep = sound.Sound(frequency, secs=duration, volume=volume) - beep.play() - sound_time = get_times() - core.wait(duration) # Wait for the beep to finish - return sound_time - def check_keys(max_wait: float=0) -> list[str]: keys: list[str] if max_wait > 0: @@ -113,21 +104,20 @@ def check_keys(max_wait: float=0) -> list[str]: logger.debug(f"keys={keys}") return keys if keys else [] +def safe_remove(file_name: str): + if file_name: + if os.path.isfile(file_name): # Check if it's a file + try: + os.remove(file_name) + logger.debug(f"File {file_name} deleted successfully.") + except Exception as e: + logger.error(f"Failed delete file {file_name}: {e}") + else: + logger.warning(f"File {file_name} does not exist or is not a valid file.") + ####################################################### # Main script code -def do_beep(duration: float): - logger.debug("do_beep()") - - snd = sound.Sound('A', secs=duration, stereo=True) - #snd = sound.Sound('beep_003.wav') - - logger.debug(f"Play sound '{snd.sound}' with psychopy {prefs.hardware['audioLib']}") - snd.play() - core.wait(snd.duration) - logger.debug(f"Sound '{snd.sound}' has finished playing.") - - def do_init(logfn: str) -> bool: if os.path.exists(logfn): logger.error(f"Log file {logfn} already exists") @@ -138,12 +128,27 @@ def do_init(logfn: str) -> bool: def do_main(mode: Mode, logfn: str, display: int, mute: bool, ntrials: int, duration: float, interval: float) -> int: logger.info("main script started") + + # late sound init + from soundcode import (beep, list_audio_devices, + save_soundcode, play_sound) + + #soundcode.logger.setLevel(logging.DEBUG) + # print(json.dumps(mkrec(mode, logfn, interval, blah=123), indent=4)) if mode == Mode.BEEP: - do_beep(duration) + for i in range(ntrials): + beep(interval*0.5, async_=True) + sleep(interval) return 0 + if mode == Mode.DEVICES: + list_audio_devices() + return 0 + + sound_data: int = 0 + sound_file: str = None f = open(logfn, "w") win = visual.Window(fullscr=True, screen=display) @@ -200,6 +205,11 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, elif mode == Mode.INTERVAL: #keys = kb.getKeys(waitRelease=False) keys = check_keys() + # prepare sound code file if any + if not mute: + safe_remove(sound_file) + sound_data = acqNum + sound_file = save_soundcode(code_uint16=sound_data) target_time = t_start + acqNum * interval to_wait = target_time - time() # sleep some part of it if long enough @@ -212,13 +222,11 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, raise ValueError(mode) if not mute: - #freq = 2000 + (100*acqNum) - #beep = sound.Sound(freq, secs=0.5, volume=0.8, sampleRate=44100, stereo=True) - #beep.play() + play_sound(sound_file, async_=True) sound_time, sound_time_str = get_times() rec['sound_time'] = sound_time rec['sound_time_str'] = sound_time_str - rec['sound_data'] = acqNum + rec['sound_data'] = sound_data rec['keys'] = keys tkeys, tkeys_str = get_times() @@ -245,6 +253,8 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, break f.close() + # cleanup temporary sound file if any + safe_remove(sound_file) logger.info("main script finished") return 0 @@ -258,6 +268,11 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, help='Output log file name prefix.') @click.option('-d', '--display', default=1, type=int, help='Display number as an integer (default: 1).') +@click.option('-a', '--audio-lib', + type=click.Choice([a.value for a in AudioLib], + case_sensitive=False), + default=AudioLib.SOUNDDEVICE, + help='Specify audio library to be used.') @click.option('-m', '--mute', is_flag=True, default=False, help="Disable sound codes generation (default is False).") @click.option('-t', '--trials', default=300, type=int, @@ -266,7 +281,7 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, help='Specifies script duration in seconds.') @click.option('-i', '--interval', default=2, type=float, help='Specifies interval value (default: 2.0).') -@click.option('--log-level', default='DEBUG', +@click.option('-l', '--log-level', default='DEBUG', type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']), @@ -275,6 +290,7 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, def main(ctx, mode: str, output_prefix: str, display: int, + audio_lib: str, mute: bool, trials: int, duration: float, @@ -290,6 +306,7 @@ def main(ctx, mode: str, logger.debug(f" mode : {mode}") logger.debug(f" prefix : {output_prefix}") logger.debug(f" display : {display}") + logger.debug(f" audio_lib: {audio_lib}") logger.debug(f" mute : {mute}") logger.debug(f" duration : {duration}") logger.debug(f" interval : {interval}") @@ -297,6 +314,10 @@ def main(ctx, mode: str, output: str = get_output_file_name(output_prefix, start_ts) logger.debug(f" output : {output}") + # setup environment variables + os.environ['REPROSTIM_AUDIO_LIB'] = audio_lib + os.environ['REPROSTIM_LOG_LEVEL'] = log_level + if not do_init(output): logger.error() return -1 diff --git a/tools/soundcode.py b/tools/soundcode.py new file mode 100644 index 0000000..0bf9554 --- /dev/null +++ b/tools/soundcode.py @@ -0,0 +1,354 @@ +# TODO: Move to reprostim.soundcode module + +import logging +import os +import time +from datetime import datetime +import tempfile + +import numpy as np +import sounddevice as sd +from scipy.io.wavfile import write +from scipy.io import wavfile +from reedsolo import RSCodec + + +# setup logging +logger = logging.getLogger(__name__) +logger.setLevel(os.environ.get('REPROSTIM_LOG_LEVEL', 'INFO')) + +# setup audio library +from psychopy import prefs +prefs.hardware['audioLib'] = [os.environ.get('REPROSTIM_AUDIO_LIB', + 'sounddevice')] +#logger.info("Using psychopy audio library: %s", prefs.hardware['audioLib']) +from psychopy import core, sound +from psychtoolbox import audio + + +###################################### +# Sound code/qr helper functions + +def bit_enumerator(data): + if isinstance(data, DataMessage): + data = data.encode() + if isinstance(data, str): + # If data is a string, iterate over each character + for char in data: + if char not in ('0', '1'): + raise ValueError("String must only contain '0' and '1'.") + yield int(char) # Yield the bit as an integer + elif isinstance(data, bytes): + # If data is bytes, iterate over each byte + for byte in data: + for i in range(7, -1, -1): # Iterate from MSB to LSB + yield (byte >> i) & 1 # Extract and yield the bit + else: + raise TypeError("Data must be either a string or bytes. Got: " + str(type(data))) + + +# Convert a list of bits to bytes +def bits_to_bytes(detected_bits): + # Check if the length of detected_bits is a multiple of 8 + if len(detected_bits) % 8 != 0: + raise ValueError(f"Detected bits array must be aligned to 8 bits. Length={len(detected_bits)}") + + byte_array = bytearray() # Use bytearray for mutable bytes + for i in range(0, len(detected_bits), 8): + # Get the next 8 bits + byte_bits = detected_bits[i:i + 8] + + # Convert the list of bits to a byte (big-endian) + byte_value = 0 + for bit in byte_bits: + byte_value = (byte_value << 1) | bit # Shift left and add the bit + + byte_array.append(byte_value) # Append the byte to the bytearray + + return bytes(byte_array) # Convert bytearray to bytes + + +def crc8(data: bytes, polynomial: int = 0x31, init_value: int = 0x00) -> int: + crc = init_value + for byte in data: + crc ^= byte # XOR byte into the CRC + for _ in range(8): # Process each bit + if crc & 0x80: # If the highest bit is set + crc = (crc << 1) ^ polynomial # Shift left and XOR with polynomial + else: + crc <<= 1 # Just shift left + crc &= 0xFF # Keep CRC to 8 bits + return crc + + +def list_audio_devices(): + logger.debug("list_audio_devices()") + + logger.debug("[psychopy]") + logger.debug(f"audioLib : {prefs.hardware['audioLib']}") + logger.debug(f"audioDevice : {prefs.hardware['audioDevice']}") + + logger.debug("[sounddevice]") + devices = sd.query_devices() # Query all devices + for i, device in enumerate(devices): + logger.debug(f"device [{i}] : {device['name']}") + default_device = sd.default.device # Get the current default input/output devices + logger.debug(f"default in : {default_device[0]}") + logger.debug(f"default out : {default_device[1]}") + + logger.debug("[psytoolbox]") + for i, device in enumerate(audio.get_devices()): + logger.debug(f"device [{i}] : {device}") + + logger.debug("[psychopy.backend_ptb]") + # TODO: investigate why only single out device listed from + # USB capture but defult one is not shown + # logger.debug(sound.backend_ptb.getDevices()) + + +def beep(duration: float = 2.0, async_: bool = False): + logger.debug(f"beep(duration={duration})") + play_sound('A', duration, async_) + + +def play_sound(name: str, duration: float = None, async_: bool = False): + logger.debug(f"play_sound(name={name}, duration={duration}, async_={async_})") + snd = None + if duration: + snd = sound.Sound(name, secs=duration, stereo=True) + else: + snd = sound.Sound(name, stereo=True) + logger.debug(f"Play sound '{snd.sound}' with psychopy {prefs.hardware['audioLib']}") + snd.play() + if not async_: + logger.debug("Waiting for sound to finish playing...") + core.wait(snd.duration) + logger.debug(f"Sound '{snd.sound}' has finished playing.") + + +def save_soundcode(fname: str = None, + code_uint16: int = None, + code_uint32: int = None, + code_uint64: int = None, + code_str: str = None, + code_bytes: bytes = None, + engine=None) -> str: + logger.debug(f"save_sound(fname={fname}...)") + if not fname: + fname = tempfile.mktemp( + prefix=f"soundcode_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}_", + suffix=".wav") + + data = DataMessage() + if not code_uint16 is None: + data.set_uint16(code_uint16) + elif not code_uint32 is None: + data.set_uint32(code_uint32) + elif not code_uint64 is None: + data.set_uint64(code_uint64) + elif code_str: + data.set_str(code_str) + elif code_bytes: + data.set_bytes(code_bytes) + else: + raise ValueError("No code data provided.") + + if not engine: + engine = SoundCodeFsk() + engine.save(data, fname) + logger.debug(f" -> {fname}") + return fname + +###################################### +# Classes + +# Class representing audio data message in big-endian +# format and encoded with Reed-Solomon error correction +# where the message is structured as follows: +# - 1st byte is CRC-8 checksum +# - 2nd byte is the length of the data +# - 3+ and the rest is the data itself +class DataMessage: + def __init__(self): + self.value: bytes = b'' + self.length: int = 0 + self.crc8: int = 0 + self.use_ecc: bool = True + self.rsc = RSCodec(4) + + def decode(self, data: bytes): + if self.use_ecc: + dec, dec_full, errata_pos_all = self.rsc.decode(data) + data = bytes(dec) + + #logger.debug(f"decoded data : {data}") + self.crc8 = data[0] + self.length = data[1] + self.value = data[2:] + n = crc8(self.value) + if self.crc8 != n: + raise ValueError(f"CRC-8 checksum mismatch: {self.crc8} <-> {n}") + + def encode(self) -> bytes: + logger.debug("size info") + logger.debug(f" - data : {len(self.value)} bytes, {self.value}") + b: bytes = bytes([self.crc8, self.length]) + self.value + logger.debug(f" - message : {len(b)} bytes, {b}") + if self.use_ecc: + b = bytes(self.rsc.encode(b)) + logger.debug(f" - ecc : {len(b)} bytes, {b}") + return b + + def get_bytes(self) -> bytes: + return self.value + + def get_str(self) -> str: + return self.value.decode("utf-8") + + def get_uint16(self) -> int: + if len(self.value) != 2: + raise ValueError(f"Data length for uint16 must be 2 bytes, " + f"but was {len(self.value)}") + return int.from_bytes(self.value, 'big') + + def get_uint32(self) -> int: + if len(self.value) != 4: + raise ValueError(f"Data length for uint32 must be 4 bytes, " + f"but was {len(self.value)}") + return int.from_bytes(self.value, 'big') + + def get_uint64(self) -> int: + if len(self.value) != 8: + raise ValueError(f"Data length for uint64 must be 8 bytes, " + f"but was {len(self.value)}") + return int.from_bytes(self.value, 'big') + + def set_bytes(self, data: bytes): + self.value = data + self.length = len(data) + self.crc8 = crc8(data) + + def set_str(self, s: str): + self.set_bytes(s.encode("utf-8")) + + def set_uint16(self, i: int): + self.set_bytes(i.to_bytes(2, 'big')) + + def set_uint32(self, i: int): + self.set_bytes(i.to_bytes(4, 'big')) + + def set_uint64(self, i: int): + self.set_bytes(i.to_bytes(8, 'big')) + + +# Class to generate/parse QR-like sound codes with FSK modulation +class SoundCodeFsk: + def __init__(self, + f1=1000, + f0=5000, + sample_rate=44100, + duration=0.0070, + volume=0.75 + ): + self.f1 = f1 + self.f0 = f0 + self.sample_rate = sample_rate + self.duration = duration + if volume < 0.0 or volume > 1.0: + raise ValueError("Volume must be between 0.0 and 1.0.") + self.volume = volume + + def generate(self, data): + logger.debug(f"audio config : f1={self.f1} Hz, f0={self.f0} Hz, rate={self.sample_rate} Hz, bit duration={self.duration} sec, volume={self.volume}") + t = np.linspace(0, self.duration, + int(self.sample_rate * self.duration), + endpoint=False) + + # Create FSK signal + fsk_signal = np.array([]) + + c: int = 0 + sb: str = '' + for bit in bit_enumerator(data): + c += 1 + sb += str(bit) + if bit == 1: + fsk_signal = np.concatenate((fsk_signal, + self.volume * np.sin(2 * np.pi * self.f1 * t))) + else: + fsk_signal = np.concatenate((fsk_signal, + self.volume * np.sin(2 * np.pi * self.f0 * t))) + + # Normalize the signal for 100% volume + if self.volume==1.0: + fsk_signal /= np.max(np.abs(fsk_signal)) + logger.debug(f"audio raw bits: count={c}, {sb}") + logger.debug(f"audio duration: {c * self.duration:.6f} seconds") + return fsk_signal + + def play(self, data): + ts = time.perf_counter() + fsk_signal = self.generate(data) + ts = time.perf_counter() - ts + logger.debug(f"generate time : {ts:.6f} seconds") + + ts = time.perf_counter() + # Play the FSK signal + sd.play(fsk_signal, samplerate=self.sample_rate) + + # Wait until sound is finished playing + sd.wait() + ts = time.perf_counter() - ts + logger.debug(f"play time : {ts:.6f} seconds") + + + def save(self, data, filename): + fsk_signal = self.generate(data) + + # Save the signal to a WAV file + write(filename, self.sample_rate, + (fsk_signal * 32767).astype(np.int16)) + + def parse(self, filename): + # Read the WAV file + rate, data = wavfile.read(filename) + + # Check if audio is stereo and convert to mono if necessary + if len(data.shape) > 1: + data = data.mean(axis=1) + + # Calculate the number of samples for each bit duration + samples_per_bit = int(self.sample_rate * self.duration) + + # Prepare a list to hold the detected bits + detected_bits = [] + + # Analyze the audio in chunks + for i in range(0, len(data), samples_per_bit): + if i + samples_per_bit > len(data): + break # Avoid out of bounds + + # Extract the current chunk of audio + chunk = data[i:i + samples_per_bit] + + # Perform FFT on the chunk + fourier = np.fft.fft(chunk) + frequencies = np.fft.fftfreq(len(fourier), 1 / rate) + + # Get the magnitudes and filter out positive frequencies + magnitudes = np.abs(fourier) + positive_frequencies = frequencies[:len(frequencies) // 2] + positive_magnitudes = magnitudes[:len(magnitudes) // 2] + + # Find the peak frequency + peak_freq = positive_frequencies[np.argmax(positive_magnitudes)] + + # Determine if the peak frequency corresponds to a '1' or '0' + if abs(peak_freq - self.f1) < 50: # Frequency for '1' + detected_bits.append(1) + elif abs(peak_freq - self.f0) < 50: # Frequency for '0' + detected_bits.append(0) + + dbg_bits: str = ''.join([str(bit) for bit in detected_bits]) + logger.debug(f"detected bits : count={len(dbg_bits)}, {dbg_bits}") + return bits_to_bytes(detected_bits) From 9276f28c20d9159fcccdde7109894766c0a8040e Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 26 Nov 2024 17:33:06 +0200 Subject: [PATCH 38/44] Created `soundcode` module prototype and integrated with `reprostim-timesync-stimuli` script, #115. --- tools/reprostim-timesync-stimuli | 12 +- tools/soundcode.py | 228 +++++++++++++++++++------------ 2 files changed, 147 insertions(+), 93 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 21345a3..360c7bb 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -131,7 +131,8 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, # late sound init from soundcode import (beep, list_audio_devices, - save_soundcode, play_sound) + save_soundcode, play_sound, + SoundCodeInfo) #soundcode.logger.setLevel(logging.DEBUG) @@ -149,6 +150,7 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, sound_data: int = 0 sound_file: str = None + sound_info: SoundCodeInfo = None f = open(logfn, "w") win = visual.Window(fullscr=True, screen=display) @@ -209,7 +211,9 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, if not mute: safe_remove(sound_file) sound_data = acqNum - sound_file = save_soundcode(code_uint16=sound_data) + sound_file, sound_info = save_soundcode(code_uint16=sound_data) + logger.debug(f" {sound_info}") + target_time = t_start + acqNum * interval to_wait = target_time - time() # sleep some part of it if long enough @@ -222,11 +226,13 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, raise ValueError(mode) if not mute: - play_sound(sound_file, async_=True) + d = play_sound(sound_file, async_=True) sound_time, sound_time_str = get_times() rec['sound_time'] = sound_time rec['sound_time_str'] = sound_time_str rec['sound_data'] = sound_data + # NOTE: should we add codec info to the log? + # like f0, f1, sampleRate, bit_duration, duration, etc rec['keys'] = keys tkeys, tkeys_str = get_times() diff --git a/tools/soundcode.py b/tools/soundcode.py index 0bf9554..2a4eade 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -5,6 +5,7 @@ import time from datetime import datetime import tempfile +from enum import Enum import numpy as np import sounddevice as sd @@ -80,85 +81,6 @@ def crc8(data: bytes, polynomial: int = 0x31, init_value: int = 0x00) -> int: crc &= 0xFF # Keep CRC to 8 bits return crc - -def list_audio_devices(): - logger.debug("list_audio_devices()") - - logger.debug("[psychopy]") - logger.debug(f"audioLib : {prefs.hardware['audioLib']}") - logger.debug(f"audioDevice : {prefs.hardware['audioDevice']}") - - logger.debug("[sounddevice]") - devices = sd.query_devices() # Query all devices - for i, device in enumerate(devices): - logger.debug(f"device [{i}] : {device['name']}") - default_device = sd.default.device # Get the current default input/output devices - logger.debug(f"default in : {default_device[0]}") - logger.debug(f"default out : {default_device[1]}") - - logger.debug("[psytoolbox]") - for i, device in enumerate(audio.get_devices()): - logger.debug(f"device [{i}] : {device}") - - logger.debug("[psychopy.backend_ptb]") - # TODO: investigate why only single out device listed from - # USB capture but defult one is not shown - # logger.debug(sound.backend_ptb.getDevices()) - - -def beep(duration: float = 2.0, async_: bool = False): - logger.debug(f"beep(duration={duration})") - play_sound('A', duration, async_) - - -def play_sound(name: str, duration: float = None, async_: bool = False): - logger.debug(f"play_sound(name={name}, duration={duration}, async_={async_})") - snd = None - if duration: - snd = sound.Sound(name, secs=duration, stereo=True) - else: - snd = sound.Sound(name, stereo=True) - logger.debug(f"Play sound '{snd.sound}' with psychopy {prefs.hardware['audioLib']}") - snd.play() - if not async_: - logger.debug("Waiting for sound to finish playing...") - core.wait(snd.duration) - logger.debug(f"Sound '{snd.sound}' has finished playing.") - - -def save_soundcode(fname: str = None, - code_uint16: int = None, - code_uint32: int = None, - code_uint64: int = None, - code_str: str = None, - code_bytes: bytes = None, - engine=None) -> str: - logger.debug(f"save_sound(fname={fname}...)") - if not fname: - fname = tempfile.mktemp( - prefix=f"soundcode_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}_", - suffix=".wav") - - data = DataMessage() - if not code_uint16 is None: - data.set_uint16(code_uint16) - elif not code_uint32 is None: - data.set_uint32(code_uint32) - elif not code_uint64 is None: - data.set_uint64(code_uint64) - elif code_str: - data.set_str(code_str) - elif code_bytes: - data.set_bytes(code_bytes) - else: - raise ValueError("No code data provided.") - - if not engine: - engine = SoundCodeFsk() - engine.save(data, fname) - logger.debug(f" -> {fname}") - return fname - ###################################### # Classes @@ -241,27 +163,55 @@ def set_uint64(self, i: int): self.set_bytes(i.to_bytes(8, 'big')) +class SoundCodec(str, Enum): + FSK = "fsk" + + +# Class to provide general information about sound code +class SoundCodeInfo: + def __init__(self): + self.codec = None + self.f1 = None + self.f0 = None + self.sample_rate = None + self.bit_duration = None + self.bit_count = None + self.volume = None + self.duration = None + + # to string + def __str__(self): + return (f"SoundCodeInfo(codec={self.codec}, " + f"f1={self.f1}, " + f"f0={self.f0}, " + f"rate={self.sample_rate}, " + f"bit_duration={self.bit_duration}, " + f"bit_count={self.bit_count}, " + f"volume={self.volume}, " + f"duration={self.duration})") + + # Class to generate/parse QR-like sound codes with FSK modulation class SoundCodeFsk: def __init__(self, f1=1000, f0=5000, sample_rate=44100, - duration=0.0070, + bit_duration=0.0070, volume=0.75 ): self.f1 = f1 self.f0 = f0 self.sample_rate = sample_rate - self.duration = duration + self.bit_duration = bit_duration if volume < 0.0 or volume > 1.0: raise ValueError("Volume must be between 0.0 and 1.0.") self.volume = volume - def generate(self, data): - logger.debug(f"audio config : f1={self.f1} Hz, f0={self.f0} Hz, rate={self.sample_rate} Hz, bit duration={self.duration} sec, volume={self.volume}") - t = np.linspace(0, self.duration, - int(self.sample_rate * self.duration), + def generate(self, data) -> (np.array, SoundCodeInfo): + logger.debug(f"audio config : f1={self.f1} Hz, f0={self.f0} Hz, rate={self.sample_rate} Hz, bit duration={self.bit_duration} sec, volume={self.volume}") + t = np.linspace(0, self.bit_duration, + int(self.sample_rate * self.bit_duration), endpoint=False) # Create FSK signal @@ -282,13 +232,24 @@ def generate(self, data): # Normalize the signal for 100% volume if self.volume==1.0: fsk_signal /= np.max(np.abs(fsk_signal)) + + sci: SoundCodeInfo = SoundCodeInfo() + sci.codec = SoundCodec.FSK + sci.f1 = self.f1 + sci.f0 = self.f0 + sci.sample_rate = self.sample_rate + sci.bit_duration = self.bit_duration + sci.bit_count = c + sci.volume = self.volume + sci.duration = c * self.bit_duration + logger.debug(f"audio raw bits: count={c}, {sb}") - logger.debug(f"audio duration: {c * self.duration:.6f} seconds") - return fsk_signal + logger.debug(f"audio duration: {sci.duration:.6f} seconds") + return (fsk_signal, sci) def play(self, data): ts = time.perf_counter() - fsk_signal = self.generate(data) + fsk_signal, sci = self.generate(data) ts = time.perf_counter() - ts logger.debug(f"generate time : {ts:.6f} seconds") @@ -303,7 +264,7 @@ def play(self, data): def save(self, data, filename): - fsk_signal = self.generate(data) + fsk_signal, sci = self.generate(data) # Save the signal to a WAV file write(filename, self.sample_rate, @@ -318,7 +279,7 @@ def parse(self, filename): data = data.mean(axis=1) # Calculate the number of samples for each bit duration - samples_per_bit = int(self.sample_rate * self.duration) + samples_per_bit = int(self.sample_rate * self.bit_duration) # Prepare a list to hold the detected bits detected_bits = [] @@ -352,3 +313,90 @@ def parse(self, filename): dbg_bits: str = ''.join([str(bit) for bit in detected_bits]) logger.debug(f"detected bits : count={len(dbg_bits)}, {dbg_bits}") return bits_to_bytes(detected_bits) + +###################################### +# Public functions + +def beep(duration: float = 2.0, async_: bool = False): + logger.debug(f"beep(duration={duration})") + play_sound('A', duration, async_) + + +def list_audio_devices(): + logger.debug("list_audio_devices()") + + logger.debug("[psychopy]") + logger.debug(f"audioLib : {prefs.hardware['audioLib']}") + logger.debug(f"audioDevice : {prefs.hardware['audioDevice']}") + + logger.debug("[sounddevice]") + devices = sd.query_devices() # Query all devices + for i, device in enumerate(devices): + logger.debug(f"device [{i}] : {device['name']}") + default_device = sd.default.device # Get the current default input/output devices + logger.debug(f"default in : {default_device[0]}") + logger.debug(f"default out : {default_device[1]}") + + logger.debug("[psytoolbox]") + for i, device in enumerate(audio.get_devices()): + logger.debug(f"device [{i}] : {device}") + + logger.debug("[psychopy.backend_ptb]") + # TODO: investigate why only single out device listed from + # USB capture but defult one is not shown + # logger.debug(sound.backend_ptb.getDevices()) + + + +def play_sound(name: str, + duration: float = None, + volume: float = 0.8, + async_: bool = False): + logger.debug(f"play_sound(name={name}, duration={duration}, async_={async_})") + snd = None + if duration: + snd = sound.Sound(name, secs=duration, + stereo=True, volume=volume) + else: + snd = sound.Sound(name, stereo=True, volume=volume) + logger.debug(f"Play sound '{snd.sound}' with psychopy {prefs.hardware['audioLib']}") + snd.play() + logger.debug(f" sampleRate={snd.sampleRate}, duration={snd.duration}, volume={snd.volume}") + if not async_: + logger.debug("Waiting for sound to finish playing...") + core.wait(snd.duration) + logger.debug(f"Sound '{snd.sound}' has finished playing.") + + +def save_soundcode(fname: str = None, + code_uint16: int = None, + code_uint32: int = None, + code_uint64: int = None, + code_str: str = None, + code_bytes: bytes = None, + engine=None) -> (str, SoundCodeInfo): + logger.debug(f"save_sound(fname={fname}...)") + if not fname: + fname = tempfile.mktemp( + prefix=f"soundcode_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}_", + suffix=".wav") + + data = DataMessage() + if not code_uint16 is None: + data.set_uint16(code_uint16) + elif not code_uint32 is None: + data.set_uint32(code_uint32) + elif not code_uint64 is None: + data.set_uint64(code_uint64) + elif code_str: + data.set_str(code_str) + elif code_bytes: + data.set_bytes(code_bytes) + else: + raise ValueError("No code data provided.") + + if not engine: + engine = SoundCodeFsk() + sci: SoundCodeInfo = engine.save(data, fname) + logger.debug(f" -> {fname}") + return (fname, sci) From 6fd89a303bc344f8f019760ba2fd099ea5df4e0b Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 26 Nov 2024 18:06:10 +0200 Subject: [PATCH 39/44] Added pre and post sound in generate soundcode API, #115. --- tools/soundcode.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tools/soundcode.py b/tools/soundcode.py index 2a4eade..3d93e16 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -198,7 +198,11 @@ def __init__(self, f0=5000, sample_rate=44100, bit_duration=0.0070, - volume=0.75 + volume=0.95, + pre_delay=0.1, + pre_f=1780, + post_delay=0.1, + post_f=0 #3571 ): self.f1 = f1 self.f0 = f0 @@ -207,6 +211,11 @@ def __init__(self, if volume < 0.0 or volume > 1.0: raise ValueError("Volume must be between 0.0 and 1.0.") self.volume = volume + self.pre_delay = pre_delay + self.pre_f = pre_f + self.post_delay = post_delay + self.post_f = post_f + def generate(self, data) -> (np.array, SoundCodeInfo): logger.debug(f"audio config : f1={self.f1} Hz, f0={self.f0} Hz, rate={self.sample_rate} Hz, bit duration={self.bit_duration} sec, volume={self.volume}") @@ -216,7 +225,24 @@ def generate(self, data) -> (np.array, SoundCodeInfo): # Create FSK signal fsk_signal = np.array([]) - + pre_signal = np.array([]) + post_signal = np.array([]) + + # generate pre-signal if any + if self.pre_delay > 0: + t_pre = np.linspace(0, self.pre_delay, + int(self.sample_rate * self.pre_delay), + endpoint=False) + pre_signal = self.volume * np.sin(2 * np.pi * self.pre_f * t_pre) + + # generate post-signal if any + if self.post_delay > 0: + t_post = np.linspace(0, self.post_delay, + int(self.sample_rate * self.post_delay), + endpoint=False) + post_signal = self.volume * np.sin(2 * np.pi * self.post_f * t_post) + + # generate data signal properly c: int = 0 sb: str = '' for bit in bit_enumerator(data): @@ -229,6 +255,10 @@ def generate(self, data) -> (np.array, SoundCodeInfo): fsk_signal = np.concatenate((fsk_signal, self.volume * np.sin(2 * np.pi * self.f0 * t))) + # concatenate pre-signal, data signal, and post-signal + if self.pre_delay > 0 or self.post_delay > 0: + fsk_signal = np.concatenate((pre_signal, fsk_signal, post_signal)) + # Normalize the signal for 100% volume if self.volume==1.0: fsk_signal /= np.max(np.abs(fsk_signal)) @@ -241,7 +271,8 @@ def generate(self, data) -> (np.array, SoundCodeInfo): sci.bit_duration = self.bit_duration sci.bit_count = c sci.volume = self.volume - sci.duration = c * self.bit_duration + sci.duration = (c * self.bit_duration + + self.pre_delay + self.post_delay) logger.debug(f"audio raw bits: count={c}, {sb}") logger.debug(f"audio duration: {sci.duration:.6f} seconds") From 6d537590e66499a292e92ddad17c46dfd63f8491 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 26 Nov 2024 18:27:41 +0200 Subject: [PATCH 40/44] Added --keep-soundcode script option to persist generated audio as wav file for debug purposes, #115. --- tools/reprostim-timesync-stimuli | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 360c7bb..c2e8413 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -7,6 +7,7 @@ from enum import Enum import logging import sys import os +import shutil import json from datetime import datetime import qrcode @@ -115,6 +116,13 @@ def safe_remove(file_name: str): else: logger.warning(f"File {file_name} does not exist or is not a valid file.") +def store_soundcode(sound_file: str, sound_data: int, logfn: str): + # if sound_file exits, copy it to {logfn}soundcode_{sound_data}.wav + if os.path.isfile(sound_file): + sfile = f"{os.path.splitext(logfn)[0]}soundcode_{sound_data}.wav" + shutil.copy(sound_file, sfile) + + ####################################################### # Main script code @@ -126,7 +134,8 @@ def do_init(logfn: str) -> bool: def do_main(mode: Mode, logfn: str, display: int, mute: bool, - ntrials: int, duration: float, interval: float) -> int: + ntrials: int, duration: float, interval: float, + keep_soundcode: bool) -> int: logger.info("main script started") # late sound init @@ -209,6 +218,8 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, keys = check_keys() # prepare sound code file if any if not mute: + if keep_soundcode and sound_file: + store_soundcode(sound_file, sound_data, logfn) safe_remove(sound_file) sound_data = acqNum sound_file, sound_info = save_soundcode(code_uint16=sound_data) @@ -259,6 +270,9 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, break f.close() + + if keep_soundcode and sound_file: + store_soundcode(sound_file, sound_data, logfn) # cleanup temporary sound file if any safe_remove(sound_file) logger.info("main script finished") @@ -287,6 +301,9 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, help='Specifies script duration in seconds.') @click.option('-i', '--interval', default=2, type=float, help='Specifies interval value (default: 2.0).') +@click.option('-k', '--keep-soundcode', is_flag=True, default=False, + help="Store soundcode as separate .wav files " + "for debug purposes (default is False).") @click.option('-l', '--log-level', default='DEBUG', type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR', @@ -301,6 +318,7 @@ def main(ctx, mode: str, trials: int, duration: float, interval: float, + keep_soundcode: bool, log_level): logger.setLevel(log_level) # psychopy has similar logging levels like @@ -329,7 +347,8 @@ def main(ctx, mode: str, return -1 res = do_main(mode, output, display, mute, - trials, duration, interval) + trials, duration, interval, + keep_soundcode) end_ts: datetime = datetime.now() logger.debug(f" Finished on: {end_ts}") From 99fdcaeb843a11a7b7dc05c6dc01f82bc2157e1a Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Wed, 27 Nov 2024 12:53:22 +0200 Subject: [PATCH 41/44] Update soundcode default config, #115. --- tools/soundcode.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/soundcode.py b/tools/soundcode.py index 3d93e16..015ea82 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -198,9 +198,11 @@ def __init__(self, f0=5000, sample_rate=44100, bit_duration=0.0070, + #bit_duration=0.014, volume=0.95, pre_delay=0.1, - pre_f=1780, + #pre_f=1780, + pre_f=0, post_delay=0.1, post_f=0 #3571 ): From edf02e2683d6f951140c0571193de1afa3c622d6 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 3 Dec 2024 15:58:55 +0200 Subject: [PATCH 42/44] Added --sound-codec argument and possibility to generate sound code with FSK or NFE codecs in runtime, #115. --- tools/reprostim-timesync-stimuli | 45 ++++++++--- tools/soundcode.py | 135 ++++++++++++++++++++++++++----- 2 files changed, 153 insertions(+), 27 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index c2e8413..d339552 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -37,8 +37,13 @@ from psychopy import visual, core, event, clock # Enum for the audio libs class AudioLib(str, Enum): + # PsychoPy SoundDevice audio lib + PSYCHOPY_SOUNDDEVICE = "psychopy_sounddevice" + # PsychoPy SoundPTB audio lib + PSYCHOPY_PTB = "psychopy_ptb" + # sounddevice audio lib + # http://python-sounddevice.readthedocs.io/ SOUNDDEVICE = "sounddevice" - PTB = "ptb" # Enum for the mode of the script operation @@ -133,7 +138,8 @@ def do_init(logfn: str) -> bool: return True -def do_main(mode: Mode, logfn: str, display: int, mute: bool, +def do_main(mode: Mode, logfn: str, display: int, + sound_codec: str, mute: bool, ntrials: int, duration: float, interval: float, keep_soundcode: bool) -> int: logger.info("main script started") @@ -141,7 +147,7 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, # late sound init from soundcode import (beep, list_audio_devices, save_soundcode, play_sound, - SoundCodeInfo) + SoundCodec, SoundCodeInfo) #soundcode.logger.setLevel(logging.DEBUG) @@ -222,7 +228,10 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, store_soundcode(sound_file, sound_data, logfn) safe_remove(sound_file) sound_data = acqNum - sound_file, sound_info = save_soundcode(code_uint16=sound_data) + sound_file, sound_info_ = save_soundcode( + code_uint16=sound_data, + codec=sound_codec) + sound_info = sound_info_ logger.debug(f" {sound_info}") target_time = t_start + acqNum * interval @@ -239,9 +248,19 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, if not mute: d = play_sound(sound_file, async_=True) sound_time, sound_time_str = get_times() - rec['sound_time'] = sound_time - rec['sound_time_str'] = sound_time_str - rec['sound_data'] = sound_data + rec['s_time'] = sound_time + rec['s_time_str'] = sound_time_str + rec['s_data'] = sound_data + rec['s_codec'] = sound_codec + rec['s_f0'] = sound_info.f0 + rec['s_f1'] = sound_info.f1 + rec['s_pre_delay'] = sound_info.pre_delay + rec['s_post_delay'] = sound_info.post_delay + rec['s_duration'] = sound_info.duration + if sound_codec == SoundCodec.NFE: + rec['s_freq'] = sound_info.nfe_freq + rec['s_df'] = sound_info.nfe_df + # NOTE: should we add codec info to the log? # like f0, f1, sampleRate, bit_duration, duration, etc @@ -291,8 +310,14 @@ def do_main(mode: Mode, logfn: str, display: int, mute: bool, @click.option('-a', '--audio-lib', type=click.Choice([a.value for a in AudioLib], case_sensitive=False), - default=AudioLib.SOUNDDEVICE, + default=AudioLib.PSYCHOPY_SOUNDDEVICE, help='Specify audio library to be used.') +@click.option('-c', '--sound-codec', + type=click.Choice(['fsk', 'nfe'], + case_sensitive=False), + default='fsk', + help='Specify sound codec to produce audio code ' + '(default: fsk).') @click.option('-m', '--mute', is_flag=True, default=False, help="Disable sound codes generation (default is False).") @click.option('-t', '--trials', default=300, type=int, @@ -314,6 +339,7 @@ def main(ctx, mode: str, output_prefix: str, display: int, audio_lib: str, + sound_codec: str, mute: bool, trials: int, duration: float, @@ -346,7 +372,8 @@ def main(ctx, mode: str, logger.error() return -1 - res = do_main(mode, output, display, mute, + res = do_main(mode, output, display, + sound_codec, mute, trials, duration, interval, keep_soundcode) diff --git a/tools/soundcode.py b/tools/soundcode.py index 015ea82..d596548 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -127,6 +127,18 @@ def get_bytes(self) -> bytes: def get_str(self) -> str: return self.value.decode("utf-8") + def get_uint(self) -> int: + c = len(self.value) + if c == 2: + return self.get_uint16() + elif c == 4: + return self.get_uint32() + elif c == 8: + return self.get_uint64() + else: + raise ValueError(f"Data length must be 2, 4, or 8 bytes, " + f"but was {c}") + def get_uint16(self) -> int: if len(self.value) != 2: raise ValueError(f"Data length for uint16 must be 2 bytes, " @@ -164,8 +176,16 @@ def set_uint64(self, i: int): class SoundCodec(str, Enum): + # Frequency Shift Keying (FSK) where binary data is + # encoded as two different frequencies f0 and f1 with + # a fixed bit duration (baud rate or bit_rate). FSK = "fsk" + # Numerical Frequency Encoding (NFE) numbers are mapped + # directly to specific frequencies + # can encode only some numeric hash. + NFE = "nfe" + # Class to provide general information about sound code class SoundCodeInfo: @@ -173,43 +193,58 @@ def __init__(self): self.codec = None self.f1 = None self.f0 = None + self.nfe_df = None self.sample_rate = None self.bit_duration = None + self.nfe_duration = None + self.nfe_freq = None self.bit_count = None self.volume = None self.duration = None + self.pre_delay = None + self.post_delay = None # to string def __str__(self): return (f"SoundCodeInfo(codec={self.codec}, " f"f1={self.f1}, " f"f0={self.f0}, " + f"nfe_df={self.nfe_df}, " f"rate={self.sample_rate}, " f"bit_duration={self.bit_duration}, " f"bit_count={self.bit_count}, " + f"nfe_freq={self.nfe_freq}, " f"volume={self.volume}, " - f"duration={self.duration})") + f"duration={self.duration})" + f"pre_delay={self.pre_delay}, " + f"post_delay={self.post_delay}") # Class to generate/parse QR-like sound codes with FSK modulation -class SoundCodeFsk: +class SoundCodeEngine: def __init__(self, - f1=1000, - f0=5000, + codec=SoundCodec.FSK, + f0=1000, + f1=5000, + nfe_df=100, # used only in NFE sample_rate=44100, - bit_duration=0.0070, + bit_duration=0.0070, # used only in FSK #bit_duration=0.014, + nfe_duration=0.3, # used only in NFE volume=0.95, pre_delay=0.1, #pre_f=1780, pre_f=0, post_delay=0.1, - post_f=0 #3571 + post_f=0 #3571 ): - self.f1 = f1 + self.codec = codec self.f0 = f0 + self.f1 = f1 + self.nfe_df = nfe_df self.sample_rate = sample_rate self.bit_duration = bit_duration + self.nfe_duration = nfe_duration if volume < 0.0 or volume > 1.0: raise ValueError("Volume must be between 0.0 and 1.0.") self.volume = volume @@ -218,8 +253,15 @@ def __init__(self, self.post_delay = post_delay self.post_f = post_f + def generate_sin(self, freq_hz, duration_sec): + t = np.linspace(0, duration_sec, + int(self.sample_rate * duration_sec), + endpoint=False) + signal = self.volume * np.sin(2 * np.pi * freq_hz * t) + return signal - def generate(self, data) -> (np.array, SoundCodeInfo): + + def generate_fsk(self, data) -> (np.array, SoundCodeInfo): logger.debug(f"audio config : f1={self.f1} Hz, f0={self.f0} Hz, rate={self.sample_rate} Hz, bit duration={self.bit_duration} sec, volume={self.volume}") t = np.linspace(0, self.bit_duration, int(self.sample_rate * self.bit_duration), @@ -232,17 +274,11 @@ def generate(self, data) -> (np.array, SoundCodeInfo): # generate pre-signal if any if self.pre_delay > 0: - t_pre = np.linspace(0, self.pre_delay, - int(self.sample_rate * self.pre_delay), - endpoint=False) - pre_signal = self.volume * np.sin(2 * np.pi * self.pre_f * t_pre) + pre_signal = self.generate_sin(self.pre_f, self.pre_delay) # generate post-signal if any if self.post_delay > 0: - t_post = np.linspace(0, self.post_delay, - int(self.sample_rate * self.post_delay), - endpoint=False) - post_signal = self.volume * np.sin(2 * np.pi * self.post_f * t_post) + post_signal = self.generate_sin(self.post_f, self.post_delay) # generate data signal properly c: int = 0 @@ -275,12 +311,72 @@ def generate(self, data) -> (np.array, SoundCodeInfo): sci.volume = self.volume sci.duration = (c * self.bit_duration + self.pre_delay + self.post_delay) + sci.pre_delay = self.pre_delay + sci.post_delay = self.post_delay logger.debug(f"audio raw bits: count={c}, {sb}") logger.debug(f"audio duration: {sci.duration:.6f} seconds") return (fsk_signal, sci) - def play(self, data): + + def generate_nfe(self, data) -> (np.array, SoundCodeInfo): + # Create NFE signal + nfe_signal = np.array([]) + pre_signal = np.array([]) + post_signal = np.array([]) + + # generate pre-signal if any + if self.pre_delay > 0: + pre_signal = self.generate_sin(self.pre_f, self.pre_delay) + + # generate post-signal if any + if self.post_delay > 0: + post_signal = self.generate_sin(self.post_f, self.post_delay) + + n = data.get_uint() + c = int((self.f1 - self.f0) / self.nfe_df) + 1 + freq = self.f0 + (n % c) * self.nfe_df + logger.debug(f" n={n}, c={c}, freq={freq}") + nfe_signal = self.generate_sin(freq, self.nfe_duration) + + # concatenate pre-signal, data signal, and post-signal + if self.pre_delay > 0 or self.post_delay > 0: + nfe_signal = np.concatenate((pre_signal, nfe_signal, post_signal)) + + # Normalize the signal for 100% volume + if self.volume==1.0: + nfe_signal /= np.max(np.abs(nfe_signal)) + + sci: SoundCodeInfo = SoundCodeInfo() + sci.codec = SoundCodec.NFE + sci.f1 = self.f1 + sci.f0 = self.f0 + sci.nfe_df = self.nfe_df + sci.sample_rate = self.sample_rate + sci.nfe_duration = self.nfe_duration + sci.nfe_freq = freq + sci.volume = self.volume + sci.duration = (self.nfe_duration + + self.pre_delay + self.post_delay) + sci.pre_delay = self.pre_delay + sci.post_delay = self.post_delay + + + logger.debug(f"audio duration: {sci.duration:.6f} seconds") + return nfe_signal, sci + + + def generate(self, data) -> (np.array, SoundCodeInfo): + if self.codec == SoundCodec.FSK: + return self.generate_fsk(data) + elif self.codec == SoundCodec.NFE: + return self.generate_nfe(data) + else: + raise ValueError(f"Unsupported codec: {self.codec}") + + + # play sound data with sounddevice + def play_data_sd(self, data): ts = time.perf_counter() fsk_signal, sci = self.generate(data) ts = time.perf_counter() - ts @@ -302,6 +398,8 @@ def save(self, data, filename): # Save the signal to a WAV file write(filename, self.sample_rate, (fsk_signal * 32767).astype(np.int16)) + return sci + def parse(self, filename): # Read the WAV file @@ -407,6 +505,7 @@ def save_soundcode(fname: str = None, code_uint64: int = None, code_str: str = None, code_bytes: bytes = None, + codec: SoundCodec = SoundCodec.FSK, engine=None) -> (str, SoundCodeInfo): logger.debug(f"save_sound(fname={fname}...)") if not fname: @@ -429,7 +528,7 @@ def save_soundcode(fname: str = None, raise ValueError("No code data provided.") if not engine: - engine = SoundCodeFsk() + engine = SoundCodeEngine(codec=codec) sci: SoundCodeInfo = engine.save(data, fname) logger.debug(f" -> {fname}") return (fname, sci) From b5485389bebba13ed63103d24be5d33bfb001919 Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 3 Dec 2024 17:27:33 +0200 Subject: [PATCH 43/44] Scale QR code with --qr-scale factor, #115. --- tools/reprostim-timesync-stimuli | 26 ++++++++++++++++---------- tools/soundcode.py | 10 ++++------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index d339552..1fbe617 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -138,7 +138,8 @@ def do_init(logfn: str) -> bool: return True -def do_main(mode: Mode, logfn: str, display: int, +def do_main(mode: Mode, logfn: str, + display: int, qr_scale: float, sound_codec: str, mute: bool, ntrials: int, duration: float, interval: float, keep_soundcode: bool) -> int: @@ -272,7 +273,7 @@ def do_main(mode: Mode, logfn: str, display: int, qrcode.make(json.dumps(rec)), pos=(0, 0) ) - qr.size = qr.size *1 + qr.size = qr.size * qr_scale qr.draw() win.flip() tflip, tflip_str = get_times() @@ -307,17 +308,20 @@ def do_main(mode: Mode, logfn: str, display: int, help='Output log file name prefix.') @click.option('-d', '--display', default=1, type=int, help='Display number as an integer (default: 1).') +@click.option('-s', '--qr-scale', default=0.8, type=float, + help='Specify QR code scale factor in range 0..1. ' + 'Use 1.0 to fit full height (default: 0.8).') @click.option('-a', '--audio-lib', type=click.Choice([a.value for a in AudioLib], case_sensitive=False), default=AudioLib.PSYCHOPY_SOUNDDEVICE, help='Specify audio library to be used.') @click.option('-c', '--sound-codec', - type=click.Choice(['fsk', 'nfe'], - case_sensitive=False), - default='fsk', + type=click.Choice(['FSK', 'NFE'], + case_sensitive=True), + default='FSK', help='Specify sound codec to produce audio code ' - '(default: fsk).') + '(default: FSK).') @click.option('-m', '--mute', is_flag=True, default=False, help="Disable sound codes generation (default is False).") @click.option('-t', '--trials', default=300, type=int, @@ -338,6 +342,7 @@ def do_main(mode: Mode, logfn: str, display: int, def main(ctx, mode: str, output_prefix: str, display: int, + qr_scale: float, audio_lib: str, sound_codec: str, mute: bool, @@ -372,10 +377,11 @@ def main(ctx, mode: str, logger.error() return -1 - res = do_main(mode, output, display, - sound_codec, mute, - trials, duration, interval, - keep_soundcode) + res = do_main(mode, output, + display, qr_scale, + sound_codec, mute, + trials, duration, interval, + keep_soundcode) end_ts: datetime = datetime.now() logger.debug(f" Finished on: {end_ts}") diff --git a/tools/soundcode.py b/tools/soundcode.py index d596548..c44a2bd 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -179,12 +179,12 @@ class SoundCodec(str, Enum): # Frequency Shift Keying (FSK) where binary data is # encoded as two different frequencies f0 and f1 with # a fixed bit duration (baud rate or bit_rate). - FSK = "fsk" + FSK = "FSK" # Numerical Frequency Encoding (NFE) numbers are mapped # directly to specific frequencies # can encode only some numeric hash. - NFE = "nfe" + NFE = "NFE" # Class to provide general information about sound code @@ -229,12 +229,10 @@ def __init__(self, nfe_df=100, # used only in NFE sample_rate=44100, bit_duration=0.0070, # used only in FSK - #bit_duration=0.014, nfe_duration=0.3, # used only in NFE - volume=0.95, + volume=0.80, pre_delay=0.1, - #pre_f=1780, - pre_f=0, + pre_f=0, #1780 post_delay=0.1, post_f=0 #3571 ): From 88d216ae83de52b7484d1e5230974aab16b94aae Mon Sep 17 00:00:00 2001 From: Vadim Melnik Date: Tue, 3 Dec 2024 18:15:28 +0200 Subject: [PATCH 44/44] Normalized --audio-lib option, and added support for python sounddevice lib, #115. --- tools/reprostim-timesync-stimuli | 21 +++---- tools/soundcode.py | 97 +++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/tools/reprostim-timesync-stimuli b/tools/reprostim-timesync-stimuli index 1fbe617..2a7fcc0 100755 --- a/tools/reprostim-timesync-stimuli +++ b/tools/reprostim-timesync-stimuli @@ -35,16 +35,6 @@ from psychopy import visual, core, event, clock ####################################################### # Constants -# Enum for the audio libs -class AudioLib(str, Enum): - # PsychoPy SoundDevice audio lib - PSYCHOPY_SOUNDDEVICE = "psychopy_sounddevice" - # PsychoPy SoundPTB audio lib - PSYCHOPY_PTB = "psychopy_ptb" - # sounddevice audio lib - # http://python-sounddevice.readthedocs.io/ - SOUNDDEVICE = "sounddevice" - # Enum for the mode of the script operation class Mode(str, Enum): @@ -312,10 +302,13 @@ def do_main(mode: Mode, logfn: str, help='Specify QR code scale factor in range 0..1. ' 'Use 1.0 to fit full height (default: 0.8).') @click.option('-a', '--audio-lib', - type=click.Choice([a.value for a in AudioLib], - case_sensitive=False), - default=AudioLib.PSYCHOPY_SOUNDDEVICE, - help='Specify audio library to be used.') + type=click.Choice(['psychopy_sounddevice', + 'psychopy_ptb', + 'sounddevice'], + case_sensitive=True), + default='psychopy_sounddevice', + help='Specify audio library to be used ' + '(default: psychopy_sounddevice).') @click.option('-c', '--sound-codec', type=click.Choice(['FSK', 'NFE'], case_sensitive=True), diff --git a/tools/soundcode.py b/tools/soundcode.py index c44a2bd..e601639 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -9,7 +9,7 @@ import numpy as np import sounddevice as sd -from scipy.io.wavfile import write +from scipy.io.wavfile import write, read from scipy.io import wavfile from reedsolo import RSCodec @@ -18,10 +18,30 @@ logger = logging.getLogger(__name__) logger.setLevel(os.environ.get('REPROSTIM_LOG_LEVEL', 'INFO')) -# setup audio library +###################################### +# Setup psychopy audio library + +# Enum for the audio libs +class AudioLib(str, Enum): + # PsychoPy SoundDevice audio lib + PSYCHOPY_SOUNDDEVICE = "psychopy_sounddevice" + # PsychoPy SoundPTB audio lib + PSYCHOPY_PTB = "psychopy_ptb" + # sounddevice audio lib + # http://python-sounddevice.readthedocs.io/ + SOUNDDEVICE = "sounddevice" + +_audio_lib = os.environ.get('REPROSTIM_AUDIO_LIB', AudioLib.PSYCHOPY_SOUNDDEVICE) + from psychopy import prefs -prefs.hardware['audioLib'] = [os.environ.get('REPROSTIM_AUDIO_LIB', - 'sounddevice')] +prefs.hardware['audioLib'] = ['sounddevice'] +if _audio_lib == AudioLib.PSYCHOPY_SOUNDDEVICE: + logger.debug("Set psychopy audio library: sounddevice") + prefs.hardware['audioLib'] = ['sounddevice'] +elif _audio_lib == AudioLib.PSYCHOPY_PTB: + logger.debug("Set psychopy audio library: ptb") + prefs.hardware['audioLib'] = ['ptb'] + #logger.info("Using psychopy audio library: %s", prefs.hardware['audioLib']) from psychopy import core, sound from psychtoolbox import audio @@ -81,6 +101,20 @@ def crc8(data: bytes, polynomial: int = 0x31, init_value: int = 0x00) -> int: crc &= 0xFF # Keep CRC to 8 bits return crc +###################################### +# Constants + +class SoundCodec(str, Enum): + # Frequency Shift Keying (FSK) where binary data is + # encoded as two different frequencies f0 and f1 with + # a fixed bit duration (baud rate or bit_rate). + FSK = "FSK" + + # Numerical Frequency Encoding (NFE) numbers are mapped + # directly to specific frequencies + # can encode only some numeric hash. + NFE = "NFE" + ###################################### # Classes @@ -175,18 +209,6 @@ def set_uint64(self, i: int): self.set_bytes(i.to_bytes(8, 'big')) -class SoundCodec(str, Enum): - # Frequency Shift Keying (FSK) where binary data is - # encoded as two different frequencies f0 and f1 with - # a fixed bit duration (baud rate or bit_rate). - FSK = "FSK" - - # Numerical Frequency Encoding (NFE) numbers are mapped - # directly to specific frequencies - # can encode only some numeric hash. - NFE = "NFE" - - # Class to provide general information about sound code class SoundCodeInfo: def __init__(self): @@ -477,17 +499,19 @@ def list_audio_devices(): -def play_sound(name: str, +def _play_sound_psychopy(name: str, duration: float = None, volume: float = 0.8, + sample_rate: int = 44100, async_: bool = False): - logger.debug(f"play_sound(name={name}, duration={duration}, async_={async_})") + logger.debug(f"_play_sound_psychopy(name={name}, duration={duration}, async_={async_})") snd = None if duration: - snd = sound.Sound(name, secs=duration, + snd = sound.Sound(name, secs=duration, sampleRate=sample_rate, stereo=True, volume=volume) else: - snd = sound.Sound(name, stereo=True, volume=volume) + snd = sound.Sound(name, stereo=True, sampleRate=sample_rate, + volume=volume) logger.debug(f"Play sound '{snd.sound}' with psychopy {prefs.hardware['audioLib']}") snd.play() logger.debug(f" sampleRate={snd.sampleRate}, duration={snd.duration}, volume={snd.volume}") @@ -497,6 +521,39 @@ def play_sound(name: str, logger.debug(f"Sound '{snd.sound}' has finished playing.") +def _play_sound_sd(name: str, + duration: float = None, + volume: float = 0.8, + sample_rate: int = 44100, + async_: bool = False): + logger.debug(f"_play_sound_sd(name={name}, duration={duration}, async_={async_})") + data = name + + if os.path.exists(name): + rate, signal = read(name) + logger.debug(f"Read sound file: {name}, rate={rate}") + # Convert from int16 to float32 + #signal = signal.astype(np.float32) / 32767 + data = signal + + sd.play(data, samplerate=sample_rate) + + +def play_sound(name: str, + duration: float = None, + volume: float = 0.8, + sample_rate: int = 44100, + async_: bool = False): + logger.debug(f"play_sound(name={name}, duration={duration}, async_={async_})") + if (_audio_lib == AudioLib.PSYCHOPY_SOUNDDEVICE or + _audio_lib == AudioLib.PSYCHOPY_PTB): + _play_sound_psychopy(name, duration, volume, sample_rate, async_) + elif _audio_lib == AudioLib.SOUNDDEVICE: + _play_sound_sd(name, duration, volume, sample_rate, async_) + else: + raise ValueError(f"Unsupported audio library: {_audio_lib}") + + def save_soundcode(fname: str = None, code_uint16: int = None, code_uint32: int = None,