#include "rtttl.h" #include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" namespace esphome::rtttl { static const char *const TAG = "rtttl"; static constexpr uint8_t SONG_NAME_LENGTH_LIMIT = 64; static constexpr uint8_t SEMITONES_IN_OCTAVE = 12; static constexpr uint8_t MIN_OCTAVE = 4; static constexpr uint8_t MAX_OCTAVE = 7; static constexpr uint8_t DEFAULT_BPM = 63; // Default beats per minute // These values can also be found as constants in the Tone library (Tone.h) static constexpr uint16_t 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 uint8_t NOTES_COUNT = static_cast(sizeof(NOTES) / sizeof(NOTES[0])); static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10; #ifdef USE_SPEAKER static constexpr uint16_t SAMPLE_BUFFER_SIZE = 2048; static constexpr uint16_t SAMPLE_RATE = 16000; inline double deg2rad(double degrees) { static constexpr double PI_ON_180 = M_PI / 180.0; return degrees * PI_ON_180; } #endif // USE_SPEAKER #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE // RTTTL state strings indexed by State enum (0-4): STOPPED, INIT, STARTING, RUNNING, STOPPING, plus UNKNOWN fallback PROGMEM_STRING_TABLE(RtttlStateStrings, "State::STOPPED", "State::INIT", "State::STARTING", "State::RUNNING", "State::STOPPING", "UNKNOWN"); static const LogString *state_to_string(State state) { return RtttlStateStrings::get_log_str(static_cast(state), RtttlStateStrings::LAST_INDEX); } #endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE static uint8_t note_index_from_char(char note) { switch (note) { case 'c': return 1; // 'c#': 2 case 'd': return 3; // 'd#': 4 case 'e': return 5; case 'f': return 6; // 'f#': 7 case 'g': return 8; // 'g#': 9 case 'a': return 10; // 'a#': 11 // Support both 'b' (English notation for B natural) and 'h' (German notation for B natural) case 'b': case 'h': return 12; case 'p': default: return 0; } } void Rtttl::dump_config() { ESP_LOGCONFIG(TAG, "Rtttl:\n" " Gain: %f", this->gain_); } void Rtttl::loop() { if (this->state_ == State::STOPPED) { this->disable_loop(); return; } #ifdef USE_OUTPUT if (this->output_ != nullptr && millis() - this->last_note_start_time_ < this->note_duration_) { return; } #endif // USE_OUTPUT #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { if (this->state_ == State::STOPPING) { if (this->speaker_->is_stopped()) { this->set_state_(State::STOPPED); } else { return; } } else if (this->state_ == State::INIT) { if (this->speaker_->is_stopped()) { audio::AudioStreamInfo audio_stream_info = audio::AudioStreamInfo(16, 1, SAMPLE_RATE); this->speaker_->set_audio_stream_info(audio_stream_info); this->speaker_->set_volume(this->gain_); this->speaker_->start(); this->set_state_(State::STARTING); } } else if (this->state_ == State::STARTING) { if (this->speaker_->is_running()) { this->set_state_(State::RUNNING); } } if (!this->speaker_->is_running()) { return; } if (this->samples_sent_ != this->samples_count_) { int16_t sample[SAMPLE_BUFFER_SIZE]; uint16_t sample_index = 0; double rem = 0.0; while (sample_index < SAMPLE_BUFFER_SIZE && this->samples_sent_ < this->samples_count_) { // Try and send out the remainder of the existing note, one per `loop()` if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note rem = ((this->samples_sent_ << 10) % this->samples_per_wave_) * (360.0 / this->samples_per_wave_); sample[sample_index] = INT16_MAX * sin(deg2rad(rem)); } else { sample[sample_index] = 0; } this->samples_sent_++; sample_index++; } if (sample_index > 0) { size_t bytes = sample_index * sizeof(int16_t); size_t sent_bytes = this->speaker_->play((uint8_t *) (&sample), bytes); size_t samples_sent = sent_bytes / sizeof(int16_t); if (samples_sent != sample_index) { this->samples_sent_ -= (sample_index - samples_sent); } return; } } } #endif // USE_SPEAKER // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case while (this->position_ < this->rtttl_.length()) { char c = this->rtttl_[this->position_]; if (c != ',' && c != ' ') break; this->position_++; } if (this->position_ >= this->rtttl_.length()) { this->finish_(); return; } // First, get note duration, if available uint8_t note_denominator = this->get_integer_(); if (note_denominator) { this->note_duration_ = this->wholenote_duration_ / note_denominator; } else { // We will need to check if we are a dotted note after this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_; } uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]); this->position_++; // Now, get optional '#' sharp if (this->rtttl_[this->position_] == '#') { note_index_in_octave++; this->position_++; } // Now, get scale uint8_t scale = this->get_integer_(); if (scale == 0) { scale = this->default_octave_; } if (scale < MIN_OCTAVE || scale > MAX_OCTAVE) { ESP_LOGE(TAG, "Octave must be between %d and %d (it is %d)", MIN_OCTAVE, MAX_OCTAVE, scale); this->finish_(); return; } // Now, get optional '.' dotted note if (this->rtttl_[this->position_] == '.') { this->note_duration_ += this->note_duration_ / 2; // Duration +50% this->position_++; } bool need_note_gap = false; // Now play the note if (note_index_in_octave == 0) { this->output_freq_ = 0; ESP_LOGVV(TAG, "Waiting: %dms", this->note_duration_); } else { uint8_t note_index = (scale - MIN_OCTAVE) * SEMITONES_IN_OCTAVE + note_index_in_octave; if (note_index >= NOTES_COUNT) { ESP_LOGE(TAG, "Note out of range (note: %d, scale: %d, index: %d, max: %d)", note_index_in_octave, scale, note_index, NOTES_COUNT); this->finish_(); return; } uint16_t freq = NOTES[note_index]; need_note_gap = freq == this->output_freq_; // Add small silence gap between same note this->output_freq_ = freq; ESP_LOGVV(TAG, "Playing note: %d for %dms", note_index_in_octave, this->note_duration_); } #ifdef USE_OUTPUT if (this->output_ != nullptr) { if (this->output_freq_ == 0) { this->output_->set_level(0.0); } else { if (need_note_gap && this->note_duration_ > REPEATING_NOTE_GAP_MS) { this->output_->set_level(0.0); delay(REPEATING_NOTE_GAP_MS); this->note_duration_ -= REPEATING_NOTE_GAP_MS; } this->output_->update_frequency(this->output_freq_); this->output_->set_level(this->gain_); } } #endif // USE_OUTPUT #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { this->samples_sent_ = 0; this->samples_gap_ = 0; this->samples_per_wave_ = 0; this->samples_count_ = (SAMPLE_RATE * this->note_duration_) / 1000; if (need_note_gap) { this->samples_gap_ = (SAMPLE_RATE * REPEATING_NOTE_GAP_MS) / 1000; } if (this->output_freq_ != 0) { // Make sure there is enough samples to add a full last sinus. uint32_t samples_wish = this->samples_count_; this->samples_per_wave_ = (SAMPLE_RATE << 10) / this->output_freq_; uint16_t division = ((this->samples_count_ << 10) / this->samples_per_wave_) + 1; this->samples_count_ = (division * this->samples_per_wave_) >> 10; ESP_LOGVV(TAG, "Calc play time: wish: %" PRIu32 " gets: %" PRIu32 " (div: %d spw: %" PRIu32 ")", samples_wish, this->samples_count_, division, this->samples_per_wave_); } // Convert from frequency in Hz to high and low samples in fixed point } #endif // USE_SPEAKER this->last_note_start_time_ = millis(); } void Rtttl::play(std::string rtttl) { if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { size_t pos = this->rtttl_.find(':'); size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); return; } this->rtttl_ = std::move(rtttl); this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR; this->default_octave_ = DEFAULT_OCTAVE; this->note_duration_ = 0; uint16_t bpm = DEFAULT_BPM; uint16_t num; // Used for: default note-denominator, default octave, BPM // Get name this->position_ = this->rtttl_.find(':'); if (this->position_ == std::string::npos) { ESP_LOGE(TAG, "Unable to determine name; missing ':'"); return; } if (this->position_ >= SONG_NAME_LENGTH_LIMIT) { ESP_LOGE(TAG, "Name is too long: length=%u, limit=%u", static_cast(this->position_), static_cast(SONG_NAME_LENGTH_LIMIT)); return; } ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); // Get default duration this->position_ = this->rtttl_.find("d=", this->position_); if (this->position_ == std::string::npos) { ESP_LOGE(TAG, "Missing 'd='"); return; } this->position_ += 2; num = this->get_integer_(); if (num == 1 || num == 2 || num == 4 || num == 8 || num == 16 || num == 32) { this->default_note_denominator_ = num; } else { ESP_LOGE(TAG, "Invalid default duration: %d", num); return; } // Get default octave this->position_ = this->rtttl_.find("o=", this->position_); if (this->position_ == std::string::npos) { ESP_LOGE(TAG, "Missing 'o="); return; } this->position_ += 2; num = this->get_integer_(); if (num >= MIN_OCTAVE && num <= MAX_OCTAVE) { this->default_octave_ = num; } else { ESP_LOGE(TAG, "Invalid default octave: %d", num); return; } // Get BPM this->position_ = this->rtttl_.find("b=", this->position_); if (this->position_ == std::string::npos) { ESP_LOGE(TAG, "Missing b="); return; } this->position_ += 2; num = this->get_integer_(); if (num >= 4) { // Below 4 is not realistic and would cause a integer overflow bpm = num; } else { ESP_LOGE(TAG, "Invalid BPM: %d", num); return; } this->position_ = this->rtttl_.find(':', this->position_); if (this->position_ == std::string::npos) { ESP_LOGE(TAG, "Missing second ':'"); return; } this->position_++; // BPM usually expresses the number of quarter notes per minute this->wholenote_duration_ = 60 * 1000L * 4 / bpm; // This is the time for whole note (in milliseconds) this->output_freq_ = 0; this->last_note_start_time_ = millis(); this->note_duration_ = 1; #ifdef USE_OUTPUT if (this->output_ != nullptr) { this->set_state_(State::RUNNING); } #endif // USE_OUTPUT #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { this->set_state_(State::INIT); this->samples_sent_ = 0; this->samples_count_ = 0; } #endif // USE_SPEAKER } void Rtttl::stop() { #ifdef USE_OUTPUT if (this->output_ != nullptr) { this->output_->set_level(0.0); this->set_state_(State::STOPPED); } #endif // USE_OUTPUT #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { if (this->speaker_->is_running()) { this->speaker_->stop(); } this->set_state_(State::STOPPING); } #endif // USE_SPEAKER this->position_ = this->rtttl_.length(); this->note_duration_ = 0; } void Rtttl::finish_() { ESP_LOGV(TAG, "Rtttl::finish_()"); #ifdef USE_OUTPUT if (this->output_ != nullptr) { this->output_->set_level(0.0); this->set_state_(State::STOPPED); } #endif // USE_OUTPUT #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { int16_t sample[2] = {0, 0}; this->speaker_->play((uint8_t *) (&sample), sizeof(sample)); this->speaker_->finish(); this->set_state_(State::STOPPING); } #endif // USE_SPEAKER // Ensure no more notes are played in case finish_() is called for an error. this->position_ = this->rtttl_.length(); this->note_duration_ = 0; } void Rtttl::set_state_(State state) { State old_state = this->state_; this->state_ = state; ESP_LOGV(TAG, "State changed from %s to %s", LOG_STR_ARG(state_to_string(old_state)), LOG_STR_ARG(state_to_string(state))); // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); this->on_finished_playback_callback_.call(); ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); } } } // namespace esphome::rtttl