mirror of
https://github.com/m5stack/MicroPythonOS.git
synced 2026-05-20 11:51:27 -07:00
API: add AudioFlinger for audio playback (i2s DAC and buzzer)
API: add LightsManager for multicolor LEDs
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# AudioFlinger - Centralized Audio Management Service for MicroPythonOS
|
||||
# Android-inspired audio routing with priority-based audio focus
|
||||
|
||||
from . import audioflinger
|
||||
|
||||
# Re-export main API
|
||||
from .audioflinger import (
|
||||
# Device types
|
||||
DEVICE_NULL,
|
||||
DEVICE_I2S,
|
||||
DEVICE_BUZZER,
|
||||
DEVICE_BOTH,
|
||||
|
||||
# Stream types
|
||||
STREAM_MUSIC,
|
||||
STREAM_NOTIFICATION,
|
||||
STREAM_ALARM,
|
||||
|
||||
# Core functions
|
||||
init,
|
||||
play_wav,
|
||||
play_rtttl,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
set_volume,
|
||||
get_volume,
|
||||
get_device_type,
|
||||
is_playing,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Device types
|
||||
'DEVICE_NULL',
|
||||
'DEVICE_I2S',
|
||||
'DEVICE_BUZZER',
|
||||
'DEVICE_BOTH',
|
||||
|
||||
# Stream types
|
||||
'STREAM_MUSIC',
|
||||
'STREAM_NOTIFICATION',
|
||||
'STREAM_ALARM',
|
||||
|
||||
# Functions
|
||||
'init',
|
||||
'play_wav',
|
||||
'play_rtttl',
|
||||
'stop',
|
||||
'pause',
|
||||
'resume',
|
||||
'set_volume',
|
||||
'get_volume',
|
||||
'get_device_type',
|
||||
'is_playing',
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
# AudioFlinger - Core Audio Management Service
|
||||
# Centralized audio routing with priority-based audio focus (Android-inspired)
|
||||
# Supports I2S (digital audio) and PWM buzzer (tones/ringtones)
|
||||
|
||||
# Device type constants
|
||||
DEVICE_NULL = 0 # No audio hardware (desktop fallback)
|
||||
DEVICE_I2S = 1 # Digital audio output (WAV playback)
|
||||
DEVICE_BUZZER = 2 # PWM buzzer (tones/RTTTL)
|
||||
DEVICE_BOTH = 3 # Both I2S and buzzer available
|
||||
|
||||
# Stream type constants (priority order: higher number = higher priority)
|
||||
STREAM_MUSIC = 0 # Background music (lowest priority)
|
||||
STREAM_NOTIFICATION = 1 # Notification sounds (medium priority)
|
||||
STREAM_ALARM = 2 # Alarms/alerts (highest priority)
|
||||
|
||||
# Module-level state (singleton pattern, follows battery_voltage.py)
|
||||
_device_type = DEVICE_NULL
|
||||
_i2s_pins = None # I2S pin configuration dict (created per-stream)
|
||||
_buzzer_instance = None # PWM buzzer instance
|
||||
_current_stream = None # Currently playing stream
|
||||
_volume = 70 # System volume (0-100)
|
||||
_stream_lock = None # Thread lock for stream management
|
||||
|
||||
|
||||
def init(device_type, i2s_pins=None, buzzer_instance=None):
|
||||
"""
|
||||
Initialize AudioFlinger with hardware configuration.
|
||||
|
||||
Args:
|
||||
device_type: One of DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH
|
||||
i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S devices)
|
||||
buzzer_instance: PWM instance for buzzer (for buzzer devices)
|
||||
"""
|
||||
global _device_type, _i2s_pins, _buzzer_instance, _stream_lock
|
||||
|
||||
_device_type = device_type
|
||||
_i2s_pins = i2s_pins
|
||||
_buzzer_instance = buzzer_instance
|
||||
|
||||
# Initialize thread lock for stream management
|
||||
try:
|
||||
import _thread
|
||||
_stream_lock = _thread.allocate_lock()
|
||||
except ImportError:
|
||||
# Desktop mode - no threading support
|
||||
_stream_lock = None
|
||||
|
||||
device_names = {
|
||||
DEVICE_NULL: "NULL (no audio)",
|
||||
DEVICE_I2S: "I2S (digital audio)",
|
||||
DEVICE_BUZZER: "Buzzer (PWM tones)",
|
||||
DEVICE_BOTH: "Both (I2S + Buzzer)"
|
||||
}
|
||||
|
||||
print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}")
|
||||
|
||||
|
||||
def _check_audio_focus(stream_type):
|
||||
"""
|
||||
Check if a stream with the given type can start playback.
|
||||
Implements priority-based audio focus (Android-inspired).
|
||||
|
||||
Args:
|
||||
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
|
||||
|
||||
Returns:
|
||||
bool: True if stream can start, False if rejected
|
||||
"""
|
||||
global _current_stream
|
||||
|
||||
if not _current_stream:
|
||||
return True # No stream playing, OK to start
|
||||
|
||||
if not _current_stream.is_playing():
|
||||
return True # Current stream finished, OK to start
|
||||
|
||||
# Check priority
|
||||
if stream_type <= _current_stream.stream_type:
|
||||
print(f"AudioFlinger: Stream rejected (priority {stream_type} <= current {_current_stream.stream_type})")
|
||||
return False
|
||||
|
||||
# Higher priority stream - interrupt current
|
||||
print(f"AudioFlinger: Interrupting stream (priority {stream_type} > current {_current_stream.stream_type})")
|
||||
_current_stream.stop()
|
||||
return True
|
||||
|
||||
|
||||
def _playback_thread(stream):
|
||||
"""
|
||||
Background thread function for audio playback.
|
||||
|
||||
Args:
|
||||
stream: Stream instance (WAVStream or RTTTLStream)
|
||||
"""
|
||||
global _current_stream
|
||||
|
||||
# Acquire lock and set as current stream
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
_current_stream = stream
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
try:
|
||||
# Run playback (blocks until complete or stopped)
|
||||
stream.play()
|
||||
except Exception as e:
|
||||
print(f"AudioFlinger: Playback error: {e}")
|
||||
finally:
|
||||
# Clear current stream
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
if _current_stream == stream:
|
||||
_current_stream = None
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
|
||||
def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None):
|
||||
"""
|
||||
Play WAV file via I2S.
|
||||
|
||||
Args:
|
||||
file_path: Path to WAV file (e.g., "M:/sdcard/music/song.wav")
|
||||
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
|
||||
volume: Override volume (0-100), or None to use system volume
|
||||
on_complete: Callback function(message) called when playback finishes
|
||||
|
||||
Returns:
|
||||
bool: True if playback started, False if rejected or unavailable
|
||||
"""
|
||||
if _device_type not in (DEVICE_I2S, DEVICE_BOTH):
|
||||
print("AudioFlinger: play_wav() failed - no I2S device available")
|
||||
return False
|
||||
|
||||
if not _i2s_pins:
|
||||
print("AudioFlinger: play_wav() failed - I2S pins not configured")
|
||||
return False
|
||||
|
||||
# Check audio focus
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
can_start = _check_audio_focus(stream_type)
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
if not can_start:
|
||||
return False
|
||||
|
||||
# Create stream and start playback in background thread
|
||||
try:
|
||||
from mpos.audio.stream_wav import WAVStream
|
||||
import _thread
|
||||
import mpos.apps
|
||||
|
||||
stream = WAVStream(
|
||||
file_path=file_path,
|
||||
stream_type=stream_type,
|
||||
volume=volume if volume is not None else _volume,
|
||||
i2s_pins=_i2s_pins,
|
||||
on_complete=on_complete
|
||||
)
|
||||
|
||||
_thread.stack_size(mpos.apps.good_stack_size())
|
||||
_thread.start_new_thread(_playback_thread, (stream,))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"AudioFlinger: play_wav() failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_complete=None):
|
||||
"""
|
||||
Play RTTTL ringtone via buzzer.
|
||||
|
||||
Args:
|
||||
rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:8e6,8d6...")
|
||||
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
|
||||
volume: Override volume (0-100), or None to use system volume
|
||||
on_complete: Callback function(message) called when playback finishes
|
||||
|
||||
Returns:
|
||||
bool: True if playback started, False if rejected or unavailable
|
||||
"""
|
||||
if _device_type not in (DEVICE_BUZZER, DEVICE_BOTH):
|
||||
print("AudioFlinger: play_rtttl() failed - no buzzer device available")
|
||||
return False
|
||||
|
||||
if not _buzzer_instance:
|
||||
print("AudioFlinger: play_rtttl() failed - buzzer not initialized")
|
||||
return False
|
||||
|
||||
# Check audio focus
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
can_start = _check_audio_focus(stream_type)
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
if not can_start:
|
||||
return False
|
||||
|
||||
# Create stream and start playback in background thread
|
||||
try:
|
||||
from mpos.audio.stream_rtttl import RTTTLStream
|
||||
import _thread
|
||||
import mpos.apps
|
||||
|
||||
stream = RTTTLStream(
|
||||
rtttl_string=rtttl_string,
|
||||
stream_type=stream_type,
|
||||
volume=volume if volume is not None else _volume,
|
||||
buzzer_instance=_buzzer_instance,
|
||||
on_complete=on_complete
|
||||
)
|
||||
|
||||
_thread.stack_size(mpos.apps.good_stack_size())
|
||||
_thread.start_new_thread(_playback_thread, (stream,))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"AudioFlinger: play_rtttl() failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def stop():
|
||||
"""Stop current audio playback."""
|
||||
global _current_stream
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
|
||||
if _current_stream:
|
||||
_current_stream.stop()
|
||||
print("AudioFlinger: Playback stopped")
|
||||
else:
|
||||
print("AudioFlinger: No playback to stop")
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
|
||||
def pause():
|
||||
"""
|
||||
Pause current audio playback (if supported by stream).
|
||||
Note: Most streams don't support pause, only stop.
|
||||
"""
|
||||
global _current_stream
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
|
||||
if _current_stream and hasattr(_current_stream, 'pause'):
|
||||
_current_stream.pause()
|
||||
print("AudioFlinger: Playback paused")
|
||||
else:
|
||||
print("AudioFlinger: Pause not supported or no playback active")
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
|
||||
def resume():
|
||||
"""
|
||||
Resume paused audio playback (if supported by stream).
|
||||
Note: Most streams don't support resume, only play.
|
||||
"""
|
||||
global _current_stream
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
|
||||
if _current_stream and hasattr(_current_stream, 'resume'):
|
||||
_current_stream.resume()
|
||||
print("AudioFlinger: Playback resumed")
|
||||
else:
|
||||
print("AudioFlinger: Resume not supported or no playback active")
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
|
||||
def set_volume(volume):
|
||||
"""
|
||||
Set system volume (affects new streams, not current playback).
|
||||
|
||||
Args:
|
||||
volume: Volume level (0-100)
|
||||
"""
|
||||
global _volume
|
||||
_volume = max(0, min(100, volume))
|
||||
|
||||
|
||||
def get_volume():
|
||||
"""
|
||||
Get system volume.
|
||||
|
||||
Returns:
|
||||
int: Current system volume (0-100)
|
||||
"""
|
||||
return _volume
|
||||
|
||||
|
||||
def get_device_type():
|
||||
"""
|
||||
Get configured audio device type.
|
||||
|
||||
Returns:
|
||||
int: Device type (DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH)
|
||||
"""
|
||||
return _device_type
|
||||
|
||||
|
||||
def is_playing():
|
||||
"""
|
||||
Check if audio is currently playing.
|
||||
|
||||
Returns:
|
||||
bool: True if playback active, False otherwise
|
||||
"""
|
||||
if _stream_lock:
|
||||
_stream_lock.acquire()
|
||||
|
||||
result = _current_stream is not None and _current_stream.is_playing()
|
||||
|
||||
if _stream_lock:
|
||||
_stream_lock.release()
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,231 @@
|
||||
# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger
|
||||
# Ring Tone Text Transfer Language parser and player
|
||||
# Ported from Fri3d Camp 2024 Badge firmware
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
|
||||
class RTTTLStream:
|
||||
"""
|
||||
RTTTL (Ring Tone Text Transfer Language) parser and player.
|
||||
Format: "name:defaults:notes"
|
||||
Example: "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d"
|
||||
|
||||
See: https://en.wikipedia.org/wiki/Ring_Tone_Text_Transfer_Language
|
||||
"""
|
||||
|
||||
# Note frequency table (A-G, with sharps)
|
||||
_NOTES = [
|
||||
440.0, # A
|
||||
493.9, # B or H
|
||||
261.6, # C
|
||||
293.7, # D
|
||||
329.6, # E
|
||||
349.2, # F
|
||||
392.0, # G
|
||||
0.0, # pad
|
||||
|
||||
466.2, # A#
|
||||
0.0, # pad
|
||||
277.2, # C#
|
||||
311.1, # D#
|
||||
0.0, # pad
|
||||
370.0, # F#
|
||||
415.3, # G#
|
||||
0.0, # pad
|
||||
]
|
||||
|
||||
def __init__(self, rtttl_string, stream_type, volume, buzzer_instance, on_complete):
|
||||
"""
|
||||
Initialize RTTTL stream.
|
||||
|
||||
Args:
|
||||
rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:...")
|
||||
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
|
||||
volume: Volume level (0-100)
|
||||
buzzer_instance: PWM buzzer instance
|
||||
on_complete: Callback function(message) when playback finishes
|
||||
"""
|
||||
self.stream_type = stream_type
|
||||
self.volume = volume
|
||||
self.buzzer = buzzer_instance
|
||||
self.on_complete = on_complete
|
||||
self._keep_running = True
|
||||
self._is_playing = False
|
||||
|
||||
# Parse RTTTL format
|
||||
tune_pieces = rtttl_string.split(':')
|
||||
if len(tune_pieces) != 3:
|
||||
raise ValueError('RTTTL should contain exactly 2 colons')
|
||||
|
||||
self.name = tune_pieces[0]
|
||||
self.tune = tune_pieces[2]
|
||||
self.tune_idx = 0
|
||||
self._parse_defaults(tune_pieces[1])
|
||||
|
||||
def is_playing(self):
|
||||
"""Check if stream is currently playing."""
|
||||
return self._is_playing
|
||||
|
||||
def stop(self):
|
||||
"""Stop playback."""
|
||||
self._keep_running = False
|
||||
|
||||
def _parse_defaults(self, defaults):
|
||||
"""
|
||||
Parse default values from RTTTL format.
|
||||
Example: "d=4,o=5,b=140"
|
||||
"""
|
||||
self.default_duration = 4
|
||||
self.default_octave = 5
|
||||
self.bpm = 120
|
||||
|
||||
for item in defaults.split(','):
|
||||
setting = item.split('=')
|
||||
if len(setting) != 2:
|
||||
continue
|
||||
|
||||
key = setting[0].strip()
|
||||
value = int(setting[1].strip())
|
||||
|
||||
if key == 'o':
|
||||
self.default_octave = value
|
||||
elif key == 'd':
|
||||
self.default_duration = value
|
||||
elif key == 'b':
|
||||
self.bpm = value
|
||||
|
||||
# Calculate milliseconds per whole note
|
||||
# 240000 = 60 sec/min * 4 beats/whole-note * 1000 msec/sec
|
||||
self.msec_per_whole_note = 240000.0 / self.bpm
|
||||
|
||||
def _next_char(self):
|
||||
"""Get next character from tune string."""
|
||||
if self.tune_idx < len(self.tune):
|
||||
char = self.tune[self.tune_idx]
|
||||
self.tune_idx += 1
|
||||
if char == ',':
|
||||
char = ' '
|
||||
return char
|
||||
return '|' # End marker
|
||||
|
||||
def _notes(self):
|
||||
"""
|
||||
Generator that yields (frequency, duration_ms) tuples.
|
||||
|
||||
Yields:
|
||||
tuple: (frequency_hz, duration_ms) for each note
|
||||
"""
|
||||
while True:
|
||||
# Skip blank characters and commas
|
||||
char = self._next_char()
|
||||
while char == ' ':
|
||||
char = self._next_char()
|
||||
|
||||
# Parse duration (if present)
|
||||
# Duration of 1 = whole note, 8 = 1/8 note
|
||||
duration = 0
|
||||
while char.isdigit():
|
||||
duration *= 10
|
||||
duration += ord(char) - ord('0')
|
||||
char = self._next_char()
|
||||
|
||||
if duration == 0:
|
||||
duration = self.default_duration
|
||||
|
||||
if char == '|': # End of tune
|
||||
return
|
||||
|
||||
# Parse note letter
|
||||
note = char.lower()
|
||||
if 'a' <= note <= 'g':
|
||||
note_idx = ord(note) - ord('a')
|
||||
elif note == 'h':
|
||||
note_idx = 1 # H is equivalent to B
|
||||
elif note == 'p':
|
||||
note_idx = 7 # Pause
|
||||
else:
|
||||
note_idx = 7 # Unknown = pause
|
||||
|
||||
char = self._next_char()
|
||||
|
||||
# Check for sharp
|
||||
if char == '#':
|
||||
note_idx += 8
|
||||
char = self._next_char()
|
||||
|
||||
# Check for duration modifier (dot) before octave
|
||||
duration_multiplier = 1.0
|
||||
if char == '.':
|
||||
duration_multiplier = 1.5
|
||||
char = self._next_char()
|
||||
|
||||
# Check for octave
|
||||
if '4' <= char <= '7':
|
||||
octave = ord(char) - ord('0')
|
||||
char = self._next_char()
|
||||
else:
|
||||
octave = self.default_octave
|
||||
|
||||
# Check for duration modifier (dot) after octave
|
||||
if char == '.':
|
||||
duration_multiplier = 1.5
|
||||
char = self._next_char()
|
||||
|
||||
# Calculate frequency and duration
|
||||
freq = self._NOTES[note_idx] * (1 << (octave - 4))
|
||||
msec = (self.msec_per_whole_note / duration) * duration_multiplier
|
||||
|
||||
yield freq, msec
|
||||
|
||||
def play(self):
|
||||
"""Play RTTTL tune via buzzer (runs in background thread)."""
|
||||
self._is_playing = True
|
||||
|
||||
# Calculate exponential duty cycle for perceptually linear volume
|
||||
if self.volume <= 0:
|
||||
duty = 0
|
||||
else:
|
||||
volume = min(100, self.volume)
|
||||
|
||||
# Exponential volume curve
|
||||
# Maximum volume is at 50% duty cycle (32768 when using duty_u16)
|
||||
# Minimum is 4 (absolute minimum for audible PWM)
|
||||
divider = 10
|
||||
duty = int(
|
||||
((math.exp(volume / divider) - math.exp(0.1)) /
|
||||
(math.exp(10) - math.exp(0.1)) * (32768 - 4)) + 4
|
||||
)
|
||||
|
||||
print(f"RTTTLStream: Playing '{self.name}' (volume {self.volume}%)")
|
||||
|
||||
try:
|
||||
for freq, msec in self._notes():
|
||||
if not self._keep_running:
|
||||
print("RTTTLStream: Playback stopped by user")
|
||||
break
|
||||
|
||||
# Play tone
|
||||
if freq > 0:
|
||||
self.buzzer.freq(int(freq))
|
||||
self.buzzer.duty_u16(duty)
|
||||
|
||||
# Play for 90% of duration, silent for 10% (note separation)
|
||||
time.sleep_ms(int(msec * 0.9))
|
||||
self.buzzer.duty_u16(0)
|
||||
time.sleep_ms(int(msec * 0.1))
|
||||
|
||||
print(f"RTTTLStream: Finished playing '{self.name}'")
|
||||
if self.on_complete:
|
||||
self.on_complete(f"Finished: {self.name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"RTTTLStream: Error: {e}")
|
||||
if self.on_complete:
|
||||
self.on_complete(f"Error: {e}")
|
||||
|
||||
finally:
|
||||
# Ensure buzzer is off
|
||||
self.buzzer.duty_u16(0)
|
||||
self._is_playing = False
|
||||
@@ -0,0 +1,313 @@
|
||||
# WAVStream - WAV File Playback Stream for AudioFlinger
|
||||
# Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control
|
||||
# Ported from MusicPlayer's AudioPlayer class
|
||||
|
||||
import machine
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
|
||||
# Volume scaling function - regular Python version
|
||||
# Note: Viper optimization removed because @micropython.viper decorator
|
||||
# causes cross-compiler errors on Unix/macOS builds even inside conditionals
|
||||
def _scale_audio(buf, num_bytes, scale_fixed):
|
||||
"""Volume scaling for 16-bit audio samples."""
|
||||
for i in range(0, num_bytes, 2):
|
||||
lo = buf[i]
|
||||
hi = buf[i + 1]
|
||||
sample = (hi << 8) | lo
|
||||
if hi & 128:
|
||||
sample -= 65536
|
||||
sample = (sample * scale_fixed) // 32768
|
||||
if sample > 32767:
|
||||
sample = 32767
|
||||
elif sample < -32768:
|
||||
sample = -32768
|
||||
buf[i] = sample & 255
|
||||
buf[i + 1] = (sample >> 8) & 255
|
||||
|
||||
|
||||
class WAVStream:
|
||||
"""
|
||||
WAV file playback stream with I2S output.
|
||||
Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete):
|
||||
"""
|
||||
Initialize WAV stream.
|
||||
|
||||
Args:
|
||||
file_path: Path to WAV file
|
||||
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
|
||||
volume: Volume level (0-100)
|
||||
i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers
|
||||
on_complete: Callback function(message) when playback finishes
|
||||
"""
|
||||
self.file_path = file_path
|
||||
self.stream_type = stream_type
|
||||
self.volume = volume
|
||||
self.i2s_pins = i2s_pins
|
||||
self.on_complete = on_complete
|
||||
self._keep_running = True
|
||||
self._is_playing = False
|
||||
self._i2s = None
|
||||
|
||||
def is_playing(self):
|
||||
"""Check if stream is currently playing."""
|
||||
return self._is_playing
|
||||
|
||||
def stop(self):
|
||||
"""Stop playback."""
|
||||
self._keep_running = False
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# WAV header parser - returns bit-depth and format info
|
||||
# ----------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _find_data_chunk(f):
|
||||
"""
|
||||
Parse WAV header and find data chunk.
|
||||
|
||||
Returns:
|
||||
tuple: (data_start, data_size, sample_rate, channels, bits_per_sample)
|
||||
"""
|
||||
f.seek(0)
|
||||
if f.read(4) != b'RIFF':
|
||||
raise ValueError("Not a RIFF (standard .wav) file")
|
||||
|
||||
file_size = int.from_bytes(f.read(4), 'little') + 8
|
||||
|
||||
if f.read(4) != b'WAVE':
|
||||
raise ValueError("Not a WAVE (standard .wav) file")
|
||||
|
||||
pos = 12
|
||||
sample_rate = None
|
||||
channels = None
|
||||
bits_per_sample = None
|
||||
|
||||
while pos < file_size:
|
||||
f.seek(pos)
|
||||
chunk_id = f.read(4)
|
||||
if len(chunk_id) < 4:
|
||||
break
|
||||
|
||||
chunk_size = int.from_bytes(f.read(4), 'little')
|
||||
|
||||
if chunk_id == b'fmt ':
|
||||
fmt = f.read(chunk_size)
|
||||
if len(fmt) < 16:
|
||||
raise ValueError("Invalid fmt chunk")
|
||||
|
||||
if int.from_bytes(fmt[0:2], 'little') != 1:
|
||||
raise ValueError("Only PCM supported")
|
||||
|
||||
channels = int.from_bytes(fmt[2:4], 'little')
|
||||
if channels not in (1, 2):
|
||||
raise ValueError("Only mono or stereo supported")
|
||||
|
||||
sample_rate = int.from_bytes(fmt[4:8], 'little')
|
||||
bits_per_sample = int.from_bytes(fmt[14:16], 'little')
|
||||
|
||||
if bits_per_sample not in (8, 16, 24, 32):
|
||||
raise ValueError("Only 8/16/24/32-bit PCM supported")
|
||||
|
||||
elif chunk_id == b'data':
|
||||
return f.tell(), chunk_size, sample_rate, channels, bits_per_sample
|
||||
|
||||
pos += 8 + chunk_size
|
||||
if chunk_size % 2:
|
||||
pos += 1
|
||||
|
||||
raise ValueError("No 'data' chunk found")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Bit depth conversion functions
|
||||
# ----------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _convert_8_to_16(buf):
|
||||
"""Convert 8-bit unsigned PCM to 16-bit signed PCM."""
|
||||
out = bytearray(len(buf) * 2)
|
||||
j = 0
|
||||
for i in range(len(buf)):
|
||||
u8 = buf[i]
|
||||
s16 = (u8 - 128) << 8
|
||||
out[j] = s16 & 0xFF
|
||||
out[j + 1] = (s16 >> 8) & 0xFF
|
||||
j += 2
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _convert_24_to_16(buf):
|
||||
"""Convert 24-bit PCM to 16-bit PCM."""
|
||||
samples = len(buf) // 3
|
||||
out = bytearray(samples * 2)
|
||||
j = 0
|
||||
for i in range(samples):
|
||||
b0 = buf[j]
|
||||
b1 = buf[j + 1]
|
||||
b2 = buf[j + 2]
|
||||
s24 = (b2 << 16) | (b1 << 8) | b0
|
||||
if b2 & 0x80:
|
||||
s24 -= 0x1000000
|
||||
s16 = s24 >> 8
|
||||
out[i * 2] = s16 & 0xFF
|
||||
out[i * 2 + 1] = (s16 >> 8) & 0xFF
|
||||
j += 3
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _convert_32_to_16(buf):
|
||||
"""Convert 32-bit PCM to 16-bit PCM."""
|
||||
samples = len(buf) // 4
|
||||
out = bytearray(samples * 2)
|
||||
j = 0
|
||||
for i in range(samples):
|
||||
b0 = buf[j]
|
||||
b1 = buf[j + 1]
|
||||
b2 = buf[j + 2]
|
||||
b3 = buf[j + 3]
|
||||
s32 = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0
|
||||
if b3 & 0x80:
|
||||
s32 -= 0x100000000
|
||||
s16 = s32 >> 16
|
||||
out[i * 2] = s16 & 0xFF
|
||||
out[i * 2 + 1] = (s16 >> 8) & 0xFF
|
||||
j += 4
|
||||
return out
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Upsampling (zero-order-hold)
|
||||
# ----------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _upsample_buffer(raw, factor):
|
||||
"""Upsample 16-bit buffer by repeating samples."""
|
||||
if factor == 1:
|
||||
return raw
|
||||
|
||||
upsampled = bytearray(len(raw) * factor)
|
||||
out_idx = 0
|
||||
for i in range(0, len(raw), 2):
|
||||
lo = raw[i]
|
||||
hi = raw[i + 1]
|
||||
for _ in range(factor):
|
||||
upsampled[out_idx] = lo
|
||||
upsampled[out_idx + 1] = hi
|
||||
out_idx += 2
|
||||
return upsampled
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main playback routine
|
||||
# ----------------------------------------------------------------------
|
||||
def play(self):
|
||||
"""Main playback routine (runs in background thread)."""
|
||||
self._is_playing = True
|
||||
|
||||
try:
|
||||
with open(self.file_path, 'rb') as f:
|
||||
st = os.stat(self.file_path)
|
||||
file_size = st[6]
|
||||
print(f"WAVStream: Playing {self.file_path} ({file_size} bytes)")
|
||||
|
||||
# Parse WAV header
|
||||
data_start, data_size, original_rate, channels, bits_per_sample = \
|
||||
self._find_data_chunk(f)
|
||||
|
||||
# Decide playback rate (force >=22050 Hz)
|
||||
target_rate = 22050
|
||||
if original_rate >= target_rate:
|
||||
playback_rate = original_rate
|
||||
upsample_factor = 1
|
||||
else:
|
||||
upsample_factor = (target_rate + original_rate - 1) // original_rate
|
||||
playback_rate = original_rate * upsample_factor
|
||||
|
||||
print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch")
|
||||
print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})")
|
||||
|
||||
if data_size > file_size - data_start:
|
||||
data_size = file_size - data_start
|
||||
|
||||
# Initialize I2S (always 16-bit output)
|
||||
try:
|
||||
i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO
|
||||
self._i2s = machine.I2S(
|
||||
0,
|
||||
sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT),
|
||||
ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT),
|
||||
sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT),
|
||||
mode=machine.I2S.TX,
|
||||
bits=16,
|
||||
format=i2s_format,
|
||||
rate=playback_rate,
|
||||
ibuf=32000
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"WAVStream: I2S init failed: {e}")
|
||||
return
|
||||
|
||||
print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)")
|
||||
f.seek(data_start)
|
||||
|
||||
chunk_size = 4096
|
||||
bytes_per_original_sample = (bits_per_sample // 8) * channels
|
||||
total_original = 0
|
||||
|
||||
while total_original < data_size:
|
||||
if not self._keep_running:
|
||||
print("WAVStream: Playback stopped by user")
|
||||
break
|
||||
|
||||
# Read chunk of original data
|
||||
to_read = min(chunk_size, data_size - total_original)
|
||||
to_read -= (to_read % bytes_per_original_sample)
|
||||
if to_read <= 0:
|
||||
break
|
||||
|
||||
raw = bytearray(f.read(to_read))
|
||||
if not raw:
|
||||
break
|
||||
|
||||
# 1. Convert bit-depth to 16-bit
|
||||
if bits_per_sample == 8:
|
||||
raw = self._convert_8_to_16(raw)
|
||||
elif bits_per_sample == 24:
|
||||
raw = self._convert_24_to_16(raw)
|
||||
elif bits_per_sample == 32:
|
||||
raw = self._convert_32_to_16(raw)
|
||||
# 16-bit unchanged
|
||||
|
||||
# 2. Upsample if needed
|
||||
if upsample_factor > 1:
|
||||
raw = self._upsample_buffer(raw, upsample_factor)
|
||||
|
||||
# 3. Volume scaling
|
||||
scale = self.volume / 100.0
|
||||
if scale < 1.0:
|
||||
scale_fixed = int(scale * 32768)
|
||||
_scale_audio(raw, len(raw), scale_fixed)
|
||||
|
||||
# 4. Output to I2S
|
||||
if self._i2s:
|
||||
self._i2s.write(raw)
|
||||
else:
|
||||
# Simulate playback timing if no I2S
|
||||
num_samples = len(raw) // (2 * channels)
|
||||
time.sleep(num_samples / playback_rate)
|
||||
|
||||
total_original += to_read
|
||||
|
||||
print(f"WAVStream: Finished playing {self.file_path}")
|
||||
if self.on_complete:
|
||||
self.on_complete(f"Finished: {self.file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"WAVStream: Error: {e}")
|
||||
if self.on_complete:
|
||||
self.on_complete(f"Error: {e}")
|
||||
|
||||
finally:
|
||||
self._is_playing = False
|
||||
if self._i2s:
|
||||
self._i2s.deinit()
|
||||
self._i2s = None
|
||||
@@ -289,4 +289,33 @@ mpos.battery_voltage.init_adc(13, adc_to_voltage)
|
||||
import mpos.sdcard
|
||||
mpos.sdcard.init(spi_bus, cs_pin=14)
|
||||
|
||||
# === AUDIO HARDWARE ===
|
||||
from machine import PWM, Pin
|
||||
import mpos.audio.audioflinger as AudioFlinger
|
||||
|
||||
# Initialize buzzer (GPIO 46)
|
||||
buzzer = PWM(Pin(46), freq=550, duty=0)
|
||||
|
||||
# I2S pin configuration (GPIO 2, 47, 16)
|
||||
# Note: I2S is created per-stream, not at boot (only one instance can exist)
|
||||
i2s_pins = {
|
||||
'sck': 2,
|
||||
'ws': 47,
|
||||
'sd': 16,
|
||||
}
|
||||
|
||||
# Initialize AudioFlinger (both I2S and buzzer available)
|
||||
AudioFlinger.init(
|
||||
device_type=AudioFlinger.DEVICE_BOTH,
|
||||
i2s_pins=i2s_pins,
|
||||
buzzer_instance=buzzer
|
||||
)
|
||||
|
||||
# === LED HARDWARE ===
|
||||
import mpos.lights as LightsManager
|
||||
|
||||
# Initialize 5 NeoPixel LEDs (GPIO 12)
|
||||
LightsManager.init(neopixel_pin=12, num_leds=5)
|
||||
|
||||
print("Fri3d hardware: Audio and LEDs initialized")
|
||||
print("boot.py finished")
|
||||
|
||||
@@ -95,6 +95,21 @@ def adc_to_voltage(adc_value):
|
||||
|
||||
mpos.battery_voltage.init_adc(999, adc_to_voltage)
|
||||
|
||||
# === AUDIO HARDWARE ===
|
||||
import mpos.audio.audioflinger as AudioFlinger
|
||||
|
||||
# Note: Desktop builds have no audio hardware
|
||||
# AudioFlinger functions will return False (no-op)
|
||||
AudioFlinger.init(
|
||||
device_type=AudioFlinger.DEVICE_NULL,
|
||||
i2s_pins=None,
|
||||
buzzer_instance=None
|
||||
)
|
||||
|
||||
# === LED HARDWARE ===
|
||||
# Note: Desktop builds have no LED hardware
|
||||
# LightsManager will not be initialized (functions will return False)
|
||||
|
||||
print("linux.py finished")
|
||||
|
||||
|
||||
|
||||
@@ -110,4 +110,20 @@ try:
|
||||
except Exception as e:
|
||||
print(f"Warning: powering off camera got exception: {e}")
|
||||
|
||||
# === AUDIO HARDWARE ===
|
||||
import mpos.audio.audioflinger as AudioFlinger
|
||||
|
||||
# Note: Waveshare board has no buzzer or LEDs, only I2S audio
|
||||
# I2S pin configuration will be determined by the board's audio hardware
|
||||
# For now, initialize with I2S only (pins will be configured per-stream if available)
|
||||
AudioFlinger.init(
|
||||
device_type=AudioFlinger.DEVICE_I2S,
|
||||
i2s_pins={'sck': 2, 'ws': 47, 'sd': 16}, # Default ESP32-S3 I2S pins
|
||||
buzzer_instance=None
|
||||
)
|
||||
|
||||
# === LED HARDWARE ===
|
||||
# Note: Waveshare board has no NeoPixel LEDs
|
||||
# LightsManager will not be initialized (functions will return False)
|
||||
|
||||
print("boot.py finished")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Fri3d Camp 2024 Badge Hardware Drivers
|
||||
# These are simple wrappers that can be used by services like AudioFlinger
|
||||
|
||||
from .buzzer import BuzzerConfig
|
||||
from .leds import LEDConfig
|
||||
from .rtttl_data import RTTTL_SONGS
|
||||
|
||||
__all__ = ['BuzzerConfig', 'LEDConfig', 'RTTTL_SONGS']
|
||||
@@ -0,0 +1,11 @@
|
||||
# Fri3d Camp 2024 Badge - Buzzer Configuration
|
||||
|
||||
class BuzzerConfig:
|
||||
"""Configuration for PWM buzzer hardware."""
|
||||
|
||||
# GPIO pin for buzzer
|
||||
PIN = 46
|
||||
|
||||
# Default PWM settings
|
||||
DEFAULT_FREQ = 550 # Hz
|
||||
DEFAULT_DUTY = 0 # Off by default
|
||||
@@ -0,0 +1,10 @@
|
||||
# Fri3d Camp 2024 Badge - LED Configuration
|
||||
|
||||
class LEDConfig:
|
||||
"""Configuration for NeoPixel RGB LED hardware."""
|
||||
|
||||
# GPIO pin for NeoPixel data line
|
||||
PIN = 12
|
||||
|
||||
# Number of NeoPixel LEDs on badge
|
||||
NUM_LEDS = 5
|
||||
@@ -0,0 +1,18 @@
|
||||
# RTTTL Song Catalog
|
||||
# Ring Tone Text Transfer Language songs for buzzer playback
|
||||
# Format: "name:defaults:notes"
|
||||
# Ported from Fri3d Camp 2024 Badge firmware
|
||||
|
||||
RTTTL_SONGS = {
|
||||
"nokia": "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e,8a,8p",
|
||||
|
||||
"macarena": "Macarena:d=4,o=5,b=180:f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,c,8c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c,p,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,p,2c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c",
|
||||
|
||||
"takeonme": "TakeOnMe:d=4,o=4,b=160:8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5,8f#5,8e5",
|
||||
|
||||
"goodbadugly": "TheGoodTheBad:d=4,o=5,b=160:c,8d,8e,8d,c,8d,8e,8d,c,8d,e,8f,2g,8p,a,b,c6,8b,8a,8g,8f,e,8f,g,8e,8d,8c",
|
||||
|
||||
"creeps": "Creeps:d=4,o=5,b=120:8c,8d,8e,8f,g,8e,8f,g,8f,8e,8d,c,8d,8e,f,8d,8e,f,8e,8d,8c,8b4",
|
||||
|
||||
"william_tell": "WilliamTell:d=4,o=5,b=125:8e,8e,8e,2p,8e,8e,8e,2p,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,e"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# LightsManager - Simple LED Control Service for MicroPythonOS
|
||||
# Provides one-shot LED control for NeoPixel RGB LEDs
|
||||
# Apps implement custom animations using the update_frame() pattern
|
||||
|
||||
# Module-level state (singleton pattern)
|
||||
_neopixel = None
|
||||
_num_leds = 0
|
||||
|
||||
|
||||
def init(neopixel_pin, num_leds=5):
|
||||
"""
|
||||
Initialize NeoPixel LEDs.
|
||||
|
||||
Args:
|
||||
neopixel_pin: GPIO pin number for NeoPixel data line
|
||||
num_leds: Number of LEDs in the strip (default 5 for Fri3d badge)
|
||||
"""
|
||||
global _neopixel, _num_leds
|
||||
|
||||
try:
|
||||
from machine import Pin
|
||||
from neopixel import NeoPixel
|
||||
|
||||
_neopixel = NeoPixel(Pin(neopixel_pin, Pin.OUT), num_leds)
|
||||
_num_leds = num_leds
|
||||
|
||||
# Clear all LEDs on initialization
|
||||
for i in range(num_leds):
|
||||
_neopixel[i] = (0, 0, 0)
|
||||
_neopixel.write()
|
||||
|
||||
print(f"LightsManager initialized: {num_leds} LEDs on GPIO {neopixel_pin}")
|
||||
except Exception as e:
|
||||
print(f"LightsManager: Failed to initialize LEDs: {e}")
|
||||
print(" - LED functions will return False (no-op)")
|
||||
|
||||
|
||||
def is_available():
|
||||
"""
|
||||
Check if LED hardware is available.
|
||||
|
||||
Returns:
|
||||
bool: True if LEDs are initialized and available
|
||||
"""
|
||||
return _neopixel is not None
|
||||
|
||||
|
||||
def get_led_count():
|
||||
"""
|
||||
Get the number of LEDs.
|
||||
|
||||
Returns:
|
||||
int: Number of LEDs, or 0 if not initialized
|
||||
"""
|
||||
return _num_leds
|
||||
|
||||
|
||||
def set_led(index, r, g, b):
|
||||
"""
|
||||
Set a single LED color (buffered until write() is called).
|
||||
|
||||
Args:
|
||||
index: LED index (0 to num_leds-1)
|
||||
r: Red value (0-255)
|
||||
g: Green value (0-255)
|
||||
b: Blue value (0-255)
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False if LEDs unavailable or invalid index
|
||||
"""
|
||||
if not _neopixel:
|
||||
return False
|
||||
|
||||
if index < 0 or index >= _num_leds:
|
||||
print(f"LightsManager: Invalid LED index {index} (valid range: 0-{_num_leds-1})")
|
||||
return False
|
||||
|
||||
_neopixel[index] = (r, g, b)
|
||||
return True
|
||||
|
||||
|
||||
def set_all(r, g, b):
|
||||
"""
|
||||
Set all LEDs to the same color (buffered until write() is called).
|
||||
|
||||
Args:
|
||||
r: Red value (0-255)
|
||||
g: Green value (0-255)
|
||||
b: Blue value (0-255)
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False if LEDs unavailable
|
||||
"""
|
||||
if not _neopixel:
|
||||
return False
|
||||
|
||||
for i in range(_num_leds):
|
||||
_neopixel[i] = (r, g, b)
|
||||
return True
|
||||
|
||||
|
||||
def clear():
|
||||
"""
|
||||
Clear all LEDs (set to black, buffered until write() is called).
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False if LEDs unavailable
|
||||
"""
|
||||
return set_all(0, 0, 0)
|
||||
|
||||
|
||||
def write():
|
||||
"""
|
||||
Update hardware with buffered LED colors.
|
||||
Must be called after set_led(), set_all(), or clear() to make changes visible.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False if LEDs unavailable
|
||||
"""
|
||||
if not _neopixel:
|
||||
return False
|
||||
|
||||
_neopixel.write()
|
||||
return True
|
||||
|
||||
|
||||
def set_notification_color(color_name):
|
||||
"""
|
||||
Convenience method to set all LEDs to a common color and update immediately.
|
||||
|
||||
Args:
|
||||
color_name: Color name (red, green, blue, yellow, orange, purple, white)
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False if LEDs unavailable or unknown color
|
||||
"""
|
||||
colors = {
|
||||
"red": (255, 0, 0),
|
||||
"green": (0, 255, 0),
|
||||
"blue": (0, 0, 255),
|
||||
"yellow": (255, 255, 0),
|
||||
"orange": (255, 128, 0),
|
||||
"purple": (128, 0, 255),
|
||||
"white": (255, 255, 255),
|
||||
}
|
||||
|
||||
color = colors.get(color_name.lower())
|
||||
if not color:
|
||||
print(f"LightsManager: Unknown color '{color_name}'")
|
||||
print(f" - Available colors: {', '.join(colors.keys())}")
|
||||
return False
|
||||
|
||||
return set_all(*color) and write()
|
||||
Reference in New Issue
Block a user