From 2c85f7ed46406a7e0bc5e1fc0f82830e4e9815ef Mon Sep 17 00:00:00 2001 From: jpunkt Date: Thu, 13 Jan 2022 19:18:40 +0100 Subject: [PATCH] Implemented all commands: - Play sound with pygame (sounddevice is too flaky) - Play sound during user interaction (interruptable) - Blink LEDs during user interaction - Flash LEDs during POST --- pizzactrl/hal_serial.py | 151 +++++++++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 58 deletions(-) diff --git a/pizzactrl/hal_serial.py b/pizzactrl/hal_serial.py index 672b626..440c2c7 100644 --- a/pizzactrl/hal_serial.py +++ b/pizzactrl/hal_serial.py @@ -1,5 +1,6 @@ import logging import functools +import threading from time import sleep from enum import Enum @@ -9,6 +10,8 @@ from scipy.io.wavfile import write as writewav import sounddevice as sd import soundfile as sf +import pygame.mixer as mx + from . import gpio_pins from picamera import PiCamera @@ -42,11 +45,6 @@ class SerialCommands(Enum): FRONTLIGHT = b'F' USER_INTERACT = b'U' - - RESP_BLUE = b'X' - RESP_RED = b'O' - RESP_YELLOW = b'Y' - RESP_GREEN = b'N' RECORD = b'C' REWIND = b'R' @@ -103,6 +101,38 @@ class PizzaHAL: else: raise SerialCommunicationError(f'Serial Connection received invalid response to HELLO: {resp}') self.connected = True + + def init_sounds(self, sounds: List=None): + """ + Load prerecorded Sounds into memory + + :param hal: + :param sounds: A list of sound files + """ + if self.soundcache is None: + self.soundcache = {} + + if not mx.get_init(): + mx.init() + + if sounds is not None: + for sound in sounds: + # Extract data and sampling rate from file + # data, fs = sf.read(str(sound), dtype='float32') + # self.soundcache[str(sound)] = (data, fs) + self.soundcache[str(sound)] = mx.Sound(sound) + + def init_camera(self): + if self.camera is None: + self.camera = PiCamera(sensor_mode=5) + + def play_sound(self, sound: str): + s = self.soundcache.get(sound, mx.Sound(sound)) + s.play() + + def stop_sound(self): + if mx.get_busy(): + mx.stop() def send_cmd(self, command: SerialCommands, *options): """ @@ -115,7 +145,15 @@ class PizzaHAL: self.serialcon.write(o) self.serialcon.write(SerialCommands.EOT.value) resp = self.serialcon.read_until() - # TODO handle errors in response + + while resp == b'': + # If serial communication timeout occurs, response is empty. + # Read again to allow for longer waiting times + resp = self.serialcon.read_until() + + if not resp.startswith(SerialCommands.RECEIVED.value): + raise SerialCommunicationError(f'Serial Communication received unexpected response: {resp}') + return resp @@ -152,44 +190,60 @@ def turn_off(hal: PizzaHAL): def wait_for_input(hal: PizzaHAL, - blue_callback: Any = None, - red_callback: Any = None, - yellow_callback: Any = None, - green_callback: Any = None, - timeout_callback: Any = None, + blue_cb: Any = None, + red_cb: Any = None, + yellow_cb: Any = None, + green_cb: Any = None, + timeout_cb: Any = None, + sound: Any = None, timeout=120, **kwargs): """ Blink leds on buttons. Wait until the user presses a button, then execute - the appropriate callback + the appropriate callback. If a callback is not defined, the button is not + used. + Optionally plays sound which can be interrupted by user input. :param hal: The hardware abstraction object - :param go_callback: called when button 'go' is pressed - :param back_callback: called whan button 'back' is pressed - :param to_callback: called on timeout - :param timeout: inactivity timeout in seconds (default 120) + :param blue_cb: Callback for blue button press + :param red_cb: Callback for red button press + :param yellow_cb: Callback for yellow button press + :param green_cb: Callback for green button press + :param timeout_cb: Callback for no button press + :param sound: Name of sound file to play until user presses a button + :param timeout: Time to wait before abort. 0 to wait forever """ - timeout *= 1000 + if timeout is not None: + timeout *= 1000 + else: + timeout = 0 - bitmask = (1 if blue_callback else 0) | \ - (2 if red_callback else 0) | \ - (4 if yellow_callback else 0) | \ - (8 if green_callback else 0) + bitmask = (1 if blue_cb else 0) | \ + (2 if red_cb else 0) | \ + (4 if yellow_cb else 0) | \ + (8 if green_cb else 0) + + if sound is not None: + hal.play_sound(sound) resp = hal.send_cmd(SerialCommands.USER_INTERACT, bitmask.to_bytes(1, 'little', signed=False), timeout.to_bytes(4, 'little', signed=False)) - if (not resp.startswith(SerialCommands.RECEIVED.value)) or (len(resp) != 3): - raise SerialCommunicationError(f'USER_INTERACTION received {resp} (expected 3 bytes, starting with 0x03)') + + if sound is not None: + hal.stop_sound() + + if len(resp) != 3: + raise SerialCommunicationError(f'USER_INTERACTION expects 3 bytes, received {resp}') resp = resp[1] if resp == 1: - blue_callback(**kwargs) + blue_cb(**kwargs) elif resp == 2: - red_callback(**kwargs) + red_cb(**kwargs) elif resp == 4: - yellow_callback(**kwargs) + yellow_cb(**kwargs) elif resp == 8: - green_callback(**kwargs) - elif timeout_callback is not None: - timeout_callback(**kwargs) + green_cb(**kwargs) + elif timeout_cb is not None: + timeout_cb(**kwargs) def light_layer(hal: PizzaHAL, r: float, g: float, b: float, w: float, fade: float = 0.0, **kwargs): @@ -232,25 +286,25 @@ def backlight(hal: PizzaHAL, r: float, g: float, b: float, w: float, fade: float int(fade * 1000).to_bytes(4, 'little')) -def play_sound(hal: PizzaHAL, sound: Any, interruptable: bool = False, **kwargs): +def play_sound(hal: PizzaHAL, sound: Any, **kwargs): """ - Play a sound. + Play a sound (blocking). :param hal: The hardware abstraction object :param sound: The sound to be played """ # Extract data and sampling rate from file try: - # TODO implement interruption - data, fs = hal.soundcache.get(str(sound), sf.read(str(sound), dtype='float32')) - sd.play(data, fs) - sd.wait() # Wait until file is done playing + hal.play_sound(sound) + while mx.get_busy(): + pass except KeyboardInterrupt: + mx.stop() logger.debug('skipped playback') - # sd.stop() -def record_sound(hal: PizzaHAL, filename: Any, duration: float, +def record_sound(hal: PizzaHAL, filename: Any, + duration: float, cache: bool = False, **kwargs): """ Record sound using the microphone @@ -264,11 +318,12 @@ def record_sound(hal: PizzaHAL, filename: Any, duration: float, samplerate=AUDIO_REC_SR, channels=2) - hal.send_cmd(SerialCommands.RECORD, int(duration).to_bytes(4, 'little', signed=False)) + hal.send_cmd(SerialCommands.RECORD, int(duration*1000).to_bytes(4, 'little', signed=False)) sd.stop() writewav(str(filename), AUDIO_REC_SR, myrecording) + if cache: hal.soundcache[str(filename)] = (myrecording, AUDIO_REC_SR) @@ -297,23 +352,3 @@ def take_photo(hal: PizzaHAL, filename: Any, **kwargs): hal.camera.resolution = PHOTO_RES hal.camera.capture(str(filename)) - -def init_sounds(hal: PizzaHAL, sounds: List): - """ - Load prerecorded Sounds into memory - - :param hal: - :param sounds: A list of sound files - """ - if hal.soundcache is None: - hal.soundcache = {} - - for sound in sounds: - # Extract data and sampling rate from file - data, fs = sf.read(str(sound), dtype='float32') - hal.soundcache[str(sound)] = (data, fs) - - -def init_camera(hal: PizzaHAL): - if hal.camera is None: - hal.camera = PiCamera(sensor_mode=5)