AudioFlinger: revert to threaded method

The TaskManager (asyncio) was jittery when under heavy CPU load.
This commit is contained in:
Thomas Farstrike
2025-12-17 20:07:51 +01:00
parent 4836db557b
commit e64b475b10
6 changed files with 96 additions and 42 deletions
@@ -3,9 +3,10 @@
# Supports I2S (digital audio) and PWM buzzer (tones/ringtones)
#
# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer
# Uses TaskManager (asyncio) for non-blocking background playback
# Uses _thread for non-blocking background playback (separate thread from UI)
from mpos.task_manager import TaskManager
import _thread
import mpos.apps
# Stream type constants (priority order: higher number = higher priority)
STREAM_MUSIC = 0 # Background music (lowest priority)
@@ -16,7 +17,6 @@ STREAM_ALARM = 2 # Alarms/alerts (highest priority)
_i2s_pins = None # I2S pin configuration dict (created per-stream)
_buzzer_instance = None # PWM buzzer instance
_current_stream = None # Currently playing stream
_current_task = None # Currently running playback task
_volume = 50 # System volume (0-100)
@@ -86,27 +86,27 @@ def _check_audio_focus(stream_type):
return True
async def _playback_coroutine(stream):
def _playback_thread(stream):
"""
Async coroutine for audio playback.
Thread function for audio playback.
Runs in a separate thread to avoid blocking the UI.
Args:
stream: Stream instance (WAVStream or RTTTLStream)
"""
global _current_stream, _current_task
global _current_stream
_current_stream = stream
try:
# Run async playback
await stream.play_async()
# Run synchronous playback in this thread
stream.play()
except Exception as e:
print(f"AudioFlinger: Playback error: {e}")
finally:
# Clear current stream
if _current_stream == stream:
_current_stream = None
_current_task = None
def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None):
@@ -122,8 +122,6 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None)
Returns:
bool: True if playback started, False if rejected or unavailable
"""
global _current_task
if not _i2s_pins:
print("AudioFlinger: play_wav() failed - I2S not configured")
return False
@@ -132,7 +130,7 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None)
if not _check_audio_focus(stream_type):
return False
# Create stream and start playback as async task
# Create stream and start playback in separate thread
try:
from mpos.audio.stream_wav import WAVStream
@@ -144,7 +142,8 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None)
on_complete=on_complete
)
_current_task = TaskManager.create_task(_playback_coroutine(stream))
_thread.stack_size(mpos.apps.good_stack_size())
_thread.start_new_thread(_playback_thread, (stream,))
return True
except Exception as e:
@@ -165,8 +164,6 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co
Returns:
bool: True if playback started, False if rejected or unavailable
"""
global _current_task
if not _buzzer_instance:
print("AudioFlinger: play_rtttl() failed - buzzer not configured")
return False
@@ -175,7 +172,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co
if not _check_audio_focus(stream_type):
return False
# Create stream and start playback as async task
# Create stream and start playback in separate thread
try:
from mpos.audio.stream_rtttl import RTTTLStream
@@ -187,7 +184,8 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co
on_complete=on_complete
)
_current_task = TaskManager.create_task(_playback_coroutine(stream))
_thread.stack_size(mpos.apps.good_stack_size())
_thread.start_new_thread(_playback_thread, (stream,))
return True
except Exception as e:
@@ -197,7 +195,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co
def stop():
"""Stop current audio playback."""
global _current_stream, _current_task
global _current_stream
if _current_stream:
_current_stream.stop()
@@ -1,10 +1,9 @@
# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger
# Ring Tone Text Transfer Language parser and player
# Uses async playback with TaskManager for non-blocking operation
# Uses synchronous playback in a separate thread for non-blocking operation
import math
from mpos.task_manager import TaskManager
import time
class RTTTLStream:
@@ -180,8 +179,8 @@ class RTTTLStream:
yield freq, msec
async def play_async(self):
"""Play RTTTL tune via buzzer (runs as TaskManager task)."""
def play(self):
"""Play RTTTL tune via buzzer (runs in separate thread)."""
self._is_playing = True
# Calculate exponential duty cycle for perceptually linear volume
@@ -213,10 +212,10 @@ class RTTTLStream:
self.buzzer.duty_u16(duty)
# Play for 90% of duration, silent for 10% (note separation)
# Use async sleep to allow other tasks to run
await TaskManager.sleep_ms(int(msec * 0.9))
# Blocking sleep is OK - we're in a separate thread
time.sleep_ms(int(msec * 0.9))
self.buzzer.duty_u16(0)
await TaskManager.sleep_ms(int(msec * 0.1))
time.sleep_ms(int(msec * 0.1))
print(f"RTTTLStream: Finished playing '{self.name}'")
if self.on_complete:
@@ -1,13 +1,12 @@
# WAVStream - WAV File Playback Stream for AudioFlinger
# Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control
# Uses async playback with TaskManager for non-blocking operation
# Uses synchronous playback in a separate thread for non-blocking operation
import machine
import micropython
import os
import sys
from mpos.task_manager import TaskManager
import time
# Volume scaling function - Viper-optimized for ESP32 performance
# NOTE: The line below is automatically commented out by build_mpos.sh during
@@ -314,8 +313,8 @@ class WAVStream:
# ----------------------------------------------------------------------
# Main playback routine
# ----------------------------------------------------------------------
async def play_async(self):
"""Main async playback routine (runs as TaskManager task)."""
def play(self):
"""Main synchronous playback routine (runs in separate thread)."""
self._is_playing = True
try:
@@ -365,9 +364,8 @@ class WAVStream:
f.seek(data_start)
# Chunk size tuning notes:
# - Smaller chunks = more responsive to stop(), better async yielding
# - Smaller chunks = more responsive to stop()
# - Larger chunks = less overhead, smoother audio
# - 4096 bytes with async yield works well for responsiveness
# - The 32KB I2S buffer handles timing smoothness
chunk_size = 8192
bytes_per_original_sample = (bits_per_sample // 8) * channels
@@ -407,18 +405,15 @@ class WAVStream:
scale_fixed = int(scale * 32768)
_scale_audio_optimized(raw, len(raw), scale_fixed)
# 4. Output to I2S
# 4. Output to I2S (blocking write is OK - we're in a separate thread)
if self._i2s:
self._i2s.write(raw)
else:
# Simulate playback timing if no I2S
num_samples = len(raw) // (2 * channels)
await TaskManager.sleep(num_samples / playback_rate)
time.sleep(num_samples / playback_rate)
total_original += to_read
# Yield to other async tasks after each chunk
await TaskManager.sleep_ms(0)
print(f"WAVStream: Finished playing {self.file_path}")
if self.on_complete:
@@ -30,6 +30,10 @@ from .mocks import (
MockTask,
MockDownloadManager,
# Threading mocks
MockThread,
MockApps,
# Network mocks
MockNetwork,
MockRequests,
@@ -60,6 +64,10 @@ __all__ = [
'MockTask',
'MockDownloadManager',
# Threading mocks
'MockThread',
'MockApps',
# Network mocks
'MockNetwork',
'MockRequests',
+54 -1
View File
@@ -727,4 +727,57 @@ class MockDownloadManager:
def clear_history(self):
"""Clear the call history."""
self.call_history = []
self.call_history = []
# =============================================================================
# Threading Mocks
# =============================================================================
class MockThread:
"""
Mock _thread module for testing threaded operations.
Usage:
sys.modules['_thread'] = MockThread
"""
_started_threads = []
_stack_size = 0
@classmethod
def start_new_thread(cls, func, args):
"""Record thread start but don't actually start a thread."""
cls._started_threads.append((func, args))
return len(cls._started_threads)
@classmethod
def stack_size(cls, size=None):
"""Mock stack_size."""
if size is not None:
cls._stack_size = size
return cls._stack_size
@classmethod
def clear_threads(cls):
"""Clear recorded threads (for test cleanup)."""
cls._started_threads = []
@classmethod
def get_started_threads(cls):
"""Get list of started threads (for test assertions)."""
return cls._started_threads
class MockApps:
"""
Mock mpos.apps module for testing.
Usage:
sys.modules['mpos.apps'] = MockApps
"""
@staticmethod
def good_stack_size():
"""Return a reasonable stack size for testing."""
return 8192
+4 -3
View File
@@ -7,15 +7,16 @@ from mpos.testing import (
MockMachine,
MockPWM,
MockPin,
MockTaskManager,
create_mock_module,
MockThread,
MockApps,
inject_mocks,
)
# Inject mocks before importing AudioFlinger
inject_mocks({
'machine': MockMachine(),
'mpos.task_manager': create_mock_module('mpos.task_manager', TaskManager=MockTaskManager),
'_thread': MockThread,
'mpos.apps': MockApps,
})
# Now import the module to test