From fc921d0a96d2c9806db26f2f32871750cec12675 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:25:09 +1100 Subject: [PATCH] [rtttl] Fade speaker notes in and out to stop clicks (#22075) --- esphome/components/rtttl/rtttl.cpp | 14 ++++++++++++- esphome/components/rtttl/rtttl.h | 3 +++ tests/components/rtttl/test_rtttl_parser.cpp | 22 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index db503c3b8be..03a93873f48 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -27,6 +27,9 @@ static constexpr uint8_t NOTES_COUNT = static_cast(sizeof(NOTES) / size static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10; +// Each note fades in and out over 2^5 samples (2 ms at 16 kHz) +static constexpr uint8_t NOTE_FADE_BITS = 5; + // The first quarter of a sine wave at full scale: round(32767 * sin(i * pi / 128)) static constexpr uint8_t QUARTER_SINE_BITS = 6; static constexpr uint16_t QUARTER_SINE[(1 << QUARTER_SINE_BITS) + 1] PROGMEM = { @@ -131,7 +134,9 @@ void Rtttl::loop() { // Try and send out the remainder of the existing note, one per `loop()` if (this->phase_step_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note // The multiplication wraps at exactly one full cycle of the phase - sample[sample_index] = sine_sample(this->samples_sent_ * this->phase_step_); + sample[sample_index] = + fade_sample(sine_sample(this->samples_sent_ * this->phase_step_), + this->samples_sent_ - this->samples_gap_, this->samples_count_ - 1 - this->samples_sent_); } else { sample[sample_index] = 0; } @@ -282,6 +287,13 @@ int16_t Rtttl::sine_sample(uint32_t phase) { return static_cast((phase >> 31) != 0 ? -value : value); } +int16_t Rtttl::fade_sample(int16_t value, uint32_t from_start, uint32_t to_end) { + const uint32_t edge = std::min(from_start, to_end); + if (edge >= (1U << NOTE_FADE_BITS)) + return value; + return static_cast((value * static_cast(edge)) >> NOTE_FADE_BITS); +} + void Rtttl::play(std::string rtttl) { if (this->is_busy_()) { return; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 7a8ab898fc5..c2a26bc658e 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -102,6 +102,9 @@ class Rtttl final : public Component { void set_state_(State state); /// The value of a full scale sine wave at phase, where the full uint32_t range is one cycle. static int16_t sine_sample(uint32_t phase); + /// Scales a sample near either end of a note, so the note does not click when the wave starts or stops away from + /// zero. from_start and to_end count the samples between this one and each end of the note. + static int16_t fade_sample(int16_t value, uint32_t from_start, uint32_t to_end); /// The song being played; points at owned_song_ or at static storage. const char *song_{nullptr}; diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp index e3ee18af27e..5b84a5c2b90 100644 --- a/tests/components/rtttl/test_rtttl_parser.cpp +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -21,6 +21,9 @@ class RtttlParserAccess { static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } static int16_t sine(uint32_t phase) { return Rtttl::sine_sample(phase); } + static int16_t fade(int16_t value, uint32_t from_start, uint32_t to_end) { + return Rtttl::fade_sample(value, from_start, to_end); + } }; using Access = RtttlParserAccess; @@ -45,6 +48,25 @@ TEST(RtttlSine, HitsTheQuarterPointsOfTheWave) { EXPECT_NEAR(Access::sine(0xFFFFFFFF), 0, 1); } +TEST(RtttlFade, FirstAndLastSamplesOfANoteAreSilent) { + EXPECT_EQ(Access::fade(INT16_MAX, 0, 1000), 0); + EXPECT_EQ(Access::fade(INT16_MIN, 1000, 0), 0); +} + +TEST(RtttlFade, RampsAcrossTheFade) { + EXPECT_EQ(Access::fade(3200, 16, 1000), 1600); + EXPECT_EQ(Access::fade(3200, 1000, 16), 1600); + EXPECT_EQ(Access::fade(-3200, 16, 1000), -1600); + EXPECT_EQ(Access::fade(3200, 31, 1000), 3100); +} + +TEST(RtttlFade, LeavesTheMiddleOfANoteUnchanged) { + EXPECT_EQ(Access::fade(INT16_MAX, 32, 32), INT16_MAX); + EXPECT_EQ(Access::fade(INT16_MIN, 1000, 1000), INT16_MIN); +} + +TEST(RtttlFade, UsesTheNearerEndOfAVeryShortNote) { EXPECT_EQ(Access::fade(3200, 16, 8), 800); } + TEST(RtttlParser, StaticSongParsesTheHeader) { Rtttl player; player.play(StaticSong{SONG});