diff --git a/src/AudioThread.cpp b/src/AudioThread.cpp new file mode 100644 index 0000000000..2335f65e05 --- /dev/null +++ b/src/AudioThread.cpp @@ -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(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 diff --git a/src/AudioThread.h b/src/AudioThread.h index 3a44bf8239..df4039a86d 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -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 #ifdef HAS_I2S -#include -#include -#include -#include -// 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(new AudioFileSourcePROGMEM(data, len)); - i2sRtttl = std::unique_ptr(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(new ESP8266SAM); - sam->Say(audioOut.get(), text); - setCPUFast(false); -#ifdef AUDIO_AMP_ENABLE - AUDIO_AMP_ENABLE(false); -#endif - } + CallbackObserver preflightSleepObserver = + CallbackObserver(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(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 i2sRtttl = nullptr; - std::unique_ptr audioOut = nullptr; + /// Frames handed to the DMA per write. One DMA buffer's worth. + static constexpr size_t kStagingFrames = 128; - std::unique_ptr 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 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 diff --git a/src/audio/RtttlPcm.cpp b/src/audio/RtttlPcm.cpp new file mode 100644 index 0000000000..17630db0bf --- /dev/null +++ b/src/audio/RtttlPcm.cpp @@ -0,0 +1,338 @@ +#include "RtttlPcm.h" + +#include + +// 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; +} diff --git a/src/audio/RtttlPcm.h b/src/audio/RtttlPcm.h new file mode 100644 index 0000000000..7970f44d2b --- /dev/null +++ b/src/audio/RtttlPcm.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +// 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; +}; diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp index 42b9900bf5..3b3a305746 100644 --- a/src/buzz/buzz.cpp +++ b/src/buzz/buzz.cpp @@ -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 #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 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 diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6625d4303c..dcb6d99719 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -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(); diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 276c382a1f..38e45cff0e 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -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() diff --git a/src/platform/esp32/MeshtasticI2SOut.h b/src/platform/esp32/MeshtasticI2SOut.h new file mode 100644 index 0000000000..bc0bf899de --- /dev/null +++ b/src/platform/esp32/MeshtasticI2SOut.h @@ -0,0 +1,222 @@ +#pragma once + +#include "configuration.h" + +#if defined(ARCH_ESP32) && defined(HAS_I2S) + +#include + +/** + * 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 diff --git a/suppressions.txt b/suppressions.txt index ca94c269d9..bd32e829d8 100644 --- a/suppressions.txt +++ b/suppressions.txt @@ -55,7 +55,6 @@ virtualCallInConstructor passedByValue:*/RedirectablePrint.h internalAstError:*/CrossPlatformCryptoEngine.cpp -uninitMemberVar:*/AudioThread.h // False positive constVariableReference:*/Channels.cpp constParameterPointer:*/unishox2.c diff --git a/test/native-suite-count b/test/native-suite-count index 87523dd7a0..d81cc0710e 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -41 +42 diff --git a/test/test_rtttl_pcm/test_main.cpp b/test/test_rtttl_pcm/test_main.cpp new file mode 100644 index 0000000000..a578bfd3dc --- /dev/null +++ b/test/test_rtttl_pcm/test_main.cpp @@ -0,0 +1,426 @@ +#include "audio/RtttlPcm.h" +#include +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// Drain a generator completely and return every frame's left-channel sample. +static std::vector drain(RtttlPcm &gen, size_t chunkFrames = 512) +{ + std::vector left; + std::vector 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() {} diff --git a/variants/esp32s3/dreamcatcher/platformio.ini b/variants/esp32s3/dreamcatcher/platformio.ini index 64d1b993db..5c91c4dc00 100644 --- a/variants/esp32s3/dreamcatcher/platformio.ini +++ b/variants/esp32s3/dreamcatcher/platformio.ini @@ -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 diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 70858f8a0c..8f328b077a 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -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 diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index d6d91d4c13..f821aead05 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -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 diff --git a/variants/esp32s3/meshnology-w10/platformio.ini b/variants/esp32s3/meshnology-w10/platformio.ini index 27194a06c7..6a877b0756 100644 --- a/variants/esp32s3/meshnology-w10/platformio.ini +++ b/variants/esp32s3/meshnology-w10/platformio.ini @@ -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 diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini index b750e7decb..e81961ec71 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini +++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini @@ -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} diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 04efbb2140..c37dd5ae8b 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -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 diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index 1565a3f7e2..25cf3085c3 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -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 diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index ad326220b8..e1dc3e73f9 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -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