Migrate from ESP8266Audio to BackgroundAudio

This commit is contained in:
vidplace7 committed 2026-07-28 11:12:59 -04:00
1 parent 2a20ee5954
commit a80181d55f
19 files changed
+1394 -205

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
#include "AudioThread.h"
#ifdef HAS_I2S
#include "platform/esp32/MeshtasticI2SOut.h"
#include "sleep.h"
// A board with an I2S amplifier opts in by defining AUDIO_AMP_ENABLE(on) in its
// variant.h to power the amp on/off around playback (e.g. an enable pin on an I/O
// expander). The includes below expose the expander instances (io / mcpIoExpander) those
// macros typically reference. Only tlora-pager and meshnology-w10 define it; on the other
// four HAS_I2S boards the speaker is always connected, which is why MeshtasticI2SOut sets
// auto_clear so the DMA emits silence rather than replaying its last buffer.
#ifdef USE_XL9555
#include "ExtensionIOXL9555.hpp"
extern ExtensionIOXL9555 io;
#endif
#ifdef USE_MCP23017
#include "platform/esp32/ExtensionIOMCP23017.h"
#endif
AudioThread::AudioThread() : OSThread("Audio")
{
// Deliberately no hardware access here. This runs from setup() at main.cpp:1034,
// before lateInitVariant() at :1134, so on meshnology-w10 the amp enable pin is not
// an output yet and driving it would be a silent no-op. Boot-time amp-off already
// comes from variant code (see main.cpp:377 for tlora-pager) - commit 42e475963.
sink = std::make_unique<MeshtasticI2SOut>(DAC_I2S_BCK, DAC_I2S_WS, DAC_I2S_DOUT, DAC_I2S_MCLK);
// Only records the rate; the hardware is not touched until begin().
sink->setFrequency(RtttlPcm::kSampleRate);
preflightSleepObserver.observe(&preflightSleep);
}
AudioThread::~AudioThread()
{
stopPlayback();
}
void AudioThread::ampEnable(bool on)
{
// Only drive it on a real change: back-to-back tones would otherwise cut the amp and
// immediately re-enable it, which is audible as a pop.
if (on == ampOn)
return;
ampOn = on;
#ifdef AUDIO_AMP_ENABLE
// Must stay on this thread: on both boards that have an amp enable this is a blocking
// I2C expander write, and issuing it from another task would race the main loop's
// other I2C users.
AUDIO_AMP_ENABLE(on);
#endif
}
bool AudioThread::startPlayback()
{
stopPlayback(); // release anything already running
// Amp first, so it has settled before the first non-silent sample arrives.
ampEnable(true);
if (!sink->begin()) {
LOG_ERROR("Audio: could not start I2S");
ampEnable(false);
generator.reset();
return false;
}
stagedFrames = 0;
stagedOffset = 0;
lastProgressMs = millis();
state = State::PLAYING;
pump(); // prime the DMA now so playback starts without waiting for a tick
setIntervalFromNow(0);
return true;
}
void AudioThread::stopPlayback()
{
stagedFrames = 0;
stagedOffset = 0;
if (sink)
sink->end();
ampEnable(false);
state = State::IDLE;
canSleep = true;
}
void AudioThread::beginDrain()
{
// done() only means the generator has no more samples; up to a full DMA ring is still
// queued for the hardware. Cutting the amp now clips the tail and pops.
drainUntil = millis() + (sink ? sink->dmaDrainMs() : 50) + kSettleMs;
state = State::DRAINING;
}
void AudioThread::pump()
{
if (state != State::PLAYING || !sink)
return;
for (;;) {
// Finish handing over anything the DMA refused last time first.
if (stagedOffset < stagedFrames) {
size_t wrote = sink->writeFrames(staging + (stagedOffset * 2), stagedFrames - stagedOffset);
if (!wrote)
return; // DMA full; come back next tick
lastProgressMs = millis();
stagedOffset += wrote;
if (stagedOffset < stagedFrames)
return;
}
stagedOffset = 0;
stagedFrames = generator.generate(staging, kStagingFrames);
if (!stagedFrames) {
beginDrain();
return;
}
}
}
void AudioThread::beginRttl(const void *data, uint32_t len)
{
if (!sink || !data)
return;
if (!generator.begin((const char *)data, len)) {
LOG_WARN("Audio: ignoring malformed RTTTL");
return;
}
startPlayback();
}
void AudioThread::beginTones(const ToneDuration *tones, size_t count)
{
if (!sink || !tones || !count)
return;
if (!generator.beginTones(tones, count))
return;
startPlayback();
}
bool AudioThread::isPlaying()
{
// Doubles as the pump so that callers polling this keep audio flowing even if this
// thread is starved - which is how ExternalNotificationModule has always driven it.
if (state == State::PLAYING)
pump();
else if (state == State::DRAINING && millis() >= drainUntil)
stopPlayback();
return !isIdle();
}
void AudioThread::stop()
{
generator.reset();
stopPlayback();
}
int32_t AudioThread::runOnce()
{
switch (state) {
case State::PLAYING:
pump();
canSleep = false;
// Never let a wedged I2S channel leave the amplifier powered forever.
if (state == State::PLAYING && (millis() - lastProgressMs) > kStallTimeoutMs) {
LOG_ERROR("Audio: I2S stalled, abandoning playback");
stop();
return kIdleIntervalMs;
}
return kActiveIntervalMs;
case State::DRAINING:
canSleep = false;
if (millis() >= drainUntil) {
stopPlayback();
return kIdleIntervalMs;
}
return kActiveIntervalMs;
case State::IDLE:
default:
canSleep = true;
return kIdleIntervalMs;
}
}
#endif // HAS_I2S
+85 -87
View File
@@ -1,112 +1,110 @@
#pragma once
#include "PowerFSM.h"
#include "Observer.h"
#include "audio/RtttlPcm.h"
#include "concurrency/OSThread.h"
#include "configuration.h"
#include "main.h"
#include "sleep.h"
#include <memory>
#ifdef HAS_I2S
#include <AudioFileSourcePROGMEM.h>
#include <AudioGeneratorRTTTL.h>
#include <AudioOutputI2S.h>
#include <ESP8266SAM.h>
// A board with an I2S amplifier opts in by defining AUDIO_AMP_ENABLE(on) in its variant.h to power the
// amp on/off around playback (e.g. an enable pin on an I/O expander). The includes below expose the
// expander instances (io / mcpIoExpander) those macros typically reference.
#ifdef USE_XL9555
#include "ExtensionIOXL9555.hpp"
extern ExtensionIOXL9555 io;
#endif
#ifdef USE_MCP23017
#include "platform/esp32/ExtensionIOMCP23017.h"
#endif
#define AUDIO_THREAD_INTERVAL_MS 100
class MeshtasticI2SOut;
/**
* I2S playback for tones and ringtones.
*
* The public API is unchanged from the ESP8266Audio implementation this replaces, minus
* readAloud(): BackgroundAudio has no SAM equivalent, its espeak-ng speech costs ~947KB of
* flash and ~88KB of permanent internal DRAM, and ESP8266SAM cannot be kept alongside it -
* espeak's `SetSpeed`/`speed` globals collide with SAM's at link time. Text-to-speech is
* therefore dropped for now.
*
* The other difference that matters: playback is asynchronous. Samples are
* generated on demand by RtttlPcm and pushed into the I2S DMA ring a chunk at a time,
* either from runOnce() or from isPlaying(). isPlaying() stays true until the DMA has
* actually drained, not just until the last sample was queued - the amplifier must not
* be cut before the audio has physically left the pin.
*
* The I2S channel is allocated per playback and released when idle, matching what
* ESP8266Audio did, so an idle board draws no more than it used to.
*/
class AudioThread : public concurrency::OSThread
{
public:
AudioThread() : OSThread("Audio") { initOutput(); }
AudioThread();
~AudioThread();
void beginRttl(const void *data, uint32_t len)
{
#ifdef AUDIO_AMP_ENABLE
AUDIO_AMP_ENABLE(true);
#endif
setCPUFast(true);
rtttlFile = std::unique_ptr<AudioFileSourcePROGMEM>(new AudioFileSourcePROGMEM(data, len));
i2sRtttl = std::unique_ptr<AudioGeneratorRTTTL>(new AudioGeneratorRTTTL());
i2sRtttl->begin(rtttlFile.get(), audioOut.get());
}
/// Play an RTTTL string (a user ringtone). Malformed songs are ignored.
void beginRttl(const void *data, uint32_t len);
// Also handles actually playing the RTTTL, needs to be called in loop
bool isPlaying()
{
if (i2sRtttl != nullptr) {
return i2sRtttl->isRunning() && i2sRtttl->loop();
}
return false;
}
/// Play a system melody directly, without going via RTTTL.
void beginTones(const ToneDuration *tones, size_t count);
void stop()
{
if (i2sRtttl != nullptr) {
i2sRtttl->stop();
i2sRtttl = nullptr;
}
/// True while anything is still playing or draining. Also services the DMA, so it is
/// safe - and useful - for callers to poll it.
bool isPlaying();
rtttlFile = nullptr;
/// Stop immediately and power the amplifier down. Safe to call when nothing is
/// playing; ExternalNotificationModule::stopNow() relies on that (see 2ae391197).
void stop();
setCPUFast(false);
#ifdef AUDIO_AMP_ENABLE
AUDIO_AMP_ENABLE(false);
#endif
}
/// Veto light sleep while audio is in flight - sleeping gates the I2S peripheral
/// clock, which would truncate playback.
int preflightSleepCb(void *unused) { return isIdle() ? 0 : 1; }
void readAloud(const char *text)
{
if (i2sRtttl != nullptr) {
i2sRtttl->stop();
i2sRtttl = nullptr;
}
#ifdef AUDIO_AMP_ENABLE
AUDIO_AMP_ENABLE(true);
#endif
auto sam = std::unique_ptr<ESP8266SAM>(new ESP8266SAM);
sam->Say(audioOut.get(), text);
setCPUFast(false);
#ifdef AUDIO_AMP_ENABLE
AUDIO_AMP_ENABLE(false);
#endif
}
CallbackObserver<AudioThread, void *> preflightSleepObserver =
CallbackObserver<AudioThread, void *>(this, &AudioThread::preflightSleepCb);
protected:
int32_t runOnce() override
{
canSleep = true; // Assume we should not keep the board awake
// if (i2sRtttl != nullptr && i2sRtttl->isRunning()) {
// i2sRtttl->loop();
// }
return AUDIO_THREAD_INTERVAL_MS;
}
int32_t runOnce() override;
private:
void initOutput()
{
audioOut = std::unique_ptr<AudioOutputI2S>(new AudioOutputI2S(1, AudioOutputI2S::EXTERNAL_I2S));
audioOut->SetPinout(DAC_I2S_BCK, DAC_I2S_WS, DAC_I2S_DOUT, DAC_I2S_MCLK);
audioOut->SetGain(0.2);
};
enum class State { IDLE, PLAYING, DRAINING };
std::unique_ptr<AudioGeneratorRTTTL> i2sRtttl = nullptr;
std::unique_ptr<AudioOutputI2S> audioOut = nullptr;
/// Frames handed to the DMA per write. One DMA buffer's worth.
static constexpr size_t kStagingFrames = 128;
std::unique_ptr<AudioFileSourcePROGMEM> rtttlFile = nullptr;
/// Extra quiet time after the DMA has drained, before the amp is cut.
static constexpr uint32_t kSettleMs = 20;
/// If the DMA accepts nothing for this long while playing, something is wrong with the
/// channel. Give up rather than leave the amplifier powered indefinitely. A real song
/// makes progress every tick, since a DMA buffer frees roughly every 46ms.
static constexpr uint32_t kStallTimeoutMs = 1000;
static constexpr int32_t kActiveIntervalMs = 10;
static constexpr int32_t kIdleIntervalMs = 100;
bool isIdle() const { return state == State::IDLE; }
/// Arm the sink and start feeding, assuming the generator is already loaded.
bool startPlayback();
/// Release the channel and power down the amp. Idempotent.
void stopPlayback();
/// Move samples into the DMA until it is full or the song ends.
void pump();
/// Hold on until the queued audio has physically played out.
void beginDrain();
void ampEnable(bool on);
std::unique_ptr<MeshtasticI2SOut> sink;
RtttlPcm generator;
// Frames generated but not yet accepted by the DMA. generate() is destructive, so a
// short write has to be carried across pump() calls rather than regenerated.
int16_t staging[kStagingFrames * 2] = {};
size_t stagedFrames = 0;
size_t stagedOffset = 0;
State state = State::IDLE;
uint32_t drainUntil = 0;
uint32_t lastProgressMs = 0;
/// Tracks the amp enable so it is only driven on an actual change. On the two boards
/// that have one it is an I2C expander write, and needlessly cycling it off and on at
/// the start of every tone is audible as a pop (see commit 42e475963).
bool ampOn = false;
};
#endif
#endif // HAS_I2S
+338
View File
@@ -0,0 +1,338 @@
#include "RtttlPcm.h"
#include <string.h>
// Equal-tempered note frequencies, C4 through B7, with a leading 0 so that a
// note index of 0 means "rest". Ported verbatim from AudioGeneratorRTTTL so that
// existing ringtones keep their exact pitches.
static const int notes[] = {0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, 523, 554, 587, 622,
659, 698, 740, 784, 831, 880, 932, 988, 1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661,
1760, 1865, 1976, 2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951};
static constexpr int notesCount = sizeof(notes) / sizeof(notes[0]);
void RtttlPcm::reset()
{
_song[0] = 0;
_len = 0;
_ptr = 0;
_toneCount = 0;
_toneIndex = 0;
_toneMode = false;
_samplesPerWaveFP10 = 0;
_phaseFP10 = 0;
_noteSamples = 0;
_samplesSent = 0;
_done = true;
}
bool RtttlPcm::begin(const char *song, size_t len)
{
reset();
if (!song || len == 0)
return false;
if (len > sizeof(_song) - 1)
len = sizeof(_song) - 1;
memcpy(_song, song, len);
_song[len] = 0;
_len = (int)len;
if (!parseHeader())
return false;
// Arm the first note now so isPlaying() is true immediately and a song whose
// body is empty reports done rather than emitting a stuck note.
if (!nextNote())
return false;
_done = false;
return true;
}
bool RtttlPcm::beginTones(const ToneDuration *tones, size_t count)
{
reset();
if (!tones || count == 0)
return false;
if (count > kMaxTones)
count = kMaxTones;
memcpy(_tones, tones, count * sizeof(ToneDuration));
_toneCount = count;
_toneMode = true;
if (!nextTone())
return false;
_done = false;
return true;
}
bool RtttlPcm::skipWhitespace()
{
while ((_ptr < _len) && ((_song[_ptr] == ' ') || (_song[_ptr] == '\t') || (_song[_ptr] == '\r') || (_song[_ptr] == '\n')))
_ptr++;
return _ptr < _len;
}
bool RtttlPcm::readInt(int *dest)
{
if (_ptr >= _len)
return false;
skipWhitespace();
if (_ptr >= _len)
return false;
if ((_song[_ptr] < '0') || (_song[_ptr] > '9'))
return false;
int t = 0;
// Unlike upstream, this loop is bounded by _len as well as by the character
// class, so a song ending in a digit cannot walk off the end.
while ((_ptr < _len) && (_song[_ptr] >= '0') && (_song[_ptr] <= '9')) {
t = (t * 10) + (_song[_ptr] - '0');
_ptr++;
}
*dest = t;
return true;
}
bool RtttlPcm::parseHeader()
{
// Skip the title, up to and including the first ':'.
while ((_ptr < _len) && (_song[_ptr] != ':'))
_ptr++;
if (_ptr >= _len)
return false;
if (_song[_ptr++] != ':')
return false;
// The d=, o=, b= fields are required, in that order.
if (!skipWhitespace())
return false;
if ((_song[_ptr] != 'd') && (_song[_ptr] != 'D'))
return false;
_ptr++;
if (!skipWhitespace())
return false;
if (_song[_ptr++] != '=')
return false;
if (!readInt(&_defaultDuration))
return false;
if (!skipWhitespace())
return false;
if (_song[_ptr++] != ',')
return false;
if (!skipWhitespace())
return false;
if ((_song[_ptr] != 'o') && (_song[_ptr] != 'O'))
return false;
_ptr++;
if (!skipWhitespace())
return false;
if (_song[_ptr++] != '=')
return false;
if (!readInt(&_defaultOctave))
return false;
if (!skipWhitespace())
return false;
if (_song[_ptr++] != ',')
return false;
int bpm = 0;
if (!skipWhitespace())
return false;
if ((_song[_ptr] != 'b') && (_song[_ptr] != 'B'))
return false;
_ptr++;
if (!skipWhitespace())
return false;
if (_song[_ptr++] != '=')
return false;
if (!readInt(&bpm))
return false;
if (!skipWhitespace())
return false;
if (_song[_ptr++] != ':')
return false;
// Upstream divided by bpm unguarded; "b=0" crashed.
if (bpm <= 0)
return false;
if (_defaultDuration <= 0)
return false;
_wholeNoteMs = (60 * 1000 * 4) / bpm;
return true;
}
bool RtttlPcm::nextNote()
{
int dur, note, scale;
if (_ptr >= _len)
return false;
if (!readInt(&dur) || (dur <= 0))
dur = _defaultDuration;
// Truncating twice - once here and again when converting ms to samples - is
// what upstream did, and existing ringtones depend on the exact result.
dur = _wholeNoteMs / dur;
if (_ptr >= _len)
return false;
note = 0;
switch (_song[_ptr++]) {
case 'c':
case 'C':
note = 1;
break;
case 'd':
case 'D':
note = 3;
break;
case 'e':
case 'E':
note = 5;
break;
case 'f':
case 'F':
note = 6;
break;
case 'g':
case 'G':
note = 8;
break;
case 'a':
case 'A':
note = 10;
break;
case 'b':
case 'B':
note = 12;
break;
case 'p':
case 'P':
note = 0;
break;
default:
// Anything else ends the song, which is also how a trailing separator is
// absorbed.
return false;
}
if ((_ptr < _len) && (_song[_ptr] == '#')) {
_ptr++;
note++;
}
// Accept a dot on either side of the octave digit; upstream only looked after
// it, so the spec-legal "4c#.5" silently desynced and truncated the song.
bool dotted = false;
if ((_ptr < _len) && (_song[_ptr] == '.')) {
_ptr++;
dotted = true;
}
if (!readInt(&scale))
scale = _defaultOctave;
if (!dotted && (_ptr < _len) && (_song[_ptr] == '.')) {
_ptr++;
dotted = true;
}
if (dotted)
dur += dur / 2;
skipWhitespace();
if ((_ptr < _len) && (_song[_ptr] == ','))
_ptr++;
if (scale < 4)
scale = 4;
if (scale > 7)
scale = 7;
int freq = 0;
if (note) {
int index = (scale - 4) * 12 + note;
// "b#7" indexes one past the table upstream; clamp instead of reading OOB.
if (index >= notesCount)
index = notesCount - 1;
freq = notes[index];
}
startNote(freq, dur);
return true;
}
bool RtttlPcm::nextTone()
{
if (_toneIndex >= _toneCount)
return false;
const ToneDuration &t = _tones[_toneIndex++];
// NOTE_SILENT is 1Hz, which as a square wave would be an audible thump rather
// than a rest, so treat anything at or below it as silence.
startNote(t.frequency_khz > 1 ? t.frequency_khz : 0, t.duration_ms);
return true;
}
void RtttlPcm::startNote(int freqHz, int durationMs)
{
if (durationMs < 0)
durationMs = 0;
_samplesPerWaveFP10 = freqHz > 0 ? (int32_t)((kSampleRate << 10) / (uint32_t)freqHz) : 0;
_phaseFP10 = 0;
_noteSamples = (kSampleRate * (uint32_t)durationMs) / 1000;
_samplesSent = 0;
}
bool RtttlPcm::advance()
{
return _toneMode ? nextTone() : nextNote();
}
size_t RtttlPcm::generate(int16_t *interleavedLR, size_t maxFrames)
{
if (!interleavedLR || _done)
return 0;
size_t n = 0;
while (n < maxFrames) {
if (_samplesSent >= _noteSamples) {
if (!advance()) {
_done = true;
break;
}
// A zero-length note would otherwise spin without making progress.
if (_noteSamples == 0)
continue;
}
if (_samplesPerWaveFP10 == 0) {
while ((n < maxFrames) && (_samplesSent < _noteSamples)) {
interleavedLR[2 * n] = 0;
interleavedLR[2 * n + 1] = 0;
_samplesSent++;
n++;
}
} else {
while ((n < maxFrames) && (_samplesSent < _noteSamples)) {
int16_t v = (_phaseFP10 > (_samplesPerWaveFP10 / 2)) ? kAmplitude : -kAmplitude;
interleavedLR[2 * n] = v;
interleavedLR[2 * n + 1] = v;
_phaseFP10 += 1024;
if (_phaseFP10 >= _samplesPerWaveFP10)
_phaseFP10 -= _samplesPerWaveFP10;
_samplesSent++;
n++;
}
}
}
return n;
}
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
// A single tone in a system melody. Frequency is in Hz despite the historical
// field name; a frequency of NOTE_SILENT (1) or less is treated as a rest.
struct ToneDuration {
int frequency_khz;
int duration_ms;
};
/**
* RTTTL parser and square-wave PCM generator.
*
* Replaces ESP8266Audio's AudioGeneratorRTTTL, which BackgroundAudio has no
* equivalent for. The parse and synthesis math is ported from that generator
* (GPL-3.0, Copyright (C) 2018 Earle F. Philhower, III) so existing user
* ringtones keep their exact pitch and note lengths, including the double
* integer truncation of note durations.
*
* This is a pull API: callers ask for N frames whenever the audio sink has room,
* rather than the generator pushing into an output. It has no Arduino or ESP-IDF
* dependency so it can be unit tested natively.
*/
class RtttlPcm
{
public:
/// Sample rate the generator emits at, matching AudioGeneratorRTTTL's default.
static constexpr uint32_t kSampleRate = 22050;
/// Peak amplitude. AudioGeneratorRTTTL emitted +/-8192 and AudioOutputI2S then
/// applied SetGain(0.2) -> gainF2P6 = 12 -> 8192 * 12 >> 6 = 1536. ESP32I2SAudio
/// has no gain stage, so the attenuation is baked in here instead.
static constexpr int16_t kAmplitude = 1536;
/// Longest melody buzz.cpp defines is 10 notes; round up for headroom.
static constexpr size_t kMaxTones = 16;
/// Parse an RTTTL song and arm the first note. Returns false if the header is
/// malformed, in which case nothing is played.
bool begin(const char *song, size_t len);
/// Arm a system melody directly, skipping RTTTL entirely. The tones are copied,
/// so callers may pass a stack array. At most kMaxTones are used.
bool beginTones(const ToneDuration *tones, size_t count);
/// Write up to maxFrames interleaved L/R frames. Returns the number of frames
/// written, which is less than maxFrames only when the song has ended.
size_t generate(int16_t *interleavedLR, size_t maxFrames);
/// True once every note has been generated.
bool done() const { return _done; }
/// Abandon any song in progress.
void reset();
private:
bool skipWhitespace();
bool readInt(int *dest);
bool parseHeader();
bool nextNote();
bool nextTone();
bool advance();
void startNote(int freqHz, int durationMs);
// NUL-terminated copy of the song. meshtastic_RTTTLConfig.ringtone is 231
// bytes. The upstream generator malloc()'d exactly len bytes with no
// terminator and its digit loop had no bounds check, so any song ending in a
// digit read past the allocation; a terminated fixed buffer removes both the
// over-read and the allocation.
char _song[256] = {0};
int _len = 0;
int _ptr = 0;
// Direct tone-list playback, used instead of RTTTL for system melodies.
ToneDuration _tones[kMaxTones] = {};
size_t _toneCount = 0;
size_t _toneIndex = 0;
bool _toneMode = false;
int _defaultDuration = 4;
int _defaultOctave = 6;
int _wholeNoteMs = 0;
// Samples per wave period in 22.10 fixed point; 0 means the note is a rest.
int32_t _samplesPerWaveFP10 = 0;
// Phase accumulator, also 22.10. Replaces the upstream `samplesSent << 10`
// expression, which overflowed int32 past 2^21 samples. Provably identical
// because the period is always > 1024 for every note in the table.
int32_t _phaseFP10 = 0;
uint32_t _noteSamples = 0;
uint32_t _samplesSent = 0;
bool _done = true;
};
+7 -51
View File
@@ -1,6 +1,9 @@
#include "buzz.h"
#include "NodeDB.h"
#include "configuration.h"
// ToneDuration lives with the synthesizer that consumes it, so the I2S path can play a
// melody directly instead of round-tripping it through an RTTTL string.
#include "audio/RtttlPcm.h"
#if !defined(ARCH_ESP32) && !defined(ARCH_RP2040) && !defined(ARCH_PORTDUINO)
#include "Tone.h"
@@ -8,18 +11,12 @@
#if defined(HAS_I2S)
#include "main.h"
#include <unordered_map>
#endif
#if !defined(ARCH_PORTDUINO)
extern "C" void delay(uint32_t dwMs);
#endif
struct ToneDuration {
int frequency_khz;
int duration_ms;
};
// Some common frequencies.
#define NOTE_SILENT 1
#define NOTE_C3 131
@@ -55,50 +52,6 @@ const int DURATION_1_2 = 500; // 1/2 note
const int DURATION_3_4 = 750; // 3/4 note
const int DURATION_1_1 = 1000; // 1/1 note
#ifdef HAS_I2S
void playTonesRTTTL(const ToneDuration *tone_durations, int size)
{
// translate ToneDuration[] to RTTTL string and play using audioThread
static std::unordered_map<int, std::string> freqToNote = {
{NOTE_C3, "c4"}, {NOTE_CS3, "c#4"}, {NOTE_D3, "d4"}, {NOTE_DS3, "d#4"}, {NOTE_E3, "e4"}, {NOTE_F3, "f4"},
{NOTE_FS3, "f#4"}, {NOTE_G3, "g4"}, {NOTE_GS3, "g#4"}, {NOTE_A3, "a4"}, {NOTE_AS3, "a#4"}, {NOTE_B3, "b4"},
{NOTE_C4, "c5"}, {NOTE_E4, "e5"}, {NOTE_G4, "g5"}, {NOTE_A4, "a5"}, {NOTE_C5, "c6"}, {NOTE_E5, "e6"},
{NOTE_G5, "g6"}, {NOTE_F5, "f6"}, {NOTE_G6, "g7"}, {NOTE_E7, "e8"}};
char rtttl[128] = "tone:d=32,o=4,b=200:"; // default duration and octave
for (int i = 0; i < size; i++) {
const auto &td = tone_durations[i];
std::string note = "b4";
if (freqToNote.find(td.frequency_khz) != freqToNote.end()) {
note = freqToNote[td.frequency_khz];
}
int dur = 32; // default duration
if (td.duration_ms >= 1000)
dur = 1;
else if (td.duration_ms >= 500)
dur = 2;
else if (td.duration_ms >= 250)
dur = 4;
else if (td.duration_ms >= 125)
dur = 8;
else if (td.duration_ms >= 62)
dur = 16;
else
dur = 32;
char noteStr[64];
snprintf(noteStr, sizeof(noteStr), "%s,%d", note.c_str(), dur);
strncat(rtttl, noteStr, sizeof(rtttl) - strlen(rtttl) - 1);
audioThread->beginRttl(rtttl, strlen(rtttl));
while (audioThread->isPlaying()) {
delay(10);
}
return;
}
}
#endif
void playTones(const ToneDuration *tone_durations, int size)
{
if (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED ||
@@ -108,7 +61,10 @@ void playTones(const ToneDuration *tone_durations, int size)
}
#ifdef HAS_I2S
if (moduleConfig.external_notification.use_i2s_as_buzzer && audioThread) {
playTonesRTTTL(tone_durations, size);
// Hand the melody straight to the synthesizer - frequency and duration are already
// in hand, so there is no reason to encode them as RTTTL and parse them back. This
// returns immediately; playback continues from the audio thread.
audioThread->beginTones(tone_durations, (size_t)size);
return;
}
#endif
+2 -25
View File
@@ -702,7 +702,7 @@ void menuHandler::clockMenu()
}
void menuHandler::messageResponseMenu()
{
enum optionsNumbers { Back = 0, ViewMode, DeleteMenu, ReplyMenu, MuteChannel, Aloud, enumEnd };
enum optionsNumbers { Back = 0, ViewMode, DeleteMenu, ReplyMenu, MuteChannel, enumEnd };
static const char *optionsArray[enumEnd];
static int optionsEnumArray[enumEnd];
@@ -734,11 +734,6 @@ void menuHandler::messageResponseMenu()
optionsArray[options] = "Delete";
optionsEnumArray[options++] = DeleteMenu;
#ifdef HAS_I2S
optionsArray[options] = "Read Aloud";
optionsEnumArray[options++] = Aloud;
#endif
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Message Action";
if (currentResolution == ScreenResolution::UltraLow) {
@@ -776,16 +771,6 @@ void menuHandler::messageResponseMenu()
} else if (selected == DeleteMenu) {
menuHandler::menuQueue = menuHandler::DeleteMessagesMenu;
screen->runNow();
#ifdef HAS_I2S
} else if (selected == Aloud) {
if (const StoredMessage *latest = getNewestMessageForActiveThread()) {
const char *msg = MessageStore::getText(*latest);
if (msg && msg[0]) {
audioThread->readAloud(msg);
}
}
#endif
}
};
screen->showOverlayBanner(bannerOptions);
@@ -2292,7 +2277,7 @@ void menuHandler::traceRouteMenu()
void menuHandler::testMenu()
{
enum optionsNumbers { Back, NumberPicker, ShowChirpy, TestAnnounce };
enum optionsNumbers { Back, NumberPicker, ShowChirpy };
static const char *optionsArray[5] = {"Back"};
static int optionsEnumArray[5] = {Back};
int options = 1;
@@ -2302,10 +2287,6 @@ void menuHandler::testMenu()
optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy";
optionsEnumArray[options++] = ShowChirpy;
#ifdef HAS_I2S
optionsArray[options] = "Test Announce";
optionsEnumArray[options++] = TestAnnounce;
#endif
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Hidden Test Menu";
@@ -2320,10 +2301,6 @@ void menuHandler::testMenu()
screen->toggleFrameVisibility("chirpy");
screen->setFrames(Screen::FOCUS_SYSTEM);
} else if (selected == TestAnnounce) {
#ifdef HAS_I2S
audioThread->readAloud("This is a test of the emergency broadcast system. This is only a test.");
#endif
} else {
menuQueue = SystemBaseMenu;
screen->runNow();
+5 -8
View File
@@ -75,7 +75,8 @@ int32_t ExternalNotificationModule::runOnce()
uint32_t delay = EXT_NOTIFICATION_MODULE_OUTPUT_MS;
bool isRtttlPlaying = rtttl::isPlaying();
#ifdef HAS_I2S
// audioThread->isPlaying() also handles actually playing the RTTTL, needs to be called in loop
// audioThread->isPlaying() also services the I2S DMA, and stays true until the
// queued audio has drained, so keep calling it from the loop
isRtttlPlaying = isRtttlPlaying || audioThread->isPlaying();
#endif
if ((nagCycleCutoff < millis()) && !isRtttlPlaying) {
@@ -281,13 +282,9 @@ void ExternalNotificationModule::stopNow()
buzzerShouldAlert = false;
nagCycleCutoff = UINT32_MAX;
#ifdef HAS_I2S
// GPIO0 is used as mclk for I2S audio and set to OUTPUT by the sound library
// T-Deck uses GPIO0 as trackball button, so restore the mode
#if defined(T_DECK) || (defined(BUTTON_PIN) && BUTTON_PIN == 0)
pinMode(0, INPUT);
#endif
#endif
// No pinMode(0, INPUT) needed any more: that undid the old sound library claiming GPIO0
// as I2S MCLK. MeshtasticI2SOut always passes the variant's explicit DAC_I2S_MCLK, which
// is never 0 on any HAS_I2S board, so GPIO0 is left alone.
}
ExternalNotificationModule::ExternalNotificationModule()
+222
View File
@@ -0,0 +1,222 @@
#pragma once
#include "configuration.h"
#if defined(ARCH_ESP32) && defined(HAS_I2S)
#include <ESP32I2SAudio.h>
/**
* BackgroundAudio's ESP32I2SAudio, corrected for Meshtastic's hardware and driven
* synchronously from AudioThread instead of from a background task.
*
* Three things upstream gets wrong for this firmware, none of which has a public
* API workaround - hence the subclass:
*
* 1. Frame format. Upstream uses I2S_STD_MSB_SLOT_DEFAULT_CONFIG (bit_shift = false,
* MSB/left-justified). ESP8266Audio's AudioOutputI2S, which this replaces, forced
* Philips (bit_shift = true). That one-BCLK shift corrupts the sign bit into the
* ES8311 codecs (tlora-pager, cardputer, meshnology-w10) and the MAX98357A-class
* amps (t-deck, t-watch-s3, dreamcatcher) - loud distortion on every board.
*
* 2. Stale DMA replay. Upstream leaves auto_clear false, and its _silenceSample is
* dead code on ESP32, so once the producer stops the circular DMA re-transmits its
* last contents forever. Four of the six HAS_I2S boards have no amp enable, so the
* speaker is permanently connected and that is a continuous audible buzz.
*
* 3. Port selection. Upstream passes I2S_NUM_AUTO. The path this replaces pinned port
* 1 (`AudioOutputI2S(1, EXTERNAL_I2S)`), and on T-LoRa Pager the codec2 AudioModule
* claims port 0 through the legacy driver (see src/modules/esp32/AudioModule.h,
* `#define I2S_PORT I2S_NUM_0`). AUTO would take port 0 when it is free and then
* codec2's i2s_driver_install fails. Do not "simplify" this back to AUTO.
*
* It also declines to start upstream's priority-2 FreeRTOS task. We feed the DMA from
* the main loop with a zero-timeout write, so we never need availableForWrite() nor
* onTransmit(). That matters for correctness, not just simplicity - see writeFrames().
*/
class MeshtasticI2SOut : public ESP32I2SAudio
{
public:
// 8 buffers x 128 frames = 1024 frames = 4096 bytes = 46.4ms at 22050Hz. Chosen to
// be byte-identical to the ESP8266Audio geometry this replaces (AudioOutputI2S
// called SetBuffers(8, 128 * 4)), so keypress feedback latency does not change.
// begin() preloads every descriptor with silence, so this figure is also the delay
// before the first sample is audible - relevant to playClick()/playChirp().
static constexpr size_t kDmaBuffers = 8;
static constexpr size_t kDmaFrames = 128;
MeshtasticI2SOut(int8_t bclk, int8_t ws, int8_t dout, int8_t mclk) : ESP32I2SAudio(bclk, ws, dout, mclk)
{
// The base constructor leaves _tx_handle uninitialized.
_tx_handle = nullptr;
setBuffers(kDmaBuffers, kDmaFrames);
}
virtual ~MeshtasticI2SOut() { end(); }
/**
* Allocate and start the I2S channel. Reimplements ESP32I2SAudio::begin() rather
* than delegating to it: upstream wraps four ESP-IDF calls in assert(), and nothing
* in the PlatformIO build defines NDEBUG (the sdkconfig
* CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE only reaches the IDF component
* build), so those are live abort() calls on a device with no console.
*/
bool begin() override
{
if (_running)
return false;
i2s_chan_config_t chanCfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
chanCfg.dma_desc_num = _buffers;
chanCfg.dma_frame_num = _bufferWords;
chanCfg.auto_clear = true; // send zeros, not the last buffer, when we stop feeding
esp_err_t err = i2s_new_channel(&chanCfg, &_tx_handle, nullptr);
if (err != ESP_OK) {
LOG_ERROR("I2S: i2s_new_channel failed: %d", err);
_tx_handle = nullptr;
return false;
}
i2s_std_config_t stdCfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG((uint32_t)_sampleRate),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
.gpio_cfg =
{
.mclk = _mclk < 0 ? I2S_GPIO_UNUSED : (gpio_num_t)_mclk,
.bclk = (gpio_num_t)_bclk,
.ws = (gpio_num_t)_ws,
.dout = (gpio_num_t)_dout,
.din = I2S_GPIO_UNUSED,
.invert_flags =
{
.mclk_inv = _mclkInv,
.bclk_inv = _bclkInv,
.ws_inv = _wsInv,
},
},
};
err = i2s_channel_init_std_mode(_tx_handle, &stdCfg);
if (err != ESP_OK) {
LOG_ERROR("I2S: i2s_channel_init_std_mode failed: %d", err);
releaseChannel();
return false;
}
// Upstream asserts the IDF honoured the requested geometry. Warn instead: a
// mismatch costs us some drain-time accuracy, not correctness.
i2s_chan_info_t info;
_totalAvailable = _buffers * _bufferWords * 4;
if (i2s_channel_get_info(_tx_handle, &info) == ESP_OK) {
if (info.total_dma_buf_size != _totalAvailable)
LOG_WARN("I2S: IDF gave %u DMA bytes, asked for %u", (unsigned)info.total_dma_buf_size,
(unsigned)_totalAvailable);
_totalAvailable = info.total_dma_buf_size;
}
// Preload silence so enabling the channel does not clock out uninitialized DMA.
int16_t silence[2] = {0, 0};
size_t loaded = 0;
do {
if (i2s_channel_preload_data(_tx_handle, silence, sizeof(silence), &loaded) != ESP_OK)
break;
} while (loaded);
err = i2s_channel_enable(_tx_handle);
if (err != ESP_OK) {
LOG_ERROR("I2S: i2s_channel_enable failed: %d", err);
releaseChannel();
return false;
}
_running = true;
return true;
}
/**
* Stop and release the channel. Must not delegate to ESP32I2SAudio::end(), which
* calls vTaskDelete(_taskHandle) - and since we never start that task _taskHandle
* is still 0, so the base implementation would delete the *calling* task.
*/
bool end() override
{
if (_running || _tx_handle) {
if (_running)
i2s_channel_disable(_tx_handle);
releaseChannel();
}
return true;
}
/**
* Hand interleaved 16-bit stereo frames to the DMA. Never blocks; returns the number
* of frames accepted, which may be fewer than asked (or zero when the ring is full).
* Callers must carry the remainder to the next pump tick.
*
* This deliberately does not consult availableForWrite(). That counter is maintained
* only by upstream's background task, fed from an ISR that posts with
* eSetValueWithoutOverwrite - which silently discards the value whenever one is
* already pending. Under a display or flash stall it therefore under-reports free
* space by whole buffers, with no diagnostic, exactly when the ring must not starve.
* A zero-timeout write asks the IDF directly, which is also what ESP8266Audio did.
*/
size_t writeFrames(const int16_t *interleavedLR, size_t frames)
{
if (!_running || !_tx_handle || !interleavedLR || !frames)
return 0;
const uint8_t *buffer = (const uint8_t *)interleavedLR;
size_t remaining = frames * kBytesPerFrame;
size_t total = 0;
// i2s_channel_write stops at a DMA buffer boundary even when later buffers are
// free, so loop until it makes no further progress.
while (remaining) {
size_t written = 0;
if (i2s_channel_write(_tx_handle, buffer, remaining, &written, 0) != ESP_OK && !written)
break;
if (!written)
break;
buffer += written;
remaining -= written;
total += written;
}
// Keep the base class's counter roughly honest for anything that reads it.
if (total)
_saturating_sub_available((uint32_t)total);
return total / kBytesPerFrame;
}
/// Milliseconds of audio a full DMA ring holds - how long to keep the amp on after
/// the last sample is queued.
uint32_t dmaDrainMs() const
{
const size_t rate = _sampleRate ? _sampleRate : 22050;
return (uint32_t)((_totalAvailable ? _totalAvailable / kBytesPerFrame : kDmaBuffers * kDmaFrames) * 1000 / rate);
}
private:
static constexpr size_t kBytesPerFrame = 4; // 16-bit stereo
/// Delete the channel and reset the state upstream forgets to, so a later begin()
/// starts clean. Upstream never nulls _tx_handle and never resets these counters,
/// which is why re-begin() misbehaves there.
void releaseChannel()
{
if (_tx_handle) {
i2s_del_channel(_tx_handle);
_tx_handle = nullptr;
}
_running = false;
_available.store(0, std::memory_order_release);
_underflows.store(0, std::memory_order_release);
_underflowed.store(false, std::memory_order_release);
_totalAvailable = 0;
_frames = 0;
_irqs = 0;
}
};
#endif // ARCH_ESP32 && HAS_I2S
-1
View File
@@ -55,7 +55,6 @@ virtualCallInConstructor
passedByValue:*/RedirectablePrint.h
internalAstError:*/CrossPlatformCryptoEngine.cpp
uninitMemberVar:*/AudioThread.h
// False positive
constVariableReference:*/Channels.cpp
constParameterPointer:*/unishox2.c
+1 -1
View File
@@ -1 +1 @@
41
42
+426
View File
@@ -0,0 +1,426 @@
#include "audio/RtttlPcm.h"
#include <cstring>
#include <string>
#include <unity.h>
#include <vector>
void setUp(void) {}
void tearDown(void) {}
// Drain a generator completely and return every frame's left-channel sample.
static std::vector<int16_t> drain(RtttlPcm &gen, size_t chunkFrames = 512)
{
std::vector<int16_t> left;
std::vector<int16_t> buf(chunkFrames * 2);
// Bound the loop so a generator bug shows up as a failure rather than a hang.
for (int guard = 0; guard < 100000 && !gen.done(); guard++) {
size_t got = gen.generate(buf.data(), chunkFrames);
for (size_t i = 0; i < got; i++) {
TEST_ASSERT_EQUAL_INT16(buf[2 * i], buf[2 * i + 1]); // must be mono-duplicated
left.push_back(buf[2 * i]);
}
if (got == 0)
break;
}
return left;
}
// --- Header parsing ---
void test_header_and_single_note_length()
{
// b=120 -> whole note 2000ms, d=4 -> 500ms -> 22050 * 500 / 1000 frames.
const char *song = "t:d=4,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(11025, pcm.size());
TEST_ASSERT_TRUE(gen.done());
}
void test_buzz_style_header_parses()
{
// The header buzz.cpp used to synthesize: d/o/b in order, whole note 1200ms.
const char *song = "tone:d=32,o=4,b=200:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
// 1200/32 = 37ms (not 37.5 - truncated), 22050 * 37 / 1000 = 815 frames.
TEST_ASSERT_EQUAL_UINT32(815, pcm.size());
}
void test_missing_header_rejected()
{
const char *song = "no colon here";
RtttlPcm gen;
TEST_ASSERT_FALSE(gen.begin(song, strlen(song)));
TEST_ASSERT_TRUE(gen.done());
}
void test_out_of_order_header_rejected()
{
// RTTTL requires d, then o, then b.
const char *song = "t:o=5,d=4,b=120:c";
RtttlPcm gen;
TEST_ASSERT_FALSE(gen.begin(song, strlen(song)));
}
void test_zero_bpm_rejected_not_divide_by_zero()
{
// Upstream divided by bpm unguarded and crashed here.
const char *song = "t:d=4,o=5,b=0:c";
RtttlPcm gen;
TEST_ASSERT_FALSE(gen.begin(song, strlen(song)));
}
void test_zero_default_duration_rejected()
{
const char *song = "t:d=0,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_FALSE(gen.begin(song, strlen(song)));
}
void test_explicit_zero_note_duration_falls_back()
{
// "0c" must not divide by zero; it falls back to the default duration.
const char *song = "t:d=4,o=5,b=120:0c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(11025, pcm.size());
}
void test_empty_body_reports_done()
{
const char *song = "t:d=4,o=5,b=120:";
RtttlPcm gen;
TEST_ASSERT_FALSE(gen.begin(song, strlen(song)));
TEST_ASSERT_TRUE(gen.done());
}
// --- Parser robustness (upstream read out of bounds on these) ---
void test_song_ending_in_digit_does_not_overrun()
{
// Upstream malloc'd without a NUL and its digit loop had no bounds check, so
// this read past the allocation. 1200... b=120 -> 2000/16 = 125ms.
const char *song = "t:d=4,o=5,b=120:16e6";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(2756, pcm.size());
}
void test_b_sharp_top_octave_does_not_read_past_table()
{
// "b#7" computes index 49 into a 49-entry table upstream. Must be clamped.
const char *song = "t:d=4,o=5,b=120:b#7";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(11025, pcm.size());
// Still a real tone, not silence.
bool sawTone = false;
for (int16_t s : pcm)
if (s != 0)
sawTone = true;
TEST_ASSERT_TRUE(sawTone);
}
void test_truncated_song_is_not_an_error()
{
// A song cut off mid-note simply ends.
const char *song = "t:d=4,o=5,b=120:c,";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(11025, pcm.size());
}
// --- Note semantics that existing ringtones depend on ---
void test_octave_clamped_low_and_high()
{
// o=1 clamps up to 4, o=8 clamps down to 7; both must still sound.
const char *low = "t:d=4,o=1,b=120:c";
const char *high = "t:d=4,o=8,b=120:c";
RtttlPcm a, b;
TEST_ASSERT_TRUE(a.begin(low, strlen(low)));
TEST_ASSERT_TRUE(b.begin(high, strlen(high)));
auto pa = drain(a);
auto pb = drain(b);
TEST_ASSERT_EQUAL_UINT32(11025, pa.size());
TEST_ASSERT_EQUAL_UINT32(11025, pb.size());
}
void test_dotted_note_after_octave()
{
// 500ms + 250ms = 750ms -> 16537 frames (truncated).
const char *song = "t:d=4,o=5,b=120:4c5.";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(16537, pcm.size());
}
void test_dotted_note_before_octave()
{
// Spec-legal placement that upstream silently desynced on.
const char *song = "t:d=4,o=5,b=120:4c.5";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(16537, pcm.size());
}
void test_rest_is_silence()
{
const char *song = "t:d=4,o=5,b=120:4p";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(11025, pcm.size());
for (int16_t s : pcm)
TEST_ASSERT_EQUAL_INT16(0, s);
}
void test_multiple_notes_sum_durations()
{
// Three quarter notes at b=120 -> 3 * 11025 frames.
const char *song = "t:d=4,o=5,b=120:c,d,e";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(3 * 11025, pcm.size());
}
// --- Waveform ---
void test_amplitude_is_attenuated_like_setgain()
{
// AudioOutputI2S::SetGain(0.2) attenuated +/-8192 to +/-1536; ESP32I2SAudio has
// no gain stage so the generator must emit the attenuated value directly.
const char *song = "t:d=4,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
for (int16_t s : pcm)
TEST_ASSERT_TRUE((s == RtttlPcm::kAmplitude) || (s == -RtttlPcm::kAmplitude));
// Phase starts at 0, which is the low half of the square wave.
TEST_ASSERT_EQUAL_INT16(-RtttlPcm::kAmplitude, pcm[0]);
}
void test_square_wave_frequency_matches_note()
{
// A 2000ms C5 (523Hz) must contain ~1046 cycles.
const char *song = "t:d=1,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(44100, pcm.size());
int rising = 0;
for (size_t i = 1; i < pcm.size(); i++)
if (pcm[i - 1] < 0 && pcm[i] > 0)
rising++;
// 523Hz * 2.0s = 1046 cycles; allow a couple for edge truncation.
TEST_ASSERT_INT_WITHIN(3, 1046, rising);
}
void test_phase_resets_each_note()
{
// Every note starts in the low half, so sample 0 of note 2 is also negative.
const char *song = "t:d=4,o=5,b=120:c,c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(2 * 11025, pcm.size());
TEST_ASSERT_EQUAL_INT16(-RtttlPcm::kAmplitude, pcm[0]);
TEST_ASSERT_EQUAL_INT16(-RtttlPcm::kAmplitude, pcm[11025]);
}
// --- Chunking behaviour the AudioThread pump relies on ---
void test_short_read_only_at_end_of_song()
{
const char *song = "t:d=4,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
int16_t buf[256 * 2];
size_t total = 0;
while (!gen.done()) {
size_t got = gen.generate(buf, 256);
total += got;
if (got < 256) {
// Only legal on the final chunk.
TEST_ASSERT_TRUE(gen.done());
}
if (got == 0)
break;
}
TEST_ASSERT_EQUAL_UINT32(11025, total);
}
void test_generate_after_done_returns_zero()
{
const char *song = "t:d=4,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
drain(gen);
TEST_ASSERT_TRUE(gen.done());
int16_t buf[16];
TEST_ASSERT_EQUAL_UINT32(0, gen.generate(buf, 8));
}
void test_reset_abandons_song()
{
const char *song = "t:d=4,o=5,b=120:c";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song, strlen(song)));
gen.reset();
TEST_ASSERT_TRUE(gen.done());
int16_t buf[16];
TEST_ASSERT_EQUAL_UINT32(0, gen.generate(buf, 8));
}
void test_oversized_song_is_truncated_not_overflowed()
{
// Longer than the internal 256-byte buffer; must clamp rather than overrun.
std::string song = "t:d=4,o=5,b=120:";
for (int i = 0; i < 200; i++)
song += "c,";
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.begin(song.c_str(), song.size()));
auto pcm = drain(gen);
TEST_ASSERT_TRUE(pcm.size() > 0);
}
// --- beginTones: the system-melody path that replaces the RTTTL round-trip ---
void test_begin_tones_uses_exact_durations()
{
// 100ms + 50ms at 22050Hz -> 2205 + 1102 frames (second one truncated).
const ToneDuration melody[] = {{440, 100}, {880, 50}};
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.beginTones(melody, 2));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(2205 + 1102, pcm.size());
}
void test_begin_tones_silent_note_is_silence()
{
// NOTE_SILENT is 1Hz; as a square wave that would be an audible thump.
const ToneDuration melody[] = {{1, 100}};
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.beginTones(melody, 1));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(2205, pcm.size());
for (int16_t s : pcm)
TEST_ASSERT_EQUAL_INT16(0, s);
}
void test_begin_tones_copies_input()
{
// playTones() passes a stack array and, now that playback is non-blocking,
// returns while the tone is still sounding. The generator must not alias it.
ToneDuration melody[] = {{440, 100}};
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.beginTones(melody, 1));
melody[0].frequency_khz = 12345;
melody[0].duration_ms = 9999;
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(2205, pcm.size());
}
void test_begin_tones_frequency_matches()
{
// 1000ms of 1000Hz -> 1000 cycles.
const ToneDuration melody[] = {{1000, 1000}};
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.beginTones(melody, 1));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(22050, pcm.size());
int rising = 0;
for (size_t i = 1; i < pcm.size(); i++)
if (pcm[i - 1] < 0 && pcm[i] > 0)
rising++;
TEST_ASSERT_INT_WITHIN(3, 1000, rising);
}
void test_begin_tones_clamps_count()
{
ToneDuration melody[64];
for (auto &t : melody) {
t.frequency_khz = 440;
t.duration_ms = 10;
}
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.beginTones(melody, 64));
auto pcm = drain(gen);
// Clamped to kMaxTones notes of 10ms -> 220 frames each.
TEST_ASSERT_EQUAL_UINT32(RtttlPcm::kMaxTones * 220, pcm.size());
}
void test_begin_tones_rejects_empty()
{
RtttlPcm gen;
TEST_ASSERT_FALSE(gen.beginTones(nullptr, 0));
const ToneDuration melody[] = {{440, 100}};
TEST_ASSERT_FALSE(gen.beginTones(melody, 0));
}
void test_begin_tones_zero_duration_is_skipped()
{
// A zero-length tone must not spin the generator.
const ToneDuration melody[] = {{440, 0}, {440, 100}};
RtttlPcm gen;
TEST_ASSERT_TRUE(gen.beginTones(melody, 2));
auto pcm = drain(gen);
TEST_ASSERT_EQUAL_UINT32(2205, pcm.size());
}
void setup()
{
UNITY_BEGIN();
RUN_TEST(test_header_and_single_note_length);
RUN_TEST(test_buzz_style_header_parses);
RUN_TEST(test_missing_header_rejected);
RUN_TEST(test_out_of_order_header_rejected);
RUN_TEST(test_zero_bpm_rejected_not_divide_by_zero);
RUN_TEST(test_zero_default_duration_rejected);
RUN_TEST(test_explicit_zero_note_duration_falls_back);
RUN_TEST(test_empty_body_reports_done);
RUN_TEST(test_song_ending_in_digit_does_not_overrun);
RUN_TEST(test_b_sharp_top_octave_does_not_read_past_table);
RUN_TEST(test_truncated_song_is_not_an_error);
RUN_TEST(test_octave_clamped_low_and_high);
RUN_TEST(test_dotted_note_after_octave);
RUN_TEST(test_dotted_note_before_octave);
RUN_TEST(test_rest_is_silence);
RUN_TEST(test_multiple_notes_sum_durations);
RUN_TEST(test_amplitude_is_attenuated_like_setgain);
RUN_TEST(test_square_wave_frequency_matches_note);
RUN_TEST(test_phase_resets_each_note);
RUN_TEST(test_short_read_only_at_end_of_song);
RUN_TEST(test_generate_after_done_returns_zero);
RUN_TEST(test_reset_abandons_song);
RUN_TEST(test_oversized_song_is_truncated_not_overflowed);
RUN_TEST(test_begin_tones_uses_exact_durations);
RUN_TEST(test_begin_tones_silent_note_is_silence);
RUN_TEST(test_begin_tones_copies_input);
RUN_TEST(test_begin_tones_frequency_matches);
RUN_TEST(test_begin_tones_clamps_count);
RUN_TEST(test_begin_tones_rejects_empty);
RUN_TEST(test_begin_tones_zero_duration_is_skipped);
exit(UNITY_END());
}
void loop() {}
+2 -4
View File
@@ -12,10 +12,8 @@ build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
[env:dreamcatcher-2206]
extends = esp32s3_base
@@ -44,10 +44,8 @@ build_flags = ${esp32s3_base.build_flags} -Os
lib_deps = ${esp32s3_base.lib_deps}
${device-ui_base.lib_deps}
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
# renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534
hideakitai/TCA9534@0.1.1
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
@@ -17,7 +17,5 @@ lib_deps =
https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
# renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver
https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
@@ -35,7 +35,5 @@ lib_deps =
; ES8311 audio codec + I2S notification tones (HAS_I2S)
; renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver
https://github.com/pschatzmann/arduino-audio-driver/archive/v0.3.0.zip
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
@@ -36,10 +36,8 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
https://github.com/mverch67/LovyanGFX/archive/9abe502add013f392a1898d7dc48d65ddc112754.zip
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
custom_sdkconfig =
${esp32s3_base.custom_sdkconfig}
+2 -4
View File
@@ -30,10 +30,8 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.26
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
[env:t-deck-tft]
extends = env:t-deck
+2 -4
View File
@@ -27,7 +27,5 @@ lib_deps = ${esp32s3_base.lib_deps}
lewisxhe/SensorLib@0.3.4
# renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
adafruit/Adafruit DRV2605 Library@1.2.4
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
+2 -4
View File
@@ -34,10 +34,8 @@ build_flags = ${esp32s3_base.build_flags}
lib_deps = ${esp32s3_base.lib_deps}
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.26
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
earlephilhower/ESP8266SAM@1.1.0
# renovate: datasource=github-tags depName=BackgroundAudio packageName=earlephilhower/BackgroundAudio
https://github.com/earlephilhower/BackgroundAudio/archive/refs/tags/1.4.4.zip
# renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
adafruit/Adafruit DRV2605 Library@1.2.4
# renovate: datasource=custom.pio depName=PCF8563 packageName=lewisxhe/library/PCF8563_Library