mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-21 22:05:26 -04:00
Migrate from ESP8266Audio to BackgroundAudio
This commit is contained in:
1 parent
2a20ee5954
commit
a80181d55f
19 files changed
+1394
-205
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -55,7 +55,6 @@ virtualCallInConstructor
|
||||
passedByValue:*/RedirectablePrint.h
|
||||
|
||||
internalAstError:*/CrossPlatformCryptoEngine.cpp
|
||||
uninitMemberVar:*/AudioThread.h
|
||||
// False positive
|
||||
constVariableReference:*/Channels.cpp
|
||||
constParameterPointer:*/unishox2.c
|
||||
|
||||
@@ -1 +1 @@
|
||||
41
|
||||
42
|
||||
@@ -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() {}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user