Skip to content

Commit f37ca70

Browse files
API: add AudioFlinger for audio playback (i2s DAC and buzzer)
API: add LightsManager for multicolor LEDs
1 parent 82f55e0 commit f37ca70

File tree

20 files changed

+2019
-143
lines changed

20 files changed

+2019
-143
lines changed

CLAUDE.md

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,212 @@ def defocus_handler(self, obj):
643643
- `mpos.clipboard`: System clipboard access
644644
- `mpos.battery_voltage`: Battery level reading (ESP32 only)
645645

646+
## Audio System (AudioFlinger)
647+
648+
MicroPythonOS provides a centralized audio service called **AudioFlinger** (Android-inspired) that manages audio playback across different hardware outputs.
649+
650+
### Supported Audio Devices
651+
652+
- **I2S**: Digital audio output for WAV file playback (Fri3d badge, Waveshare board)
653+
- **Buzzer**: PWM-based tone/ringtone playback (Fri3d badge only)
654+
- **Both**: Simultaneous I2S and buzzer support
655+
- **Null**: No audio (desktop/Linux)
656+
657+
### Basic Usage
658+
659+
**Playing WAV files**:
660+
```python
661+
import mpos.audio.audioflinger as AudioFlinger
662+
663+
# Play music file
664+
success = AudioFlinger.play_wav(
665+
"M:/sdcard/music/song.wav",
666+
stream_type=AudioFlinger.STREAM_MUSIC,
667+
volume=80,
668+
on_complete=lambda msg: print(msg)
669+
)
670+
671+
if not success:
672+
print("Audio playback rejected (higher priority stream active)")
673+
```
674+
675+
**Playing RTTTL ringtones**:
676+
```python
677+
# Play notification sound via buzzer
678+
rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e"
679+
AudioFlinger.play_rtttl(
680+
rtttl,
681+
stream_type=AudioFlinger.STREAM_NOTIFICATION
682+
)
683+
```
684+
685+
**Volume control**:
686+
```python
687+
AudioFlinger.set_volume(70) # 0-100
688+
volume = AudioFlinger.get_volume()
689+
```
690+
691+
**Stopping playback**:
692+
```python
693+
AudioFlinger.stop()
694+
```
695+
696+
### Audio Focus Priority
697+
698+
AudioFlinger implements priority-based audio focus (Android-inspired):
699+
- **STREAM_ALARM** (priority 2): Highest priority
700+
- **STREAM_NOTIFICATION** (priority 1): Medium priority
701+
- **STREAM_MUSIC** (priority 0): Lowest priority
702+
703+
Higher priority streams automatically interrupt lower priority streams. Equal or lower priority streams are rejected while a stream is playing.
704+
705+
### Hardware Support Matrix
706+
707+
| Board | I2S | Buzzer | LEDs |
708+
|-------|-----|--------|------|
709+
| Fri3d 2024 Badge | ✓ (GPIO 2, 47, 16) | ✓ (GPIO 46) | ✓ (5 RGB, GPIO 12) |
710+
| Waveshare ESP32-S3 | ✓ (GPIO 2, 47, 16) |||
711+
| Linux/macOS ||||
712+
713+
### Configuration
714+
715+
Audio device preference is configured in Settings app under "Advanced Settings":
716+
- **Auto-detect**: Use available hardware (default)
717+
- **I2S (Digital Audio)**: Digital audio only
718+
- **Buzzer (PWM Tones)**: Tones/ringtones only
719+
- **Both I2S and Buzzer**: Use both devices
720+
- **Disabled**: No audio
721+
722+
**Note**: Changing the audio device requires a restart to take effect.
723+
724+
### Implementation Details
725+
726+
- **Location**: `lib/mpos/audio/audioflinger.py`
727+
- **Pattern**: Module-level singleton (similar to `battery_voltage.py`)
728+
- **Thread-safe**: Uses locks for concurrent access
729+
- **Background playback**: Runs in separate thread
730+
- **WAV support**: 8/16/24/32-bit PCM, mono/stereo, auto-upsampling to ≥22050 Hz
731+
- **RTTTL parser**: Full Ring Tone Text Transfer Language support with exponential volume curve
732+
733+
## LED Control (LightsManager)
734+
735+
MicroPythonOS provides a simple LED control service for NeoPixel RGB LEDs (Fri3d badge only).
736+
737+
### Basic Usage
738+
739+
**Check availability**:
740+
```python
741+
import mpos.lights as LightsManager
742+
743+
if LightsManager.is_available():
744+
print(f"LEDs available: {LightsManager.get_led_count()}")
745+
```
746+
747+
**Control individual LEDs**:
748+
```python
749+
# Set LED 0 to red (buffered)
750+
LightsManager.set_led(0, 255, 0, 0)
751+
752+
# Set LED 1 to green
753+
LightsManager.set_led(1, 0, 255, 0)
754+
755+
# Update hardware
756+
LightsManager.write()
757+
```
758+
759+
**Control all LEDs**:
760+
```python
761+
# Set all LEDs to blue
762+
LightsManager.set_all(0, 0, 255)
763+
LightsManager.write()
764+
765+
# Clear all LEDs (black)
766+
LightsManager.clear()
767+
LightsManager.write()
768+
```
769+
770+
**Notification colors**:
771+
```python
772+
# Convenience method for common colors
773+
LightsManager.set_notification_color("red")
774+
LightsManager.set_notification_color("green")
775+
# Available: red, green, blue, yellow, orange, purple, white
776+
```
777+
778+
### Custom Animations
779+
780+
LightsManager provides one-shot control only (no built-in animations). Apps implement custom animations using the `update_frame()` pattern:
781+
782+
```python
783+
import time
784+
import mpos.lights as LightsManager
785+
786+
def blink_pattern():
787+
for _ in range(5):
788+
LightsManager.set_all(255, 0, 0)
789+
LightsManager.write()
790+
time.sleep_ms(200)
791+
792+
LightsManager.clear()
793+
LightsManager.write()
794+
time.sleep_ms(200)
795+
796+
def rainbow_cycle():
797+
colors = [
798+
(255, 0, 0), # Red
799+
(255, 128, 0), # Orange
800+
(255, 255, 0), # Yellow
801+
(0, 255, 0), # Green
802+
(0, 0, 255), # Blue
803+
]
804+
805+
for i, color in enumerate(colors):
806+
LightsManager.set_led(i, *color)
807+
808+
LightsManager.write()
809+
```
810+
811+
**For frame-based LED animations**, use the TaskHandler event system:
812+
813+
```python
814+
import mpos.ui
815+
import time
816+
817+
class LEDAnimationActivity(Activity):
818+
last_time = 0
819+
led_index = 0
820+
821+
def onResume(self, screen):
822+
self.last_time = time.ticks_ms()
823+
mpos.ui.task_handler.add_event_cb(self.update_frame, 1)
824+
825+
def onPause(self, screen):
826+
mpos.ui.task_handler.remove_event_cb(self.update_frame)
827+
LightsManager.clear()
828+
LightsManager.write()
829+
830+
def update_frame(self, a, b):
831+
current_time = time.ticks_ms()
832+
delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0
833+
self.last_time = current_time
834+
835+
# Update animation every 0.5 seconds
836+
if delta_time > 0.5:
837+
LightsManager.clear()
838+
LightsManager.set_led(self.led_index, 0, 255, 0)
839+
LightsManager.write()
840+
self.led_index = (self.led_index + 1) % LightsManager.get_led_count()
841+
```
842+
843+
### Implementation Details
844+
845+
- **Location**: `lib/mpos/lights.py`
846+
- **Pattern**: Module-level singleton (similar to `battery_voltage.py`)
847+
- **Hardware**: 5 NeoPixel RGB LEDs on GPIO 12 (Fri3d badge)
848+
- **Buffered**: LED colors are buffered until `write()` is called
849+
- **Thread-safe**: No locking (single-threaded usage recommended)
850+
- **Desktop**: Functions return `False` (no-op) on desktop builds
851+
646852
## Animations and Game Loops
647853

648854
MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations.

internal_filesystem/apps/com.micropythonos.musicplayer/assets/music_player.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import machine
22
import os
3-
import _thread
43
import time
54

65
from mpos.apps import Activity, Intent
76
import mpos.sdcard
87
import mpos.ui
9-
10-
from audio_player import AudioPlayer
8+
import mpos.audio.audioflinger as AudioFlinger
119

1210
class MusicPlayer(Activity):
1311

@@ -68,17 +66,17 @@ def onCreate(self):
6866
self._filename = self.getIntent().extras.get("filename")
6967
qr_screen = lv.obj()
7068
self._slider_label=lv.label(qr_screen)
71-
self._slider_label.set_text(f"Volume: {AudioPlayer.get_volume()}%")
69+
self._slider_label.set_text(f"Volume: {AudioFlinger.get_volume()}%")
7270
self._slider_label.align(lv.ALIGN.TOP_MID,0,lv.pct(4))
7371
self._slider=lv.slider(qr_screen)
7472
self._slider.set_range(0,100)
75-
self._slider.set_value(AudioPlayer.get_volume(), False)
73+
self._slider.set_value(AudioFlinger.get_volume(), False)
7674
self._slider.set_width(lv.pct(90))
7775
self._slider.align_to(self._slider_label,lv.ALIGN.OUT_BOTTOM_MID,0,10)
7876
def volume_slider_changed(e):
7977
volume_int = self._slider.get_value()
8078
self._slider_label.set_text(f"Volume: {volume_int}%")
81-
AudioPlayer.set_volume(volume_int)
79+
AudioFlinger.set_volume(volume_int)
8280
self._slider.add_event_cb(volume_slider_changed,lv.EVENT.VALUE_CHANGED,None)
8381
self._filename_label = lv.label(qr_screen)
8482
self._filename_label.align(lv.ALIGN.CENTER,0,0)
@@ -104,11 +102,23 @@ def onResume(self, screen):
104102
if not self._filename:
105103
print("Not playing any file...")
106104
else:
107-
print("Starting thread to play file {self._filename}")
108-
AudioPlayer.stop_playing()
105+
print(f"Playing file {self._filename}")
106+
AudioFlinger.stop()
109107
time.sleep(0.1)
110-
_thread.stack_size(mpos.apps.good_stack_size())
111-
_thread.start_new_thread(AudioPlayer.play_wav, (self._filename,self.player_finished,))
108+
109+
success = AudioFlinger.play_wav(
110+
self._filename,
111+
stream_type=AudioFlinger.STREAM_MUSIC,
112+
on_complete=self.player_finished
113+
)
114+
115+
if not success:
116+
error_msg = "Error: Audio device unavailable or busy"
117+
print(error_msg)
118+
self.update_ui_threadsafe_if_foreground(
119+
self._filename_label.set_text,
120+
error_msg
121+
)
112122

113123
def focus_obj(self, obj):
114124
obj.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN)
@@ -118,7 +128,7 @@ def defocus_obj(self, obj):
118128
obj.set_style_border_width(0, lv.PART.MAIN)
119129

120130
def stop_button_clicked(self, event):
121-
AudioPlayer.stop_playing()
131+
AudioFlinger.stop()
122132
self.finish()
123133

124134
def player_finished(self, result=None):

internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def __init__(self):
4343
{"title": "Theme Color", "key": "theme_primary_color", "value_label": None, "cont": None, "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors},
4444
{"title": "Timezone", "key": "timezone", "value_label": None, "cont": None, "ui": "dropdown", "ui_options": self.get_timezone_tuples(), "changed_callback": lambda : mpos.time.refresh_timezone_preference()},
4545
# Advanced settings, alphabetically:
46+
{"title": "Audio Output Device", "key": "audio_device", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Auto-detect", "auto"), ("I2S (Digital Audio)", "i2s"), ("Buzzer (PWM Tones)", "buzzer"), ("Both I2S and Buzzer", "both"), ("Disabled", "null")], "changed_callback": self.audio_device_changed},
4647
{"title": "Auto Start App", "key": "auto_start_app", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in PackageManager.get_app_list()]},
4748
{"title": "Restart to Bootloader", "key": "boot_mode", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("Normal", "normal"), ("Bootloader", "bootloader")]}, # special that doesn't get saved
4849
{"title": "Format internal data partition", "key": "format_internal_data_partition", "value_label": None, "cont": None, "ui": "radiobuttons", "ui_options": [("No, do not format", "no"), ("Yes, erase all settings, files and non-builtin apps", "yes")]}, # special that doesn't get saved
@@ -111,6 +112,34 @@ def startSettingActivity(self, setting):
111112
def get_timezone_tuples():
112113
return [(tz, tz) for tz in mpos.time.get_timezones()]
113114

115+
def audio_device_changed(self):
116+
"""
117+
Called when audio device setting changes.
118+
Note: Changing device type at runtime requires a restart for full effect.
119+
AudioFlinger initialization happens at boot.
120+
"""
121+
import mpos.audio.audioflinger as AudioFlinger
122+
123+
new_value = self.prefs.get_string("audio_device", "auto")
124+
print(f"Audio device setting changed to: {new_value}")
125+
print("Note: Restart required for audio device change to take effect")
126+
127+
# Map setting values to device types
128+
device_map = {
129+
"auto": AudioFlinger.get_device_type(), # Keep current
130+
"i2s": AudioFlinger.DEVICE_I2S,
131+
"buzzer": AudioFlinger.DEVICE_BUZZER,
132+
"both": AudioFlinger.DEVICE_BOTH,
133+
"null": AudioFlinger.DEVICE_NULL,
134+
}
135+
136+
desired_device = device_map.get(new_value, AudioFlinger.get_device_type())
137+
current_device = AudioFlinger.get_device_type()
138+
139+
if desired_device != current_device:
140+
print(f"Desired device type ({desired_device}) differs from current ({current_device})")
141+
print("Full device type change requires restart - current session continues with existing device")
142+
114143
def focus_container(self, container):
115144
print(f"container {container} focused, setting border...")
116145
container.set_style_border_color(lv.theme_get_color_primary(None),lv.PART.MAIN)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# AudioFlinger - Centralized Audio Management Service for MicroPythonOS
2+
# Android-inspired audio routing with priority-based audio focus
3+
4+
from . import audioflinger
5+
6+
# Re-export main API
7+
from .audioflinger import (
8+
# Device types
9+
DEVICE_NULL,
10+
DEVICE_I2S,
11+
DEVICE_BUZZER,
12+
DEVICE_BOTH,
13+
14+
# Stream types
15+
STREAM_MUSIC,
16+
STREAM_NOTIFICATION,
17+
STREAM_ALARM,
18+
19+
# Core functions
20+
init,
21+
play_wav,
22+
play_rtttl,
23+
stop,
24+
pause,
25+
resume,
26+
set_volume,
27+
get_volume,
28+
get_device_type,
29+
is_playing,
30+
)
31+
32+
__all__ = [
33+
# Device types
34+
'DEVICE_NULL',
35+
'DEVICE_I2S',
36+
'DEVICE_BUZZER',
37+
'DEVICE_BOTH',
38+
39+
# Stream types
40+
'STREAM_MUSIC',
41+
'STREAM_NOTIFICATION',
42+
'STREAM_ALARM',
43+
44+
# Functions
45+
'init',
46+
'play_wav',
47+
'play_rtttl',
48+
'stop',
49+
'pause',
50+
'resume',
51+
'set_volume',
52+
'get_volume',
53+
'get_device_type',
54+
'is_playing',
55+
]

0 commit comments

Comments
 (0)