From 2fae2c0ed72aba2a90628c7a7881219e5f13c474 Mon Sep 17 00:00:00 2001 From: Lars Immisch Date: Fri, 19 Apr 2024 13:08:03 +0100 Subject: [PATCH] Experment with state machine --- examples/loopback.py | 156 ++++++++++++++++++++++++------------------- 1 file changed, 87 insertions(+), 69 deletions(-) diff --git a/examples/loopback.py b/examples/loopback.py index 37adb43..6e5740c 100644 --- a/examples/loopback.py +++ b/examples/loopback.py @@ -7,12 +7,20 @@ import logging import re import struct import subprocess +from enum import Enum, auto from datetime import datetime, timedelta from alsaaudio import (PCM, pcms, PCM_PLAYBACK, PCM_CAPTURE, PCM_NONBLOCK, Mixer, PCM_STATE_OPEN, PCM_STATE_SETUP, PCM_STATE_PREPARED, PCM_STATE_RUNNING, PCM_STATE_XRUN, PCM_STATE_DRAINING, PCM_STATE_PAUSED, PCM_STATE_SUSPENDED, ALSAAudioError) from argparse import ArgumentParser +# wake up at least after *idle_timer_period* seconds +idle_timer_period = 0.25 +# grace period for opening the device in seconds +open_grace_period = 0.5 +# close the device after *idle_close_timeout* seconds silence +idle_close_timeout = 2.0 + poll_names = { select.POLLIN: 'POLLIN', select.POLLPRI: 'POLLPRI', @@ -57,6 +65,11 @@ class PollDescriptor(object): return cls(name, fd, mask) +class LoopbackState(Enum): + LISTENING = auto() + PLAYING = auto() + DEVICE_BUSY = auto() + class Loopback(object): '''Loopback state and event handling''' @@ -64,7 +77,6 @@ class Loopback(object): self.playback_args = playback_args self.playback = None self.volume_handler = volume_handler - self.capture_started = None self.last_capture_event = None self.capture = capture @@ -79,11 +91,12 @@ class Loopback(object): self.run_before_start = run_before_start.split(' ') self.run_after_stop_did_run = False - self.waitBeforeOpen = False + self.state = None + self.last_state_change = None + self.queue = [] - self.period_size = 0 - self.silent_periods = 0 + self.silence_start = None @staticmethod def compute_energy(data): @@ -104,36 +117,68 @@ class Loopback(object): logging.info(f'run {cmd}, return code {rc.returncode}') def register(self, reactor): - reactor.register_timeout_handler(self.timeout_handler) + reactor.register_idle_handler(self.idle_handler) reactor.register(self.capture_pd, self) def start(self): + assert self.state == None, "start must only be called once" # start reading data size, data = self.capture.read() if size: self.queue.append(data) - def timeout_handler(self): - if self.playback and self.capture_started: - if self.last_capture_event: - if datetime.now() - self.last_capture_event > timedelta(seconds=2): - logging.info('timeout - closing playback device') - self.playback.close() - self.playback = None - self.capture_started = None + self.state = LoopbackState.LISTENING + + def set_state(self, new_state: LoopbackState) -> LoopbackState: + '''Implement the state of the Loopback as an old-school state machine''' + + if self.state == new_state: + return self.state + + logging.info(f'{self.state} -> {new_state}') + self.last_state_change = datetime.now() + + if new_state == LoopbackState.LISTENING: + if self.state == LoopbackState.PLAYING: + self.playback.close() + self.playback = None + self.last_capture_event = None + if self.volume_handler: + self.volume_handler.stop() + self.run_command(self.run_after_stop) + elif self.state == LoopbackState.DEVICE_BUSY: + pass + elif new_state == LoopbackState.PLAYING: + if self.state == LoopbackState.LISTENING: + try: + self.run_command(self.run_before_start) + self.playback = PCM(**self.playback_args) if self.volume_handler: - self.volume_handler.stop() - self.run_command(self.run_after_stop) + self.volume_handler.start() + + period_size = self.playback.info()['period_size'] + logging.info(f'opened playback device with period_size {period_size}') + except ALSAAudioError as e: + logging.info('opening PCM playback device failed: %s', e) + self.state = LoopbackState.DEVICE_BUSY + return self.state + elif self.state == LoopbackState.DEVICE_BUSY: + # Only try to reopen the device after the grace period + if datetime.now() - self.last_state_change > timedelta(seconds=open_grace_period): + return self.set_state(LoopbackState.PLAYING) + elif new_state == LoopbackState.DEVICE_BUSY: + logging.error(f'{new_state} is internal and cannot be set directly') + + self.state = new_state + return self.state + + def idle_handler(self): + if self.state == LoopbackState.PLAYING: + if datetime.now() - self.last_capture_event > timedelta(seconds=idle_close_timeout): + logging.info('timeout - closing playback device') + self.set_state(LoopbackState.LISTENING) return - self.waitBeforeOpen = False - - if not self.run_after_stop_did_run and not self.playback: - if self.volume_handler: - self.volume_handler.stop() - self.run_command(self.run_after_stop) - self.run_after_stop_did_run = True - def pop(self): if len(self.queue): return self.queue.pop() @@ -148,50 +193,23 @@ class Loopback(object): logging.warning(f'capture event but no data') return False + # the usecase is a USB capture device where we get perfect silence when it's idle + # compute the energy and go back to LISTENING if nothing is captured energy = self.compute_energy(data) logging.debug(f'energy: {energy}') - - # the usecase is a USB capture device where we get perfect silence when it's idle if energy == 0: - self.silent_periods = self.silent_periods + 1 + if self.silence_start is None: + self.silence_start = datetime.now() - # turn off playback after two seconds of silence - # 2 channels * 2 seconds * 2 bytes per sample - fps = self.playback_args['rate'] * 8 // (self.playback_args['periodsize'] * self.playback_args['periods']) - - logging.debug(f'{self.silent_periods} of {fps} silent periods: {self.playback}') - - if self.silent_periods > fps and self.playback: - logging.info(f'closing playback due to silence') - self.playback.close() - self.playback = None - if self.volume_handler: - self.volume_handler.stop() - self.run_command(self.run_after_stop) - self.run_after_stop_did_run = True - - if not self.playback: - return + # turn off playback after idle_close_timeout when there was only silence + if datetime.now() - self.silence_start > timedelta(seconds=idle_close_timeout): + self.set_state(LoopbackState.LISTENING) + return False else: - self.silent_periods = 0 + self.silence_start = None - if not self.playback: - if self.waitBeforeOpen: - return False - try: - if self.volume_handler: - self.volume_handler.start() - self.run_command(self.run_before_start) - self.playback = PCM(**self.playback_args) - self.period_size = self.playback.info()['period_size'] - logging.info(f'opened playback device with period_size {self.period_size}') - except ALSAAudioError as e: - logging.info('opening PCM playback device failed: %s', e) - self.waitBeforeOpen = True - return False - - self.capture_started = datetime.now() - logging.info(f'{self.playback} capture started: {self.capture_started}') + if self.set_state(LoopbackState.PLAYING) != LoopbackState.PLAYING: + return False self.queue.append(data) @@ -263,7 +281,7 @@ class Reactor(object): def __init__(self): self.poll = select.poll() self.descriptors = {} - self.timeout_handlers = set() + self.idle_handlers = set() def register(self, polldescriptor, callable): logging.debug(f'registered {polldescriptor.name}: {poll_desc(polldescriptor.mask)}') @@ -274,17 +292,17 @@ class Reactor(object): self.poll.unregister(polldescriptor.fd) del self.descriptors[polldescriptor.fd] - def register_timeout_handler(self, callable): - self.timeout_handlers.add(callable) + def register_idle_handler(self, callable): + self.idle_handlers.add(callable) - def unregister_timeout_handler(self, callable): - self.timeout_handlers.remove(callable) + def unregister_idle_handler(self, callable): + self.idle_handlers.remove(callable) def run(self): last_timeout_ev = datetime.now() while True: # poll for a bit, then send a timeout to registered handlers - events = self.poll.poll(0.25) + events = self.poll.poll(idle_timer_period) for fd, ev in events: polldescriptor, handler = self.descriptors[fd] @@ -293,8 +311,8 @@ class Reactor(object): handler(fd, ev, polldescriptor.name) - if datetime.now() - last_timeout_ev > timedelta(seconds=0.25): - for t in self.timeout_handlers: + if datetime.now() - last_timeout_ev > timedelta(seconds=idle_timer_period): + for t in self.idle_handlers: t() last_timeout_ev = datetime.now()