From 95f4b288db2ea5538faf9fa0be4549dc21d8155a Mon Sep 17 00:00:00 2001 From: Abubakr Eirabie Date: Sun, 11 Oct 2026 09:33:57 -0400 Subject: [PATCH 1/4] Calibrate Voice Pyramid volume and preserve saved levels --- CMakeLists.txt | 13 ++ docs/development/architecture.md | 5 + .../atom-volume-calibration-2026-10-11.md | 133 ++++++++++++ .../components/openathan/openathan.cpp | 4 +- .../esphome/components/openathan/openathan.h | 2 + .../openathan_audio/partition_audio.cpp | 15 +- .../openathan_audio/partition_audio.h | 3 + .../openathan_validation/validation.cpp | 2 +- .../openathan_validation/validation.h | 2 + .../components/voice_pyramid/README.md | 5 + .../voice_pyramid/voice_pyramid.cpp | 2 + .../components/voice_pyramid/voice_pyramid.h | 3 + .../components/voice_pyramid_audio/README.md | 18 ++ .../voice_pyramid_audio/__init__.py | 38 ++++ .../components/voice_pyramid_audio/audio.h | 81 ++++++++ .../components/voice_pyramid_audio/volume.h | 36 ++++ .../voice_pyramid_audio/volume_store.h | 60 ++++++ firmware/esphome/openathan.yaml | 1 + .../hardware/voice-pyramid-volume.yaml | 32 +++ .../packages/hardware/voice-pyramid.yaml | 2 + firmware/esphome/scheduler/VALIDATION.md | 7 + firmware/esphome/scheduler/development.yaml | 3 +- firmware/esphome/scheduler/validation.yaml | 7 +- tests/audio_adapter_tests.cpp | 13 ++ .../esphome/components/switch/switch.h | 9 + tests/pyramid_volume_tests.cpp | 193 ++++++++++++++++++ tests/stubs/esphome/components/i2c/i2c.h | 12 ++ tests/stubs/esphome/core/component.h | 4 +- tests/test_scheduler_config.py | 15 ++ tests/test_waveshare.py | 18 +- .../esphome/components/es8311/es8311.h | 13 +- .../openathan_audio/partition_audio.h | 14 +- web/device-ui/README.md | 6 + 33 files changed, 754 insertions(+), 17 deletions(-) create mode 100644 docs/development/atom-volume-calibration-2026-10-11.md create mode 100644 firmware/esphome/components/voice_pyramid_audio/README.md create mode 100644 firmware/esphome/components/voice_pyramid_audio/__init__.py create mode 100644 firmware/esphome/components/voice_pyramid_audio/audio.h create mode 100644 firmware/esphome/components/voice_pyramid_audio/volume.h create mode 100644 firmware/esphome/components/voice_pyramid_audio/volume_store.h create mode 100644 firmware/esphome/packages/hardware/voice-pyramid-volume.yaml create mode 100644 tests/pyramid_stubs/esphome/components/switch/switch.h create mode 100644 tests/pyramid_volume_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 859ba91..f3b145e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,19 @@ target_include_directories(waveshare_adapter_tests PRIVATE tests/waveshare_stubs firmware lib/openathan-core/include) add_test(NAME waveshare_power_and_backlight COMMAND waveshare_adapter_tests) +foreach(mode IN ITEMS production isolated) + add_executable(pyramid_volume_${mode}_tests tests/pyramid_volume_tests.cpp + firmware/esphome/components/voice_pyramid/voice_pyramid.cpp + firmware/esphome/components/openathan/nvs_settings_store.cpp) + target_include_directories(pyramid_volume_${mode}_tests PRIVATE tests/pyramid_stubs tests/waveshare_stubs tests/stubs + firmware lib/openathan-core/include) + target_link_libraries(pyramid_volume_${mode}_tests PRIVATE openathan_core) + if(mode STREQUAL "isolated") + target_compile_definitions(pyramid_volume_${mode}_tests PRIVATE OPENATHAN_PROVISIONING_TEST_STORAGE) + endif() + add_test(NAME pyramid_volume_${mode} COMMAND pyramid_volume_${mode}_tests) +endforeach() + add_executable(rtc_adapter_tests tests/rtc_adapter_tests.cpp firmware/esphome/components/waveshare_box_rtc/rtc.cpp) target_include_directories(rtc_adapter_tests PRIVATE tests/rtc_stubs tests/stubs firmware) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index d17da7b..4936383 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -110,6 +110,11 @@ and is not a buildable release configuration. `DeviceSettings` and `SettingsService` own complete snapshots and revision checks. The ESP32 adapter stores one checksummed settings blob at `openathan/settings`; the existing `openathan/scheduler` consumption/skip record remains separate. +The calibrated Voice Pyramid product adapter retains a backward-readable +settings mirror there and a matching companion at `pyr_volume` for its wider +volume scale. It supplies the generic `SettingsStore` and `Playback` interfaces; +the core has no dependency on this hardware. See the +[volume compatibility contract](atom-volume-calibration-2026-10-11.md). Successful saves commit before application or acknowledgment. A write failure latches settings writes and automatic playback off for that boot without erasing history or stopping current audio. diff --git a/docs/development/atom-volume-calibration-2026-10-11.md b/docs/development/atom-volume-calibration-2026-10-11.md new file mode 100644 index 0000000..acc597d --- /dev/null +++ b/docs/development/atom-volume-calibration-2026-10-11.md @@ -0,0 +1,133 @@ +# Atom / Voice Pyramid volume calibration — 2026-10-11 + +The AtomS3R / Voice Pyramid product profile previously sent the slider directly +to the ES8311 with a 60% ceiling. Under the pinned ESPHome mapping, even 100% +selected register 153, or -19 dB. The calibrated profile reaches register 191 +(`0xBF`, 0 dB) at 100%, with software attenuation for everyday volume control. +This removes 19 dB of digital attenuation at the top; audible output and +distortion at the new maximum remain untested on the physical Pyramid. + +## Gain and startup contract + +The curve is `-96.5 × (1 - percent / 100)` dB for percentages 1–100. Zero uses +both software mute and the codec mute. From 50% upwards the codec stays at +0 dB. Lower percentages add codec attenuation to preserve the old very quiet +settings beyond ESPHome's -49 dB software range. Neither stage amplifies samples +or sets positive codec gain. DAC dynamic-range compression is explicitly disabled. + +The media player starts muted and the GPIO expander's amplifier enable starts +off. The adapter waits for the audio partition, clock and amplifier initialization. +For every volume change it disables the amplifier, requests software mute and +waits for readback. Only then does it apply codec mute, DRC and gain settings, +verify the gain/DRC registers, and request the final software level. Amplifier +enable and new playback remain gated until that level reads back. Codec write or +readback failure leaves the adapter failed with the amplifier disabled; dropped +media-player commands are retried while gated. A live adjustment briefly mutes +the output without stopping or replaying the recording. + +The mappings are checked against the [ES8311 register guide](https://files.waveshare.com/wiki/common/ES8311.user.Guide.pdf) +and pinned ESPHome 2026.9.0 sources for the +[ES8311](https://github.com/esphome/esphome/blob/2026.9.0/esphome/components/es8311/es8311.cpp), +[I2S speaker](https://github.com/esphome/esphome/blob/2026.9.0/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp) +and [media player](https://github.com/esphome/esphome/blob/2026.9.0/esphome/components/speaker/media_player/speaker_media_player.cpp). +[M5Stack's A167 documentation](https://docs.m5stack.com/en/atom/Voice_Pyramid) +identifies its ES8311 and AW87559. The verified clock and amplifier register +initialization are retained; this change does not introduce an amplifier boost. + +## Saved levels and older firmware + +On upgrade, each saved legacy percentage is converted to the greatest integer +on the new curve that is no louder than its old register gain. The rounding +difference is less than 0.966 dB. Zero remains muted; a positive legacy setting +remains positive. Fresh devices start at 56%, equivalent to or slightly quieter +than the old 70% default. + +| Legacy setting | Calibrated setting | Legacy gain | Calibrated gain | +| --- | ---: | ---: | ---: | +| 20% | 16% | -80.5 dB | -81.06 dB | +| 70% | 56% | -42 dB | -42.46 dB | +| 80% | 64% | -34.5 dB | -34.74 dB | +| 100% | 80% | -19 dB | -19.3 dB | + +The existing CRC-protected v1 `settings` record remains readable by older +firmware. A second v1 record at `pyr_volume` stores the calibrated snapshot. +It is authoritative only when its complete derived legacy mirror, including +revision and all other settings, matches `settings`. Saves commit the quiet +legacy mirror first, then the calibrated record. A power cut can retain the old +snapshot, the complete new snapshot, or the new snapshot with a quieter mirrored +volume; it cannot assemble unrelated fields from the two records. + +Returning to older firmware loads the mirror, capped at that firmware's existing +maximum. If older firmware saves settings, the next calibrated boot recognizes +the mismatch and migrates its current snapshot again. Boot migration writes +neither settings nor prayer history. It advances the exposed revision once to +reject an old browser's stale save. Corrupt companion records and exhausted +migration revisions block settings and automatic playback while retaining NVS. + +The scheduler's synthetic validation profile uses this same store in +`oa_validation`; product and provisioning profiles retain their existing +production or isolated namespaces. Consumption, skips, shared audio and other +preferences are independent of the companion record. The generic hardware +package retains its ordinary ESPHome DAC binding; calibration is selected by the +separate product package. Core prayer logic and the public website do not acquire +a hardware dependency. + +## Automated validation + +All 20 CTests passed with UndefinedBehaviorSanitizer, including production and +isolated calibration fixtures. They cover every legacy percentage, effective +two-stage gain, stale revisions, companion/mirror matching, older-firmware saves, +power cuts at both writes and commits, early persistence, storage corruption, +namespace separation, dropped volume commands and codec mute/gain/DRC failures. +The full pinned Python suite passed all 113 tests, including product volume +routing, synthetic validation storage and generic custom hardware configuration. +All 488 Chromium/WebKit cases passed with Node 24.19.0 and Playwright 1.62.1. +All 13 ESPHome schemas, the pinned website installer contract and the production +upgrade runtime/resolved HTTP regressions passed. Browser endpoints and NVS/I2C +fixtures are simulated; these checks do not establish physical output quality. + +Matched OTA measurements use main revision `e4304465` before this change and +the calibrated source afterwards. Both snapshots use identical compile-only +fixtures, ESPHome 2026.9.0, ESP-IDF 5.5.5 and the pinned compiler. These source +snapshots do not carry a release Git identity; the measurements compare the +same generated identity in both builds and are not published-release sizes. + +| Variant | Before OTA bytes | After OTA bytes | Delta | Remaining 1.5 MiB budget | +| --- | ---: | ---: | ---: | ---: | +| Atom reference | 1,277,360 | 1,281,568 | +4,208 | 291,296 | +| Atom real schedule | 1,121,296 | 1,125,488 | +4,192 | 447,376 | +| Atom synthetic timetable | 1,138,832 | 1,143,040 | +4,208 | 429,824 | +| Atom provisioning validation | 1,278,800 | 1,283,056 | +4,256 | 289,808 | +| Atom upgrade qualification | 1,284,336 | 1,288,656 | +4,320 | 284,208 | +| Atom startup-failure qualification | 1,284,336 | 1,288,656 | +4,320 | 284,208 | +| Waveshare production | 1,284,752 | 1,284,912 | +160 | 287,952 | +| Waveshare audio | 1,213,344 | 1,213,504 | +160 | 359,360 | +| Waveshare display | 1,263,312 | 1,263,472 | +160 | 309,392 | +| Waveshare diagnostics | 1,350,224 | 1,350,384 | +160 | 222,480 | + +All ten variants passed dependency, partition, image and capacity checks. +The Waveshare mapping is unchanged; its 160-byte delta comes from the shared +audio/default-volume bridge changes. + +The 1.5 MiB application limit, both 2 MiB OTA slots and the separate 3.5 MiB +shared audio partition are retained. Compile-only fixtures must never be +installed or played. The adapter adds no audio task, sample buffer or dependency. +Reference static RAM increases by 152 bytes; runtime heap, fragmentation and +PSRAM during networking and playback remain unmeasured. + +## Physical acceptance still pending + +No firmware was installed and no audio played during this implementation. +Earlier Atom listening results apply to the old mapping; the +[Waveshare results](waveshare-volume-build-2026-10-10.md) apply to different hardware. + +Use the [scheduler runbook](../../firmware/esphome/scheduler/VALIDATION.md) or +[provisioning runbook](../../firmware/esphome/provisioning/HARDWARE_TEST.md) +for an attended application-only update with approved real recordings. First +recheck the live identity, settings, history, audio digest and compatible recovery. +Verify the migrated level before playback. Start at that level, increase in +bounded steps, and stop if the listener reports distortion or uncomfortable +output. Check zero mute, live adjustment, Stop, both recordings and a cold restart +with unchanged settings/history/audio. Record register/output observations and +runtime memory separately. This build evidence does not qualify a release or +establish the Pyramid's usable maximum loudness. diff --git a/firmware/esphome/components/openathan/openathan.cpp b/firmware/esphome/components/openathan/openathan.cpp index babd7ba..9d3c7b6 100644 --- a/firmware/esphome/components/openathan/openathan.cpp +++ b/firmware/esphome/components/openathan/openathan.cpp @@ -23,14 +23,14 @@ void OpenAthan::setup() { ::openathan::DeviceSettings defaults{settings_, {timezone_name_, tz.std_offset_seconds, tz.has_dst() ? tz.dst_offset_seconds : 0, stored_rule(tz.dst_start), stored_rule(tz.dst_end)}, - static_cast(playback_->volume_percent().value_or(70))}; + static_cast(playback_->volume_percent().value_or(default_volume_))}; // Load independently: a settings upgrade must never overwrite consumption. bool gate_ok = true; if (setup_gate_) { ::openathan::SavedSettings existing; ::openathan::DurableState history; gate_ok = setup_gate_->begin(settings_store_->load(existing), state_store_->load(history)); - defaults.volume = 70; + defaults.volume = default_volume_; } const bool settings_ok = gate_ok && settings_service_->begin(defaults); scheduler_->begin(settings_ok ? settings_service_->saved()->value.prayer : settings_); diff --git a/firmware/esphome/components/openathan/openathan.h b/firmware/esphome/components/openathan/openathan.h index 3ca4a68..398cc72 100644 --- a/firmware/esphome/components/openathan/openathan.h +++ b/firmware/esphome/components/openathan/openathan.h @@ -37,6 +37,7 @@ class OpenAthan : public PollingComponent, public ::openathan::Clock { void set_state_store(::openathan::StateStore *store) { state_store_ = store; } void set_settings_store(::openathan::SettingsStore *store) { settings_store_ = store; } void set_default_timezone(const std::string &name) { timezone_name_ = name; } + void set_default_volume(unsigned percent) { default_volume_ = percent; } void require_setup() { setup_gate_ = std::make_unique(); } const char *setup_state() const; bool activated() const { return !maintenance_ && (!setup_gate_ || setup_gate_->active()); } @@ -114,6 +115,7 @@ class OpenAthan : public PollingComponent, public ::openathan::Clock { std::unique_ptr setup_gate_; std::string timezone_name_; bool volume_applied_{false}, request_ok_{false}; + unsigned default_volume_{70}; bool maintenance_{false}; unsigned volume_attempts_{}; uint64_t next_volume_check_{}; diff --git a/firmware/esphome/components/openathan_audio/partition_audio.cpp b/firmware/esphome/components/openathan_audio/partition_audio.cpp index 0db84f5..d901ddd 100644 --- a/firmware/esphome/components/openathan_audio/partition_audio.cpp +++ b/firmware/esphome/components/openathan_audio/partition_audio.cpp @@ -13,14 +13,21 @@ namespace esphome::openathan_audio { static const char *const TAG = "openathan_audio"; std::optional PartitionAudio::volume_percent() const { + const auto level = volume_level(); + return level ? std::optional(static_cast(std::lround(*level * 100))) : std::nullopt; +} +bool PartitionAudio::request_volume_percent(unsigned percent) { + return percent <= 100 && request_volume_level(percent / 100.0f); +} +std::optional PartitionAudio::volume_level() const { if (!player_ || !player_->is_ready() || !std::isfinite(player_->volume) || player_->volume < 0 || player_->volume > 1) return {}; - return player_->is_muted() ? 0U : static_cast(std::lround(player_->volume * 100)); + return player_->is_muted() ? 0.0f : player_->volume; } -bool PartitionAudio::request_volume_percent(unsigned percent) { - if (!player_ || !player_->is_ready() || percent > 100) return false; +bool PartitionAudio::request_volume_level(float level) { + if (!player_ || !player_->is_ready() || !std::isfinite(level) || level < 0 || level > 1) return false; auto call = player_->make_call(); - call.set_volume(percent / 100.0f); + call.set_volume(level); call.perform(); // ESPHome may drop a full-queue request; caller must check readback. return true; } diff --git a/firmware/esphome/components/openathan_audio/partition_audio.h b/firmware/esphome/components/openathan_audio/partition_audio.h index f2503e4..2be9a1f 100644 --- a/firmware/esphome/components/openathan_audio/partition_audio.h +++ b/firmware/esphome/components/openathan_audio/partition_audio.h @@ -30,6 +30,9 @@ class PartitionAudio : public Component, public ::openathan::Playback { bool start_stream(const std::string &url) override; std::optional volume_percent() const override; bool request_volume_percent(unsigned percent) override; + // Hardware adapters may split attenuation between the codec and software. + std::optional volume_level() const; + bool request_volume_level(float level); protected: speaker::SpeakerMediaPlayer *player_{}; std::string partition_; diff --git a/firmware/esphome/components/openathan_validation/validation.cpp b/firmware/esphome/components/openathan_validation/validation.cpp index 442de03..b77a5e7 100644 --- a/firmware/esphome/components/openathan_validation/validation.cpp +++ b/firmware/esphome/components/openathan_validation/validation.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "oa_validation"; void Validation::setup() { scheduler_->set_calculator(this); scheduler_->set_state_store(this); - scheduler_->set_settings_store(&settings_store_); + scheduler_->set_settings_store(settings_source_); if (nvs_open("oa_validation", NVS_READWRITE, &metadata_) != ESP_OK) return; char record[80]{}; size_t size = sizeof(record); diff --git a/firmware/esphome/components/openathan_validation/validation.h b/firmware/esphome/components/openathan_validation/validation.h index 455b667..b96e432 100644 --- a/firmware/esphome/components/openathan_validation/validation.h +++ b/firmware/esphome/components/openathan_validation/validation.h @@ -6,6 +6,7 @@ namespace esphome::openathan_validation { class Validation : public Component, public ::openathan::DayCalculator, public ::openathan::StateStore { public: void set_scheduler(openathan_component::OpenAthan *scheduler) { scheduler_ = scheduler; } + void set_settings_store(::openathan::SettingsStore *store) { settings_source_ = store; } float get_setup_priority() const override { return setup_priority::LATE + 1; } void setup() override; bool start_test(); @@ -16,6 +17,7 @@ class Validation : public Component, public ::openathan::DayCalculator, public : openathan_component::OpenAthan *scheduler_{}; openathan_component::NvsStateStore store_{"oa_validation"}; openathan_component::NvsSettingsStore settings_store_{"oa_validation"}; + ::openathan::SettingsStore *settings_source_{&settings_store_}; nvs_handle_t metadata_{}; bool healthy_{false}; int32_t day_{}; diff --git a/firmware/esphome/components/voice_pyramid/README.md b/firmware/esphome/components/voice_pyramid/README.md index 9d50d66..797bd19 100644 --- a/firmware/esphome/components/voice_pyramid/README.md +++ b/firmware/esphome/components/voice_pyramid/README.md @@ -10,6 +10,11 @@ The extracted implementation passed full-recording playback and scheduler validation on the reference unit; see the [device results](../../../../docs/development/feasibility-report.md#integrated-device-validation--2026-09-24). +Product volume calibration lives in the separate +[Voice Pyramid audio adapter](../voice_pyramid_audio/README.md). Its new maximum +output has build and host-test coverage; physical listening acceptance remains +pending. The generic hardware package keeps its ordinary ESPHome DAC binding. + No third-party M5Stack component checkout is required. The register settings come from the verified diagnostic; the reusable implementation is OpenAthan code. Optional LEDs use the STM32 controller through checked I2C writes; see the diff --git a/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp b/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp index b60080b..63e45d5 100644 --- a/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp +++ b/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp @@ -34,7 +34,9 @@ void Amplifier::setup() { if (!write_byte(0x00, 0xFF) || !write_byte(0x01, 0x78)) { ESP_LOGE("voice_pyramid", "AW87559 setup failed"); mark_failed(); + return; } + ready_ = true; }); } } // namespace esphome::voice_pyramid diff --git a/firmware/esphome/components/voice_pyramid/voice_pyramid.h b/firmware/esphome/components/voice_pyramid/voice_pyramid.h index 5a23db8..488f19e 100644 --- a/firmware/esphome/components/voice_pyramid/voice_pyramid.h +++ b/firmware/esphome/components/voice_pyramid/voice_pyramid.h @@ -11,6 +11,9 @@ class Clock : public Component, public i2c::I2CDevice { class Amplifier : public Component, public i2c::I2CDevice { public: void setup() override; + bool ready() const { return ready_ && !is_failed(); } float get_setup_priority() const override { return setup_priority::HARDWARE; } + private: + bool ready_{}; }; } // namespace esphome::voice_pyramid diff --git a/firmware/esphome/components/voice_pyramid_audio/README.md b/firmware/esphome/components/voice_pyramid_audio/README.md new file mode 100644 index 0000000..98106e6 --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/README.md @@ -0,0 +1,18 @@ +# Voice Pyramid product audio + +This adapter implements OpenAthan's portable `Playback` interface using the +existing shared-partition audio player and A167 hardware adapter. It owns the +product volume curve, muted gain transitions and compatible settings migration. +Prayer calculations and scheduling remain in the reusable core. + +Select `packages/hardware/voice-pyramid-volume.yaml` alongside the reference +hardware and product/scheduler packages. It removes the direct speaker-to-DAC +volume route, starts the media player muted, and binds the calibrated playback +and settings store to the scheduler. The optional `validation_id` selects +`oa_validation` and supplies that same store to the synthetic timetable harness. +Integrations using different player/scheduler IDs can retain the generic hardware +package without selecting calibration. + +See the [calibration contract and dated build evidence](../../../../docs/development/atom-volume-calibration-2026-10-11.md) +for the gain curve, settings compatibility, amplifier gating and outstanding +physical acceptance. diff --git a/firmware/esphome/components/voice_pyramid_audio/__init__.py b/firmware/esphome/components/voice_pyramid_audio/__init__.py new file mode 100644 index 0000000..0240376 --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/__init__.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components.voice_pyramid import Clock, Amplifier +from esphome.const import CONF_ID + +DEPENDENCIES = ["voice_pyramid", "openathan", "openathan_audio", "audio_dac", "switch"] +ns = cg.esphome_ns.namespace("voice_pyramid_audio") +Audio = ns.class_("Audio", cg.Component, cg.global_ns.namespace("openathan").class_("Playback")) +Playback = cg.esphome_ns.namespace("openathan_audio").class_("PartitionAudio") +ES8311 = cg.esphome_ns.namespace("es8311").class_("ES8311") +Switch = cg.esphome_ns.namespace("switch_").class_("Switch") +OpenAthan = cg.esphome_ns.namespace("openathan_component").class_("OpenAthan") +Validation = cg.esphome_ns.namespace("openathan_validation").class_("Validation") +CONFIG_SCHEMA = cv.Schema({ + cv.GenerateID(): cv.declare_id(Audio), + cv.Required("playback_id"): cv.use_id(Playback), + cv.Required("dac_id"): cv.use_id(ES8311), + cv.Required("enable_id"): cv.use_id(Switch), + cv.Required("clock_id"): cv.use_id(Clock), + cv.Required("amplifier_id"): cv.use_id(Amplifier), + cv.Required("scheduler_id"): cv.use_id(OpenAthan), + cv.Optional("validation_id"): cv.use_id(Validation), +}).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = (cg.new_Pvariable(config[CONF_ID], "oa_validation") if "validation_id" in config + else cg.new_Pvariable(config[CONF_ID])) + await cg.register_component(var, config) + for key in ("playback", "dac", "enable", "clock", "amplifier"): + cg.add(getattr(var, "set_" + key)(await cg.get_variable(config[key + "_id"]))) + scheduler = await cg.get_variable(config["scheduler_id"]) + cg.add(scheduler.set_settings_store(var.settings_store())) + # Equivalent to the old 70% startup setting, rounded slightly quieter. + cg.add(scheduler.set_default_volume(56)) + if "validation_id" in config: + validation = await cg.get_variable(config["validation_id"]) + cg.add(validation.set_settings_store(var.settings_store())) diff --git a/firmware/esphome/components/voice_pyramid_audio/audio.h b/firmware/esphome/components/voice_pyramid_audio/audio.h new file mode 100644 index 0000000..0d8d1de --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/audio.h @@ -0,0 +1,81 @@ +#pragma once +#include "esphome/components/voice_pyramid/voice_pyramid.h" +#include "volume_store.h" +#include "esphome/components/es8311/es8311.h" +#include "esphome/components/openathan_audio/partition_audio.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::voice_pyramid_audio { +class Audio : public Component, public ::openathan::Playback { + public: + explicit Audio(std::string storage_namespace = openathan_storage::PRAYER) : settings_store_(std::move(storage_namespace)) {} + void set_playback(openathan_audio::PartitionAudio *value) { playback_ = value; } + void set_dac(es8311::ES8311 *value) { dac_ = value; } + void set_enable(switch_::Switch *value) { enable_ = value; } + void set_clock(voice_pyramid::Clock *value) { clock_ = value; } + void set_amplifier(voice_pyramid::Amplifier *value) { amplifier_ = value; } + ::openathan::SettingsStore *settings_store() { return &settings_store_; } + float get_setup_priority() const override { return setup_priority::HARDWARE; } + void setup() override { enable_->turn_off(); } + void loop() override { + if (!hardware_healthy_() || phase_ != Phase::IDLE || !applied_) enable_->turn_off(); + if (!hardware_healthy_()) return; + if (!pending_) { + if (applied_ && phase_ == Phase::IDLE) enable_->turn_on(); + return; + } + const auto level = playback_->volume_level(); + if (phase_ == Phase::MUTING) { + if (!level || *level != 0.0f) { playback_->request_volume_level(0.0f); return; } + uint8_t gain{}, drc{}; + if (!dac_->set_mute_on() || !dac_->write_byte(0x34, 0x00) || !dac_->set_volume(plan_.codec_level) || + !dac_->read_byte(0x32, &gain) || gain != static_cast(plan_.codec_level * 255) || + !dac_->read_byte(0x34, &drc) || drc != 0 || + (*pending_ && !dac_->set_mute_off())) { + mark_failed(); return; + } + phase_ = Phase::APPLYING; + playback_->request_volume_level(plan_.software_level); + } else if (phase_ == Phase::APPLYING) { + if (!level || std::fabs(*level - plan_.software_level) > 0.00001f) { + playback_->request_volume_level(plan_.software_level); return; + } + applied_ = pending_; pending_.reset(); phase_ = Phase::IDLE; + enable_->turn_on(); + } + } + void on_shutdown() override { enable_->turn_off(); dac_->set_mute_on(); } + bool ready() const override { return hardware_healthy_() && phase_ == Phase::IDLE && applied_ && enable_->state; } + bool playing() const override { return playback_ && playback_->playing(); } + bool start(::openathan::Track track) override { return ready() && playback_->start(track); } + void stop() override { if (playback_) playback_->stop(); } + std::optional volume_percent() const override { + if (!ready()) return {}; + const auto level = playback_->volume_level(); + return level && std::fabs(*level - plan_.software_level) <= 0.00001f ? applied_ : std::nullopt; + } + bool request_volume_percent(unsigned percent) override { + if (percent > 100 || is_failed()) return false; + if (pending_ == percent || volume_percent() == percent) return true; + pending_ = percent; plan_ = volume_plan(percent); phase_ = Phase::MUTING; + if (enable_) enable_->turn_off(); + if (playback_) playback_->request_volume_level(0.0f); + return true; + } + private: + enum class Phase { IDLE, MUTING, APPLYING }; + bool hardware_healthy_() const { + return !is_failed() && clock_ && !clock_->is_failed() && amplifier_ && amplifier_->ready() && + dac_ && !dac_->is_failed() && playback_ && playback_->ready(); + } + openathan_audio::PartitionAudio *playback_{}; + es8311::ES8311 *dac_{}; + switch_::Switch *enable_{}; + voice_pyramid::Clock *clock_{}; + voice_pyramid::Amplifier *amplifier_{}; + VolumeSettingsStore settings_store_; + VolumePlan plan_{}; + std::optional applied_, pending_; + Phase phase_{Phase::IDLE}; +}; +} // namespace esphome::voice_pyramid_audio diff --git a/firmware/esphome/components/voice_pyramid_audio/volume.h b/firmware/esphome/components/voice_pyramid_audio/volume.h new file mode 100644 index 0000000..ea690a8 --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/volume.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include + +namespace esphome::voice_pyramid_audio { +// A wider curve retains the old codec's quiet tail. ESPHome software attenuation +// spans -49..0 dB; below that range the codec supplies the remaining attenuation. +// The codec never exceeds register 0xBF (0 dB), and samples are never amplified. +inline float volume_db(unsigned percent) { return -96.5f * (1.0f - percent / 100.0f); } +inline unsigned legacy_register(unsigned percent) { + return static_cast((percent / 100.0f) * 0.6f * 255.0f); +} +inline unsigned migrate_volume(unsigned percent) { + return percent ? (legacy_register(percent) + 2) * 100 / 193 : 0; +} +inline unsigned rollback_volume(unsigned percent) { + if (!percent) return 0; + unsigned result = 0; + for (unsigned old = 1; old <= 100; ++old) { + if ((static_cast(legacy_register(old)) - 191) * 0.5f <= volume_db(percent)) result = old; + } + return result; +} +struct VolumePlan { + float codec_level{}, software_level{}; +}; +inline VolumePlan volume_plan(unsigned percent) { + if (!percent) return {0.75f, 0.0f}; + const float db = volume_db(percent); + const int reg = db >= -49.0f ? 191 : static_cast(std::floor(191 + 2 * (db + 48.0f))); + const float codec_db = (reg - 191) * 0.5f; + // The quarter-register offset avoids float truncation into the preceding + // register in ESPHome's linear 0..255 mapping; 191.25 / 255 is exactly 0.75. + return {(reg + 0.25f) / 255.0f, std::clamp(1.0f + (db - codec_db) / 49.0f, 0.0f, 1.0f)}; +} +} // namespace esphome::voice_pyramid_audio diff --git a/firmware/esphome/components/voice_pyramid_audio/volume_store.h b/firmware/esphome/components/voice_pyramid_audio/volume_store.h new file mode 100644 index 0000000..ab43248 --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/volume_store.h @@ -0,0 +1,60 @@ +#pragma once +#include "esphome/components/openathan/nvs_settings_store.h" +#include "volume.h" +#include + +namespace esphome::voice_pyramid_audio { +// Keep the v1 settings key readable by older firmware. A CRC-protected companion +// holds the new volume only while its complete legacy mirror still matches. +class VolumeSettingsStore : public ::openathan::SettingsStore { + public: + explicit VolumeSettingsStore(std::string name = openathan_storage::PRAYER) : + namespace_(std::move(name)), legacy_(namespace_) {} + ~VolumeSettingsStore() override { if (opened_) nvs_close(handle_); } + ::openathan::LoadResult load(::openathan::SavedSettings &settings) override { + using ::openathan::LoadResult; + ::openathan::SavedSettings legacy; + const auto result = legacy_.load(legacy); + if (result == LoadResult::ERROR) return result; + if (!opened_) { + if (nvs_open(namespace_.c_str(), NVS_READWRITE, &handle_) != ESP_OK) return LoadResult::ERROR; + opened_ = true; + } + size_t length = 0; + const auto found = nvs_get_blob(handle_, KEY, nullptr, &length); + if (found != ESP_OK && found != ESP_ERR_NVS_NOT_FOUND) return LoadResult::ERROR; + if (result == LoadResult::EMPTY) return found == ESP_ERR_NVS_NOT_FOUND ? result : LoadResult::ERROR; + if (found == ESP_OK) { + ::openathan::SettingsRecord record; + ::openathan::SavedSettings current; + if (length != record.size() || nvs_get_blob(handle_, KEY, record.data(), &length) != ESP_OK || + length != record.size() || !::openathan::decode_settings(record, current)) return LoadResult::ERROR; + auto mirror = current; + mirror.value.volume = rollback_volume(current.value.volume); + if (mirror == legacy) { settings = std::move(current); return LoadResult::LOADED; } + // Older firmware saved a new revision. Its volume is authoritative again. + } + if (legacy.revision == std::numeric_limits::max()) return LoadResult::ERROR; + settings = std::move(legacy); + settings.value.volume = migrate_volume(settings.value.volume); + ++settings.revision; // Invalidate an old browser's settings revision. + return LoadResult::LOADED; // Boot does not rewrite flash or consumption. + } + bool save(const ::openathan::SavedSettings &settings) override { + if (!opened_ || !settings.revision || !::openathan::valid_device_settings(settings.value)) return false; + auto mirror = settings; + mirror.value.volume = rollback_volume(settings.value.volume); + // Commit the quiet, backwards-readable mirror first. An interrupted second + // write either matches a complete companion or loads the quieter mirror. + if (!legacy_.save(mirror)) return false; + const auto record = ::openathan::encode_settings(settings); + return nvs_set_blob(handle_, KEY, record.data(), record.size()) == ESP_OK && nvs_commit(handle_) == ESP_OK; + } + private: + static constexpr const char *KEY = "pyr_volume"; + std::string namespace_; + openathan_component::NvsSettingsStore legacy_; + nvs_handle_t handle_{}; + bool opened_{}; +}; +} // namespace esphome::voice_pyramid_audio diff --git a/firmware/esphome/openathan.yaml b/firmware/esphome/openathan.yaml index b42274d..9d1ca46 100644 --- a/firmware/esphome/openathan.yaml +++ b/firmware/esphome/openathan.yaml @@ -13,6 +13,7 @@ packages: openathan: !include packages/openathan.yaml product: !include packages/product.yaml reference_hardware: !include packages/hardware/voice-pyramid.yaml + reference_volume: !include packages/hardware/voice-pyramid-volume.yaml reference_display: !include boards/atom-s3r-gc9107-display.yaml voice_pyramid: diff --git a/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml b/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml new file mode 100644 index 0000000..57514cf --- /dev/null +++ b/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml @@ -0,0 +1,32 @@ +# Product-only volume calibration; the reusable hardware package retains its +# normal ESPHome DAC binding for integrations with different player/scheduler IDs. +substitutions: + athan_playback_id: pyramid_audio + +external_components: + - source: + type: local + path: ../../components + components: [voice_pyramid_audio] + +switch: + - id: !extend pyramid_speaker_enable + restore_mode: ALWAYS_OFF + +speaker: + - id: !extend pyramid_speaker + audio_dac: !remove + +media_player: + - id: !extend athan_player + volume_initial: 0% + volume_max: 100% + +voice_pyramid_audio: + id: pyramid_audio + playback_id: athan_audio + dac_id: pyramid_dac + enable_id: pyramid_speaker_enable + clock_id: pyramid_clock + amplifier_id: pyramid_amplifier + scheduler_id: athan_scheduler diff --git a/firmware/esphome/packages/hardware/voice-pyramid.yaml b/firmware/esphome/packages/hardware/voice-pyramid.yaml index 77085b6..1fdeadb 100644 --- a/firmware/esphome/packages/hardware/voice-pyramid.yaml +++ b/firmware/esphome/packages/hardware/voice-pyramid.yaml @@ -25,9 +25,11 @@ switch: voice_pyramid: clock: + id: pyramid_clock i2c_id: pyramid_bus address: 0x60 amplifier: + id: pyramid_amplifier i2c_id: pyramid_bus address: 0x5B diff --git a/firmware/esphome/scheduler/VALIDATION.md b/firmware/esphome/scheduler/VALIDATION.md index efd1ca9..b50cdec 100644 --- a/firmware/esphome/scheduler/VALIDATION.md +++ b/firmware/esphome/scheduler/VALIDATION.md @@ -56,6 +56,13 @@ flash restore also restores historical consumption; it is not normal settings or Wi-Fi recovery. The dated [validation report](../../../docs/development/feasibility-report.md) records what was actually observed for each firmware milestone. +The calibrated Atom/Voice Pyramid profiles convert saved volume to preserve +near-equivalent output; for example, old 80% becomes 64%. Recheck the live +percentage after updating. The new 100% removes the old digital ceiling and +requires attended listening in gradual steps. This mapping and its pending +physical checks are recorded in the +[volume report](../../../docs/development/atom-volume-calibration-2026-10-11.md). + 1. Recheck stable compatible dependencies; run host tests and capacity checks. Record exact firmware hashes and dependency locks. ESPHome 2026.9.0 pins the diagnostic encryption libraries noise-c 0.1.30 and libsodium 1.10021.11. diff --git a/firmware/esphome/scheduler/development.yaml b/firmware/esphome/scheduler/development.yaml index 79ac67e..4aefc61 100644 --- a/firmware/esphome/scheduler/development.yaml +++ b/firmware/esphome/scheduler/development.yaml @@ -2,6 +2,7 @@ packages: baseline: !include ../feasibility/baseline.yaml openathan: !include ../packages/openathan.yaml + volume: !include ../packages/hardware/voice-pyramid-volume.yaml external_components: - source: @@ -21,7 +22,7 @@ openathan_audio: openathan: id: athan_scheduler time_id: athan_clock - playback_id: athan_audio + playback_id: pyramid_audio latitude: !secret athan_latitude longitude: !secret athan_longitude method: !secret athan_method diff --git a/firmware/esphome/scheduler/validation.yaml b/firmware/esphome/scheduler/validation.yaml index 9489d80..8f3f883 100644 --- a/firmware/esphome/scheduler/validation.yaml +++ b/firmware/esphome/scheduler/validation.yaml @@ -51,6 +51,9 @@ openathan_validation: id: validation scheduler_id: athan_scheduler +voice_pyramid_audio: + validation_id: validation + button: - platform: template name: Start test sequence @@ -60,11 +63,11 @@ button: - platform: template name: Play normal on_press: - - lambda: id(athan_audio).start(openathan::Track::NORMAL); + - lambda: id(pyramid_audio).start(openathan::Track::NORMAL); - platform: template name: Play Fajr on_press: - - lambda: id(athan_audio).start(openathan::Track::FAJR); + - lambda: id(pyramid_audio).start(openathan::Track::FAJR); - platform: template name: Stop audio on_press: diff --git a/tests/audio_adapter_tests.cpp b/tests/audio_adapter_tests.cpp index 1dc6623..b05b328 100644 --- a/tests/audio_adapter_tests.cpp +++ b/tests/audio_adapter_tests.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include static bool present = true; @@ -58,6 +59,17 @@ int main(int argc, char **argv) { check(!valid.request_volume_percent(101)); check(valid.request_volume_percent(0) && valid.volume_percent() == 0 && player.is_muted()); check(valid.request_volume_percent(70) && valid.volume_percent() == 70 && !player.is_muted()); + check(valid.request_volume_level(0.5001f) && valid.volume_level() == 0.5001f && valid.volume_percent() == 50); + for (float invalid : {-0.01f, 1.01f, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}) { + check(!valid.request_volume_level(invalid) && valid.volume_level() == 0.5001f); + player.volume = invalid; + check(!valid.volume_level() && !valid.volume_percent()); + player.volume = 0.5001f; + } + player.muted = true; + check(valid.volume_level() == 0.0f); + check(valid.request_volume_percent(70) && !player.is_muted()); check(valid.play(Track::NORMAL)); check(player.queued.empty() && valid.source() == openathan::PlaybackSource::ATHAN); player.drain_commands(); valid.loop(); @@ -101,6 +113,7 @@ int main(int argc, char **argv) { player.initialized = false; player.commands.clear(); check(!valid.ready() && !valid.start(Track::NORMAL)); + check(!valid.volume_level() && !valid.request_volume_level(0.5f)); valid.stop(); check(player.commands.empty()); diff --git a/tests/pyramid_stubs/esphome/components/switch/switch.h b/tests/pyramid_stubs/esphome/components/switch/switch.h new file mode 100644 index 0000000..a215e88 --- /dev/null +++ b/tests/pyramid_stubs/esphome/components/switch/switch.h @@ -0,0 +1,9 @@ +#pragma once +namespace esphome::switch_ { +class Switch { + public: + bool state{}; + void turn_on() { state = true; } + void turn_off() { state = false; } +}; +} diff --git a/tests/pyramid_volume_tests.cpp b/tests/pyramid_volume_tests.cpp new file mode 100644 index 0000000..c1e3c9e --- /dev/null +++ b/tests/pyramid_volume_tests.cpp @@ -0,0 +1,193 @@ +#include "esphome/components/voice_pyramid_audio/audio.h" +#include "nvs_memory.h" +#include +#include + +using namespace esphome; +using namespace voice_pyramid_audio; +using voice_pyramid::Clock; +using voice_pyramid::Amplifier; +using ::openathan::LoadResult; +using ::openathan::SavedSettings; + +static SavedSettings legacy_settings(unsigned volume = 80) { + SavedSettings settings; + settings.revision = 12; settings.value.volume = volume; + settings.value.timezone.name = "UTC"; + settings.value.prayer.offsets[1] = 4; + return settings; +} +static void install_legacy(const SavedSettings &settings) { + openathan_component::NvsSettingsStore store; + SavedSettings unused; + assert(store.load(unused) == LoadResult::EMPTY && store.save(settings)); +} +static float legacy_db(unsigned percent) { return (static_cast(legacy_register(percent)) - 191) * 0.5f; } +static void curves() { + assert(migrate_volume(0) == 0 && migrate_volume(70) == 56 && migrate_volume(80) == 64 && migrate_volume(100) == 80); + assert(volume_plan(0).software_level == 0 && volume_plan(100).codec_level == 0.75f && + volume_plan(100).software_level == 1.0f); + for (unsigned old = 1; old <= 100; ++old) { + const unsigned migrated = migrate_volume(old); + assert(migrated >= 1 && migrated <= 100 && migrated >= migrate_volume(old - 1)); + assert(volume_db(migrated) <= legacy_db(old) + 0.0001f); + assert(legacy_db(old) - volume_db(migrated) < 0.966f); + } + for (unsigned percent = 1; percent <= 100; ++percent) { + const auto plan = volume_plan(percent); + assert(plan.codec_level <= 0.75f && plan.codec_level >= 0); + assert(plan.software_level >= 0.001f && plan.software_level <= 1); + const int reg = static_cast(plan.codec_level * 255); + const float effective = (reg - 191) * 0.5f - 49 * (1 - plan.software_level); + assert(std::fabs(effective - volume_db(percent)) < 0.0001f); + const unsigned rollback = rollback_volume(percent); + assert(rollback <= 100 && (!rollback || legacy_db(rollback) <= volume_db(percent) + 0.0001f)); + } +} +static void storage() { + nvs_test::reset(); install_legacy(legacy_settings()); + nvs_test::committed[{openathan_storage::PRAYER, "state"}] = {1,2,3}; + nvs_test::committed[{"other", "settings"}] = {4,5,6}; + const auto before = nvs_test::committed; + SavedSettings migrated; + VolumeSettingsStore store; + assert(store.load(migrated) == LoadResult::LOADED); + assert(migrated.revision == 13 && migrated.value.volume == 64); + assert(migrated.value.prayer == legacy_settings().value.prayer); + assert(nvs_test::committed == before && nvs_test::writes == 1); + ::openathan::SettingsService service(store); + assert(service.begin(migrated.value)); + auto next = migrated.value; next.volume = 100; + assert(service.update(next, 12, [](const auto &) { return true; }) == ::openathan::SettingsResult::CONFLICT); + assert(service.update(next, 13, [](const auto &) { return true; }) == ::openathan::SettingsResult::SAVED); + VolumeSettingsStore reboot; + SavedSettings restored; + assert(reboot.load(restored) == LoadResult::LOADED && restored == *service.saved()); + openathan_component::NvsSettingsStore old_firmware; + SavedSettings mirror; + assert(old_firmware.load(mirror) == LoadResult::LOADED && mirror.value.volume == 100); + assert(mirror.value.prayer == next.prayer && mirror.revision == 14); + mirror.value.volume = 75; mirror.value.prayer.offsets[2] = 7; ++mirror.revision; + assert(old_firmware.save(mirror)); + VolumeSettingsStore after_downgrade; + assert(after_downgrade.load(restored) == LoadResult::LOADED); + assert(restored.value.volume == migrate_volume(75) && restored.revision == 16); + assert(restored.value.prayer == mirror.value.prayer); + assert(nvs_test::committed.at({openathan_storage::PRAYER, "state"}) == before.at({openathan_storage::PRAYER, "state"})); + assert(nvs_test::committed.at({"other", "settings"}) == before.at({"other", "settings"})); + nvs_test::committed[{openathan_storage::PRAYER, "pyr_volume"}][40] ^= 1; + const auto corrupt = nvs_test::committed; + VolumeSettingsStore damaged; + assert(damaged.load(restored) == LoadResult::ERROR && nvs_test::committed == corrupt); + nvs_test::reset(); VolumeSettingsStore fresh; + assert(fresh.load(restored) == LoadResult::EMPTY); + const auto defaults = legacy_settings(56); + assert(fresh.save(defaults)); + VolumeSettingsStore fresh_reboot; + assert(fresh_reboot.load(restored) == LoadResult::LOADED && restored == defaults); + nvs_test::reset(); auto maximum = legacy_settings(); maximum.revision = UINT32_MAX; + install_legacy(maximum); VolumeSettingsStore exhausted; + assert(exhausted.load(restored) == LoadResult::ERROR); +} +static void interrupted_saves() { + for (unsigned target : {40U, 100U}) for (bool companion : {false, true}) + for (bool early : {false, true}) for (bool commit : {false, true}) for (unsigned cut = 1; cut <= 2; ++cut) { + nvs_test::reset(); install_legacy(legacy_settings()); + nvs_test::committed[{openathan_storage::PRAYER, "state"}] = {1,2,3}; + VolumeSettingsStore store; SavedSettings next; + assert(store.load(next) == LoadResult::LOADED); + if (companion) assert(store.save(next)); + const auto previous = next; + ++next.revision; next.value.volume = target; + next.value.prayer.offsets[3] = 5; + next.value.prayer.latitude = 43.6532; + next.value.prayer.longitude = -79.3832; + const unsigned writes = nvs_test::writes, commits = nvs_test::commits; + nvs_test::early_persist = early; + if (commit) nvs_test::cut_commit_number = commits + cut; + else nvs_test::cut_write_number = writes + cut; + try { store.save(next); assert(false); } catch (const std::runtime_error &) {} + nvs_test::power_cycle(); nvs_test::cut_commit_number = nvs_test::cut_write_number = 0; + VolumeSettingsStore reboot; SavedSettings restored; + assert(reboot.load(restored) == LoadResult::LOADED); + // The old level, complete new level, or a quieter rollback mirror survives. + if (restored.value.prayer == previous.value.prayer) assert(restored == previous); + else { + auto expected = next.value; + expected.volume = restored.value.volume; + assert(restored.value == expected && restored.value.volume <= target); + } + assert(nvs_test::committed.at({openathan_storage::PRAYER, "state"}) == std::vector({1,2,3})); + } + for (bool early : {false, true}) for (bool commit : {false, true}) { + nvs_test::reset(); install_legacy(legacy_settings()); + VolumeSettingsStore store; SavedSettings next; + assert(store.load(next) == LoadResult::LOADED); ++next.revision; next.value.volume = 20; + nvs_test::early_persist = early; + nvs_test::fail_write = !commit; nvs_test::fail_commit = commit; + assert(!store.save(next)); nvs_test::power_cycle(); + nvs_test::fail_write = nvs_test::fail_commit = false; + VolumeSettingsStore reboot; SavedSettings restored; + assert(reboot.load(restored) == LoadResult::LOADED); + assert(restored.value.volume == 64 || restored.value.volume <= 20); + } +} +static void isolated_storage() { + nvs_test::reset(); install_legacy(legacy_settings()); + const auto production = nvs_test::committed; + VolumeSettingsStore isolated("oa_validation"); SavedSettings settings; + assert(isolated.load(settings) == LoadResult::EMPTY); + settings = legacy_settings(56); + assert(isolated.save(settings)); + VolumeSettingsStore reboot("oa_validation"); SavedSettings restored; + assert(reboot.load(restored) == LoadResult::LOADED && restored == settings); + for (const auto &[key, value] : production) assert(nvs_test::committed.at(key) == value); + assert(nvs_test::committed.contains({"oa_validation", "settings"})); + assert(nvs_test::committed.contains({"oa_validation", "pyr_volume"})); +} +static void adapter() { + switch_::Switch enable; Clock clock; Amplifier amplifier; es8311::ES8311 dac; + openathan_audio::PartitionAudio playback; Audio audio; + audio.set_enable(&enable); audio.set_clock(&clock); audio.set_amplifier(&lifier); + audio.set_dac(&dac); audio.set_playback(&playback); audio.setup(); + assert(!enable.state && !audio.ready() && !audio.start(::openathan::Track::NORMAL)); + assert(audio.request_volume_percent(64)); audio.loop(); assert(dac.volumes.empty()); + playback.available = true; audio.loop(); assert(!audio.ready()); + amplifier.setup(); audio.loop(); assert(!audio.ready() && !enable.state); + audio.loop(); audio.loop(); assert(audio.ready() && enable.state && audio.volume_percent() == 64); + assert(audio.start(::openathan::Track::NORMAL) && audio.playing()); + assert(!audio.request_volume_percent(101)); + playback.apply_requests = false; + assert(audio.request_volume_percent(100) && !enable.state); + const auto gain_writes = dac.volumes.size(); + audio.loop(); assert(dac.volumes.size() == gain_writes && !audio.ready()); + playback.level = 0; audio.loop(); assert(!audio.ready() && !enable.state); + audio.loop(); assert(!audio.ready()); // Dropped software-volume request stays gated. + playback.level = 1; audio.loop(); + assert(audio.ready() && audio.volume_percent() == 100 && dac.volumes.back() == 0.75f); + playback.apply_requests = true; + assert(audio.request_volume_percent(0)); audio.loop(); audio.loop(); + assert(audio.volume_percent() == 0 && playback.level == 0); + assert(audio.request_volume_percent(1)); audio.loop(); audio.loop(); + assert(audio.volume_percent() == 1 && dac.volumes.back() < 0.75f); + audio.stop(); assert(!audio.playing()); + playback.available = false; audio.loop(); assert(!enable.state && !audio.ready()); + playback.available = true; audio.loop(); assert(audio.ready()); + audio.on_shutdown(); assert(!enable.state); + for (unsigned failure = 0; failure < 6; ++failure) { + switch_::Switch failed_enable; Clock failed_clock; Amplifier failed_amp; + es8311::ES8311 failed_dac; openathan_audio::PartitionAudio failed_playback; Audio failed; + failed.set_enable(&failed_enable); failed.set_clock(&failed_clock); failed.set_amplifier(&failed_amp); + failed.set_dac(&failed_dac); failed.set_playback(&failed_playback); + failed_amp.setup(); failed_playback.available = true; failed.setup(); + failed_dac.mute_write_ok = failure != 0; failed_dac.volume_write_ok = failure != 1; + failed_dac.unmute_write_ok = failure != 2; + failed_dac.register_write_ok = failure != 3; + failed_dac.register_read_ok = failure != 4; + failed_dac.wrong_readback = failure == 5; + assert(failed.request_volume_percent(80)); failed.loop(); + assert(failed.is_failed() && !failed_enable.state && !failed.ready()); + assert(!failed.request_volume_percent(20) && !failed.start(::openathan::Track::FAJR)); + } +} +int main() { curves(); storage(); interrupted_saves(); isolated_storage(); adapter(); } diff --git a/tests/stubs/esphome/components/i2c/i2c.h b/tests/stubs/esphome/components/i2c/i2c.h index de5db8e..78bc7b9 100644 --- a/tests/stubs/esphome/components/i2c/i2c.h +++ b/tests/stubs/esphome/components/i2c/i2c.h @@ -1,16 +1,28 @@ #pragma once #include #include +#include namespace esphome::i2c { struct Write { uint8_t reg; std::vector data; }; class I2CDevice { public: std::vector writes; unsigned fail_at{}; + bool fail_read{}; bool write_bytes(uint8_t reg, const uint8_t *data, size_t size) { writes.push_back({reg,{data,data+size}}); return !fail_at || writes.size()!=fail_at; } bool write_byte(uint8_t reg, uint8_t value) { return write_bytes(reg,&value,1); } + bool read_bytes(uint8_t reg, uint8_t *data, size_t size) { + if (fail_read) return false; + for (auto it = writes.rbegin(); it != writes.rend(); ++it) { + if (it->reg == reg && it->data.size() == size) { + std::copy(it->data.begin(), it->data.end(), data); return true; + } + } + return false; + } + bool read_byte(uint8_t reg, uint8_t *value) { return read_bytes(reg, value, 1); } }; } diff --git a/tests/stubs/esphome/core/component.h b/tests/stubs/esphome/core/component.h index bf9cff5..e215eb4 100644 --- a/tests/stubs/esphome/core/component.h +++ b/tests/stubs/esphome/core/component.h @@ -1,6 +1,7 @@ #pragma once +#include namespace esphome { -namespace setup_priority { constexpr float AFTER_WIFI = 200; constexpr float LATE = -100; constexpr float HARDWARE = 800; constexpr float DATA = 600; constexpr float BEFORE_CONNECTION = 220; } +namespace setup_priority { constexpr float IO = 900; constexpr float AFTER_WIFI = 200; constexpr float LATE = -100; constexpr float HARDWARE = 800; constexpr float DATA = 600; constexpr float BEFORE_CONNECTION = 220; } class Component { public: virtual ~Component() = default; @@ -10,6 +11,7 @@ class Component { virtual float get_setup_priority() const { return 0; } void mark_failed() { failed_ = true; } bool is_failed() const { return failed_; } + void set_timeout(unsigned, std::function callback) { callback(); } private: bool failed_{false}; }; diff --git a/tests/test_scheduler_config.py b/tests/test_scheduler_config.py index 601b313..0c6062d 100644 --- a/tests/test_scheduler_config.py +++ b/tests/test_scheduler_config.py @@ -1,5 +1,6 @@ """Validate the real ESPHome schema, when run in the pinned build environment.""" import importlib.util +import os from pathlib import Path import subprocess import sys @@ -76,6 +77,20 @@ def test_valid_and_actions(self): """ self.validate(config, True) + def test_generic_hardware_retains_dac_volume_control(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / "test.yaml" + config.write_text(self.config()) + result = subprocess.run([sys.executable, "-m", "esphome", "compile", "--only-generate", str(config)], + env=dict(os.environ, ESPHOME_BUILD_PATH=str(root / "build")), + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + source = (root / "build/scheduler-schema-test/src/main.cpp").read_text() + self.assertIn("pyramid_speaker->set_audio_dac(pyramid_dac)", source) + self.assertIn("athan_test_scheduler->set_playback(athan_test_audio)", source) + self.assertNotIn("voice_pyramid_audio::Audio", source) + def test_explicit_timezone_required(self): self.validate(self.config(timezone=""), False, "explicit timezone") diff --git a/tests/test_waveshare.py b/tests/test_waveshare.py index 450f2e3..f0e6f35 100644 --- a/tests/test_waveshare.py +++ b/tests/test_waveshare.py @@ -99,7 +99,7 @@ def test_volume_routes_for_each_profile(self): # Included diagnostic profiles fall back to the main config's secrets. (root / 'secrets.yaml').write_text('diagnostic_api_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=\n') for profile in ('waveshare/audio', 'waveshare/development', - 'waveshare/diagnostics', 'openathan'): + 'waveshare/diagnostics', 'openathan', 'scheduler/validation'): with self.subTest(profile=profile): config = root / 'volume.yaml' config.write_text(f"packages:\n product: !include {ROOT / 'firmware/esphome' / (profile + '.yaml')}\n") @@ -109,12 +109,22 @@ def test_volume_routes_for_each_profile(self): env=dict(os.environ, ESPHOME_BUILD_PATH=str(build)), capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - name = 'openathan-test' if profile.startswith('waveshare/') else 'openathan' + name = ('openathan-test' if profile.startswith('waveshare/') else + 'openathan-feasibility' if profile == 'scheduler/validation' else 'openathan') source = (build / name / 'src/main.cpp').read_text() if profile.startswith('waveshare/'): self.assertIn('athan_player->set_volume_max(1.0f)', source) self.assertIn('waveshare_audio->set_dac(waveshare_dac)', source) self.assertNotIn('waveshare_speaker->set_audio_dac(', source) else: - self.assertIn('athan_player->set_volume_max(0.6f)', source) - self.assertIn('pyramid_speaker->set_audio_dac(pyramid_dac)', source) + self.assertIn('athan_player->set_volume_max(1.0f)', source) + self.assertIn('athan_player->set_volume_initial(0.0f)', source) + self.assertNotIn('pyramid_speaker->set_audio_dac(', source) + self.assertIn('pyramid_audio->set_dac(pyramid_dac)', source) + self.assertIn('athan_scheduler->set_playback(pyramid_audio)', source) + self.assertIn('athan_scheduler->set_settings_store(pyramid_audio->settings_store())', source) + self.assertIn('athan_scheduler->set_default_volume(56)', source) + if profile == 'scheduler/validation': + self.assertIn('new(pyramid_audio) voice_pyramid_audio::Audio("oa_validation")', source) + self.assertIn('validation->set_settings_store(pyramid_audio->settings_store())', source) + self.assertIn('pyramid_audio->start(openathan::Track::NORMAL)', source) diff --git a/tests/waveshare_stubs/esphome/components/es8311/es8311.h b/tests/waveshare_stubs/esphome/components/es8311/es8311.h index fd5a9ad..d3669b3 100644 --- a/tests/waveshare_stubs/esphome/components/es8311/es8311.h +++ b/tests/waveshare_stubs/esphome/components/es8311/es8311.h @@ -1,14 +1,25 @@ #pragma once #include "esphome/core/component.h" #include +#include namespace esphome::es8311 { class ES8311 : public Component { public: bool volume_write_ok{true}; bool unmute_write_ok{true}; + bool mute_write_ok{true}; unsigned unmute_writes{}; + unsigned mute_writes{}; + bool register_write_ok{true}, register_read_ok{true}, wrong_readback{}; + uint8_t gain{}, drc{}; std::vector volumes; - bool set_volume(float value) { volumes.push_back(value); return volume_write_ok; } + bool set_volume(float value) { volumes.push_back(value); gain = static_cast(value * 255); return volume_write_ok; } bool set_mute_off() { ++unmute_writes; return unmute_write_ok; } + bool set_mute_on() { ++mute_writes; return mute_write_ok; } + bool write_byte(uint8_t reg, uint8_t value) { if (reg == 0x34) drc = value; return register_write_ok; } + bool read_byte(uint8_t reg, uint8_t *value) { + *value = (reg == 0x32 ? gain : drc) ^ (wrong_readback ? 1 : 0); + return register_read_ok; + } }; } diff --git a/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h b/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h index 2e31a2b..b5fe8c4 100644 --- a/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h +++ b/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h @@ -1,15 +1,27 @@ #pragma once #include "openathan/playback.h" +#include +#include namespace esphome::openathan_audio { class PartitionAudio : public ::openathan::Playback { public: bool available{}, active{}; unsigned starts{}, stops{}, volume{70}; + float level{0.7f}; + bool apply_requests{true}; + std::vector requests; bool ready() const override { return available; } bool playing() const override { return active; } bool start(::openathan::Track) override { ++starts; return active = true; } void stop() override { ++stops; active = false; } std::optional volume_percent() const override { return volume; } - bool request_volume_percent(unsigned percent) override { volume = percent; return true; } + bool request_volume_percent(unsigned percent) override { volume = percent; return request_volume_level(percent / 100.0f); } + std::optional volume_level() const { return available ? std::optional(level) : std::nullopt; } + bool request_volume_level(float value) { + if (!available || !std::isfinite(value) || value < 0 || value > 1) return false; + requests.push_back(value); + if (apply_requests) level = value; + return true; + } }; } diff --git a/web/device-ui/README.md b/web/device-ui/README.md index 6eeb522..0d56fec 100644 --- a/web/device-ui/README.md +++ b/web/device-ui/README.md @@ -60,6 +60,12 @@ an ongoing drag retains its edited value. Feedback and recovery stay beside the affected group, distinguishing unsaved input, saving, confirmed storage, applying, unavailable output and failure. +Volume is relative to the installed hardware. The calibrated Atom/Voice Pyramid +profile converts an older saved percentage to keep near-equivalent output, so +an old 80% appears as 64% after upgrade. The new maximum is higher; see the +[calibration report](../../docs/development/atom-volume-calibration-2026-10-11.md) +for its acceptance limits. + ### Prayer drafts and first-run setup Location, timezone, calculation conventions, high-latitude rules and offsets remain From 45db53788d1f2cd80617b2aec579f5bcb289f9be Mon Sep 17 00:00:00 2001 From: Abubakr Eirabie Date: Sun, 11 Oct 2026 09:44:03 -0400 Subject: [PATCH 2/4] Provide all compile settings in volume profile tests --- tests/test_waveshare.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_waveshare.py b/tests/test_waveshare.py index f0e6f35..a2086ee 100644 --- a/tests/test_waveshare.py +++ b/tests/test_waveshare.py @@ -96,8 +96,9 @@ def test_generated_board_and_profile_guards(self): def test_volume_routes_for_each_profile(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - # Included diagnostic profiles fall back to the main config's secrets. - (root / 'secrets.yaml').write_text('diagnostic_api_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=\n') + # Included profiles fall back to the main config's public placeholders. + # This must work before prepare_ci creates any repository secrets. + (root / 'secrets.yaml').write_bytes((ROOT / 'firmware/esphome/compile-secrets.example.yaml').read_bytes()) for profile in ('waveshare/audio', 'waveshare/development', 'waveshare/diagnostics', 'openathan', 'scheduler/validation'): with self.subTest(profile=profile): From 12231c9114ed2210a1a29d13aaa610a8c1c23ae9 Mon Sep 17 00:00:00 2001 From: Abubakr Eirabie Date: Sun, 11 Oct 2026 12:22:48 -0400 Subject: [PATCH 3/4] Fence Voice Pyramid gain changes behind checked output gates --- CMakeLists.txt | 6 +- README.md | 4 +- .../atom-volume-calibration-2026-10-11.md | 63 +- .../atom-volume-fence-2026-10-11.md | 94 +++ .../components/voice_pyramid/README.md | 7 +- .../components/voice_pyramid/__init__.py | 5 +- .../voice_pyramid/voice_pyramid.cpp | 13 +- .../components/voice_pyramid/voice_pyramid.h | 4 + .../components/voice_pyramid_audio/README.md | 11 +- .../voice_pyramid_audio/__init__.py | 22 +- .../components/voice_pyramid_audio/audio.h | 108 ++- .../hardware/voice-pyramid-volume.yaml | 13 +- .../packages/hardware/voice-pyramid.yaml | 16 - tests/i2s_stubs/driver/gpio.h | 3 + tests/i2s_stubs/driver/i2s_std.h | 43 ++ tests/i2s_stubs/esp_idf_version.h | 3 + .../esphome/components/audio/audio.h | 18 + .../components/audio/audio_transfer_buffer.h | 19 + .../i2s_audio/speaker/i2s_audio_speaker.h | 2 + .../components/ring_buffer/ring_buffer.h | 10 + .../esphome/components/speaker/speaker.h | 17 + tests/i2s_stubs/esphome/core/application.h | 1 + tests/i2s_stubs/esphome/core/component.h | 16 + tests/i2s_stubs/esphome/core/defines.h | 1 + tests/i2s_stubs/esphome/core/gpio.h | 1 + tests/i2s_stubs/esphome/core/hal.h | 4 + tests/i2s_stubs/esphome/core/helpers.h | 14 + tests/i2s_stubs/esphome/core/log.h | 7 + tests/i2s_stubs/fake_i2s.h | 70 ++ tests/i2s_stubs/freertos/FreeRTOS.h | 2 + tests/i2s_stubs/freertos/event_groups.h | 2 + tests/i2s_stubs/freertos/queue.h | 2 + tests/i2s_stubs/gain.h | 13 + tests/i2s_stubs/hal/dma_types.h | 2 + tests/i2s_stubs/pcm_convert.h | 7 + .../esphome/components/i2c/i2c.h | 32 + .../esphome/components/switch/switch.h | 9 - tests/pyramid_volume_tests.cpp | 250 ++++-- tests/test_scheduler_config.py | 3 + tests/test_volume_fence.py | 79 ++ .../esphome/components/es8311/es8311.h | 9 +- .../openathan_audio/partition_audio.h | 11 +- third_party/esphome-i2s-audio/LICENSE | 709 ++++++++++++++++++ third_party/esphome-i2s-audio/UPSTREAM.md | 49 ++ .../components/i2s_audio/__init__.py | 300 ++++++++ .../components/i2s_audio/i2s_audio.h | 81 ++ .../i2s_audio/media_player/__init__.py | 7 + .../i2s_audio/microphone/__init__.py | 159 ++++ .../microphone/i2s_audio_microphone.cpp | 429 +++++++++++ .../microphone/i2s_audio_microphone.h | 76 ++ .../components/i2s_audio/speaker/__init__.py | 283 +++++++ .../i2s_audio/speaker/i2s_audio_spdif.cpp | 504 +++++++++++++ .../i2s_audio/speaker/i2s_audio_spdif.h | 34 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 540 +++++++++++++ .../i2s_audio/speaker/i2s_audio_speaker.h | 195 +++++ .../speaker/i2s_audio_speaker_standard.cpp | 569 ++++++++++++++ .../speaker/i2s_audio_speaker_standard.h | 32 + .../i2s_audio/speaker/spdif_encoder.cpp | 492 ++++++++++++ .../i2s_audio/speaker/spdif_encoder.h | 148 ++++ .../i2s_audio/speaker/volume_fence.h | 74 ++ .../esphome-i2s-audio/upstream-sha256.json | 17 + third_party/esphome-i2s-audio/upstream.json | 7 + .../esphome-i2s-audio/volume-fence.patch | 416 ++++++++++ 63 files changed, 6002 insertions(+), 135 deletions(-) create mode 100644 docs/development/atom-volume-fence-2026-10-11.md create mode 100644 tests/i2s_stubs/driver/gpio.h create mode 100644 tests/i2s_stubs/driver/i2s_std.h create mode 100644 tests/i2s_stubs/esp_idf_version.h create mode 100644 tests/i2s_stubs/esphome/components/audio/audio.h create mode 100644 tests/i2s_stubs/esphome/components/audio/audio_transfer_buffer.h create mode 100644 tests/i2s_stubs/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h create mode 100644 tests/i2s_stubs/esphome/components/ring_buffer/ring_buffer.h create mode 100644 tests/i2s_stubs/esphome/components/speaker/speaker.h create mode 100644 tests/i2s_stubs/esphome/core/application.h create mode 100644 tests/i2s_stubs/esphome/core/component.h create mode 100644 tests/i2s_stubs/esphome/core/defines.h create mode 100644 tests/i2s_stubs/esphome/core/gpio.h create mode 100644 tests/i2s_stubs/esphome/core/hal.h create mode 100644 tests/i2s_stubs/esphome/core/helpers.h create mode 100644 tests/i2s_stubs/esphome/core/log.h create mode 100644 tests/i2s_stubs/fake_i2s.h create mode 100644 tests/i2s_stubs/freertos/FreeRTOS.h create mode 100644 tests/i2s_stubs/freertos/event_groups.h create mode 100644 tests/i2s_stubs/freertos/queue.h create mode 100644 tests/i2s_stubs/gain.h create mode 100644 tests/i2s_stubs/hal/dma_types.h create mode 100644 tests/i2s_stubs/pcm_convert.h create mode 100644 tests/pyramid_stubs/esphome/components/i2c/i2c.h delete mode 100644 tests/pyramid_stubs/esphome/components/switch/switch.h create mode 100644 tests/test_volume_fence.py create mode 100644 third_party/esphome-i2s-audio/LICENSE create mode 100644 third_party/esphome-i2s-audio/UPSTREAM.md create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/__init__.py create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/i2s_audio.h create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/media_player/__init__.py create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/microphone/__init__.py create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.cpp create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.h create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/__init__.py create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.cpp create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.h create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.h create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.cpp create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.h create mode 100644 third_party/esphome-i2s-audio/components/i2s_audio/speaker/volume_fence.h create mode 100644 third_party/esphome-i2s-audio/upstream-sha256.json create mode 100644 third_party/esphome-i2s-audio/upstream.json create mode 100644 third_party/esphome-i2s-audio/volume-fence.patch diff --git a/CMakeLists.txt b/CMakeLists.txt index f3b145e..f34ee70 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,9 +111,13 @@ add_test(NAME waveshare_power_and_backlight COMMAND waveshare_adapter_tests) foreach(mode IN ITEMS production isolated) add_executable(pyramid_volume_${mode}_tests tests/pyramid_volume_tests.cpp firmware/esphome/components/voice_pyramid/voice_pyramid.cpp + third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp + third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp firmware/esphome/components/openathan/nvs_settings_store.cpp) - target_include_directories(pyramid_volume_${mode}_tests PRIVATE tests/pyramid_stubs tests/waveshare_stubs tests/stubs + target_include_directories(pyramid_volume_${mode}_tests PRIVATE tests/i2s_stubs tests/pyramid_stubs tests/waveshare_stubs tests/stubs + third_party/esphome-i2s-audio/components/i2s_audio/speaker firmware lib/openathan-core/include) + target_compile_definitions(pyramid_volume_${mode}_tests PRIVATE USE_ESP32 USE_OPENATHAN_VOLUME_FENCE) target_link_libraries(pyramid_volume_${mode}_tests PRIVATE openathan_core) if(mode STREQUAL "isolated") target_compile_definitions(pyramid_volume_${mode}_tests PRIVATE OPENATHAN_PROVISIONING_TEST_STORAGE) diff --git a/README.md b/README.md index 3688e2f..53ac071 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing substantial changes. ## Licensing -- OpenAthan-authored software: [Apache License 2.0](LICENSE). +- OpenAthan-authored software: [Apache License 2.0](LICENSE), except the + [vendored ESPHome I2S audio patch](third_party/esphome-i2s-audio/UPSTREAM.md) + (GPLv3 runtime and MIT Python). - OpenAthan-authored documentation: [CC BY 4.0](docs/LICENSE.md). - Future original hardware design sources: [CERN-OHL-P-2.0](hardware/LICENSE.md), unless stated otherwise. - Third-party dependencies, hardware, and media remain under their respective terms. diff --git a/docs/development/atom-volume-calibration-2026-10-11.md b/docs/development/atom-volume-calibration-2026-10-11.md index acc597d..45cabc1 100644 --- a/docs/development/atom-volume-calibration-2026-10-11.md +++ b/docs/development/atom-volume-calibration-2026-10-11.md @@ -15,15 +15,42 @@ both software mute and the codec mute. From 50% upwards the codec stays at settings beyond ESPHome's -49 dB software range. Neither stage amplifies samples or sets positive codec gain. DAC dynamic-range compression is explicitly disabled. -The media player starts muted and the GPIO expander's amplifier enable starts -off. The adapter waits for the audio partition, clock and amplifier initialization. -For every volume change it disables the amplifier, requests software mute and -waits for readback. Only then does it apply codec mute, DRC and gain settings, -verify the gain/DRC registers, and request the final software level. Amplifier -enable and new playback remain gated until that level reads back. Codec write or -readback failure leaves the adapter failed with the amplifier disabled; dropped -media-player commands are retried while gated. A live adjustment briefly mutes -the output without stopping or replaying the recording. +The calibrated amplifier selects `enable_on_startup: false`; its existing +one-second initialization delay and SYSCTRL settings remain, with PA disabled. +Generic configurations default to enabled startup. The previous expander GPIO +binding was removed: checked PA control reads AW87559 SYSCTRL `0x01`, changes +only EN_PA bit `0x08`, writes it and verifies the complete resulting register. +This follows [M5Stack's AW87559 control](https://github.com/m5stack/M5Echo-Pyramid/blob/main/src/AW87559.cpp). + +Each transition verifies PA disabled, verifies codec mute (`0x31`, bits `0x60`), +applies and reads back codec gain and DRC, requests fenced software gain, then +waits for the current token. Only after completion does it verify codec unmute, +verify PA enabled and publish the applied volume. Zero stays codec-muted and +PA-disabled while remaining ready and applied. Duplicate pending requests retain +the transition; a different request immediately closes the gates and supersedes +the old generation. Stop and speaker sessions invalidate obsolete tokens without +starting or replaying a recording. + +The calibrated adapter owns software gain. Restored or queued media-player +volume/mute commands cannot override it. The repository-local, pinned +[ESPHome I2S component](../../third_party/esphome-i2s-audio/UPSTREAM.md) stages raw +PCM and captures one synchronized gain/generation pair for each DMA descriptor. +It scales only consumed samples, before bit-depth conversion, and tags the +existing FIFO completion record, including silence. A complete matching buffer +proves older FIFO buffers drained. Logical volume readback and wall-clock delay +cannot provide that proof; a fully stopped and cleaned-up speaker may complete +immediately. + +Each distinct requested value gets a two-second fault deadline once hardware is +ready. Duplicate requests and Stop/restart retries retain that deadline. It +triggers failure, never permission to unmute. Allocation, partial writes, completion overflow, +queue desynchronization, unverified DMA layout, counter exhaustion, failed +hardware writes/readbacks or missing completion block playback. Failure requests +software zero, stops the speaker and player, attempts both hardware mutes and +latches the adapter failed until restart. I2C failure can prevent a physical gate +from responding; these are checked controls and best-effort fault mutes, not +proof of physical silence or a replacement for attended acceptance. Normal live +adjustments briefly mute without stopping or replaying the recording. The mappings are checked against the [ES8311 register guide](https://files.waveshare.com/wiki/common/ES8311.user.Guide.pdf) and pinned ESPHome 2026.9.0 sources for the @@ -72,7 +99,13 @@ package retains its ordinary ESPHome DAC binding; calibration is selected by the separate product package. Core prayer logic and the public website do not acquire a hardware dependency. -## Automated validation +## Initial calibration evidence before the fence correction + +The following measurements describe the initial calibrated revision `f1bbe0b`. +The [output-fence validation report](atom-volume-fence-2026-10-11.md) records the +correction, current transport regressions and matched before/after builds. + +### Automated validation All 20 CTests passed with UndefinedBehaviorSanitizer, including production and isolated calibration fixtures. They cover every legacy percentage, effective @@ -111,7 +144,9 @@ audio/default-volume bridge changes. The 1.5 MiB application limit, both 2 MiB OTA slots and the separate 3.5 MiB shared audio partition are retained. Compile-only fixtures must never be -installed or played. The adapter adds no audio task, sample buffer or dependency. +installed or played. The initial adapter adds no audio task or sample buffer. +The fence correction vendors the same pinned I2S component and enlarges its existing record queue; +see the linked report for its separate costs. Reference static RAM increases by 152 bytes; runtime heap, fragmentation and PSRAM during networking and playback remain unmeasured. @@ -125,7 +160,11 @@ Use the [scheduler runbook](../../firmware/esphome/scheduler/VALIDATION.md) or [provisioning runbook](../../firmware/esphome/provisioning/HARDWARE_TEST.md) for an attended application-only update with approved real recordings. First recheck the live identity, settings, history, audio digest and compatible recovery. -Verify the migrated level before playback. Start at that level, increase in +Verify the migrated level and both hardware gates before playback. Confirm PA +bit `0x08` and codec mute bits `0x60` correspond to observed output gating. +Check rapid, duplicate and paused live adjustments, Stop during a pending fence +and fault handling with approved recordings. Register readback alone does not +prove physical mute or amplifier behavior. Start at that level, increase in bounded steps, and stop if the listener reports distortion or uncomfortable output. Check zero mute, live adjustment, Stop, both recordings and a cold restart with unchanged settings/history/audio. Record register/output observations and diff --git a/docs/development/atom-volume-fence-2026-10-11.md b/docs/development/atom-volume-fence-2026-10-11.md new file mode 100644 index 0000000..14277bf --- /dev/null +++ b/docs/development/atom-volume-fence-2026-10-11.md @@ -0,0 +1,94 @@ +# Atom output fence validation — 2026-10-11 + +This corrects PR #44's GPIO gate and logical volume acknowledgement. Checked +AW87559 SYSCTRL PA control replaces the expander switch; the codec remains a +second checked gate. The calibrated adapter owns software gain and waits for a +complete current-generation DMA descriptor before opening either gate. A fault +never authorizes unmute. See the [calibration contract](atom-volume-calibration-2026-10-11.md) +and [pinned component provenance and maintained patch](../../third_party/esphome-i2s-audio/UPSTREAM.md). + +The branch was rebased onto main `2cea368` (Quran streaming). The existing +partition-player streaming capability, ownership, state and playback epoch are +forwarded through the calibrated adapter; Athan and Quran use the same output +control. HTTP contracts, volume curve, 56% default, compatible settings records, +prayer history and shared audio retain their contracts. + +## Automated checks + +- All 20 CTests passed with UndefinedBehaviorSanitizer, including production and + isolated settings migration/interrupted-save regressions. +- The actual vendored standard speaker task runs against a deterministic fake + I2S transport. The tests reproduce logical readback while louder DMA buffers + remain queued, and a staged raw tail across pause and bit-depth narrowing. + Reintroducing early unmute or early staging attenuation makes those regressions + fail. Transport/sample helpers are simulated; this is not physical playback. +- Coverage includes rapid/superseded and duplicate requests, separate deadlines + for distinct targets, duplicate requests at the deadline, paused/idle playback, + Stop without replay, session restart/stale tokens, allocation/task/channel/ + callback failures, missing/overflowed completions, queue desynchronization, + partial PCM/silence/preload writes, invalid DMA layout and counter exhaustion. + Deferred ISR event-group updates and faults during driver teardown cannot + erase failure. Startup cleanup retains another bus owner's lock and releases + its own once. +- Checked amplifier/codec writes and incorrect readbacks, startup/shutdown, + applied zero with both gates closed, generic enabled startup, ignored + media-player preferences and Quran ownership handoffs are covered. +- All 120 Python tests and 13 configuration schemas passed. The six affected + provenance/schema tests passed again after the final maintained-patch update. + Generic hardware retains its ordinary DAC route; calibrated profiles reject + an unfenced speaker, enabled startup, a direct DAC binding and SPDIF. +- All 532 Chromium/WebKit cases passed with Node 24.19.0 / Playwright 1.62.1. + Upgrade runtime, resolved ESP-IDF HTTP and Quran worker regressions passed. + +## Matched firmware measurements + +Before is the original PR #44 calibration rebased onto current main (`45db537`). +After is the delivered fence and compatibility correction. Both source exports +use identical compile-only settings/audio, generated development identities, +ESPHome 2026.9.0, ESP-IDF 5.5.5 and compiler `esp-14.2.0_20260121`. +All ten pairs were built; after-builds were refreshed after the final review +fixes. Dependency hashes match within every pair and all capacity checks pass. +These are development measurements, not release-qualified images. + +| Variant | Before OTA bytes | After OTA bytes | Delta | Remaining 1.5 MiB budget | Static RAM delta | +| --- | ---: | ---: | ---: | ---: | ---: | +| Atom reference | 1,305,888 | 1,305,296 | -592 | 267,568 | +20 | +| Atom real schedule | 1,125,792 | 1,123,808 | -1,984 | 449,056 | +20 | +| Atom synthetic timetable | 1,143,344 | 1,141,344 | -2,000 | 431,520 | +20 | +| Atom provisioning validation | 1,307,632 | 1,307,040 | -592 | 265,824 | +20 | +| Atom upgrade qualification | 1,312,416 | 1,311,824 | -592 | 261,040 | +20 | +| Atom startup-failure qualification | 1,312,416 | 1,311,824 | -592 | 261,040 | +20 | +| Waveshare production | 1,309,872 | 1,309,872 | +0 | 262,992 | +0 | +| Waveshare audio | 1,238,352 | 1,238,352 | +0 | 334,512 | +0 | +| Waveshare display | 1,288,080 | 1,288,080 | +0 | 284,784 | +0 | +| Waveshare diagnostics | 1,374,928 | 1,374,928 | +0 | 197,936 | +0 | + +The four Waveshare controls have unchanged OTA sizes and static RAM; independently +rebuilt images have different hashes. Both 2 MiB OTA slots, the 1,572,864-byte +application limit and separate 3.5 MiB shared audio partition are preserved. +Reference slot headroom is 791,856 bytes, exceeding the required 512 KiB. + +The fence adds no audio task, PCM buffer or queue. The existing ten-entry +completion-record queue grows from 4 to 12 bytes per entry (+80 heap bytes). +Its state occupies 24 bytes; the ESP32-S3 critical-section lock occupies 8 bytes, +plus the enable flag/alignment. The measured static RAM delta includes removal +of the incorrect GPIO objects and added transition state. Ring/staging/DMA buffer +sizes and the 4096-byte speaker task stack remain unchanged. New task locals and +concurrent audio/network/UI load still need runtime heap, fragmentation, stack +and PSRAM measurements; static RAM does not establish that headroom. + +## Attended acceptance remains separate + +No hardware was connected, installed, played or restarted for this correction. +Compile-only fixtures must NEVER be installed or played. Follow the +[scheduler validation runbook](../../firmware/esphome/scheduler/VALIDATION.md) or +[provisioning hardware runbook](../../firmware/esphome/provisioning/HARDWARE_TEST.md). +Identify the live device and preserve compatible application-only recovery, +settings, consumption history and shared audio before any attended installation. + +With approved recordings, verify that PA and codec readbacks correspond to +observed output gating, then exercise zero, live/rapid/paused adjustments, Stop +and fault handling. Check cold restart and runtime memory separately. Register +verification and simulated FIFO completion do not establish physical silence, +analog transients, distortion, comfortable maximum output or release readiness. +Merge, release, website work and installation remain separate actions. diff --git a/firmware/esphome/components/voice_pyramid/README.md b/firmware/esphome/components/voice_pyramid/README.md index 797bd19..86b3344 100644 --- a/firmware/esphome/components/voice_pyramid/README.md +++ b/firmware/esphome/components/voice_pyramid/README.md @@ -5,10 +5,15 @@ Audio clock and amplifier adapter for the M5Stack Voice Pyramid A167. Implements the 44.1 kHz Si5351 CLK1 configuration (11.2896 MHz from a 27 MHz crystal) with register readback, and the AW87559 reset/SYSCTRL initialization used in the private 2026-09-24 audio diagnostics. The reference hardware -package supplies the ESPHome ES8311 DAC, I2S speaker, and GPIO expander enable. +package supplies the ESPHome ES8311 DAC, I2S speaker, and checked AW87559 PA +control. Generic amplifier startup remains enabled; +calibrated profiles select `enable_on_startup: false`. PA transitions change only +SYSCTRL bit `0x08` and verify the resulting register. The extracted implementation passed full-recording playback and scheduler validation on the reference unit; see the [device results](../../../../docs/development/feasibility-report.md#integrated-device-validation--2026-09-24). +Those dated results cover the original initialization; checked PA transitions +and the DMA fence still require physical acceptance. Product volume calibration lives in the separate [Voice Pyramid audio adapter](../voice_pyramid_audio/README.md). Its new maximum diff --git a/firmware/esphome/components/voice_pyramid/__init__.py b/firmware/esphome/components/voice_pyramid/__init__.py index fb4a5a5..76b4ea6 100644 --- a/firmware/esphome/components/voice_pyramid/__init__.py +++ b/firmware/esphome/components/voice_pyramid/__init__.py @@ -13,7 +13,8 @@ .extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x1A)), cv.Required("clock"): cv.Schema({cv.GenerateID(): cv.declare_id(Clock)}) .extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x60)), - cv.Required("amplifier"): cv.Schema({cv.GenerateID(): cv.declare_id(Amplifier)}) + cv.Required("amplifier"): cv.Schema({cv.GenerateID(): cv.declare_id(Amplifier), + cv.Optional("enable_on_startup", default=True): cv.boolean}) .extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x5B)), }) @@ -27,3 +28,5 @@ async def to_code(config): var = cg.new_Pvariable(config[key][CONF_ID]) await cg.register_component(var, config[key]) await i2c.register_i2c_device(var, config[key]) + if key == "amplifier": + cg.add(var.set_enable_on_startup(config[key]["enable_on_startup"])) diff --git a/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp b/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp index 63e45d5..f1eedc8 100644 --- a/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp +++ b/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp @@ -31,7 +31,10 @@ void Clock::setup() { void Amplifier::setup() { // Same reset and SYSCTRL values as the successful private audio diagnostic. set_timeout(1000, [this]() { - if (!write_byte(0x00, 0xFF) || !write_byte(0x01, 0x78)) { + uint8_t id{}, control{}; + const uint8_t startup = enable_on_startup_ ? 0x78 : 0x70; + if (!read_byte(0x00, &id) || id != 0x5A || !write_byte(0x00, 0xFF) || + !write_byte(0x01, startup) || !read_byte(0x01, &control) || control != startup) { ESP_LOGE("voice_pyramid", "AW87559 setup failed"); mark_failed(); return; @@ -39,4 +42,12 @@ void Amplifier::setup() { ready_ = true; }); } +bool Amplifier::set_pa_enabled(bool enabled) { + // M5Stack AW87559: SYSCTRL EN_PA is bit 3. Preserve boost, software + // enable and every unrelated bit, then verify the entire resulting register. + uint8_t before{}, after{}; + if (!read_byte(0x01, &before)) return false; + const uint8_t requested = enabled ? before | 0x08 : before & ~0x08; + return write_byte(0x01, requested) && read_byte(0x01, &after) && after == requested; +} } // namespace esphome::voice_pyramid diff --git a/firmware/esphome/components/voice_pyramid/voice_pyramid.h b/firmware/esphome/components/voice_pyramid/voice_pyramid.h index 488f19e..87698b7 100644 --- a/firmware/esphome/components/voice_pyramid/voice_pyramid.h +++ b/firmware/esphome/components/voice_pyramid/voice_pyramid.h @@ -11,9 +11,13 @@ class Clock : public Component, public i2c::I2CDevice { class Amplifier : public Component, public i2c::I2CDevice { public: void setup() override; + void set_enable_on_startup(bool enabled) { enable_on_startup_ = enabled; } + bool set_pa_enabled(bool enabled); + void on_shutdown() override { set_pa_enabled(false); } bool ready() const { return ready_ && !is_failed(); } float get_setup_priority() const override { return setup_priority::HARDWARE; } private: bool ready_{}; + bool enable_on_startup_{true}; }; } // namespace esphome::voice_pyramid diff --git a/firmware/esphome/components/voice_pyramid_audio/README.md b/firmware/esphome/components/voice_pyramid_audio/README.md index 98106e6..8e4548f 100644 --- a/firmware/esphome/components/voice_pyramid_audio/README.md +++ b/firmware/esphome/components/voice_pyramid_audio/README.md @@ -7,9 +7,16 @@ Prayer calculations and scheduling remain in the reusable core. Select `packages/hardware/voice-pyramid-volume.yaml` alongside the reference hardware and product/scheduler packages. It removes the direct speaker-to-DAC -volume route, starts the media player muted, and binds the calibrated playback -and settings store to the scheduler. The optional `validation_id` selects +volume route, starts the amplifier with PA disabled, owns software gain through +the vendored I2S DMA fence, and binds the calibrated playback and settings store +to the scheduler. The optional `validation_id` selects `oa_validation` and supplies that same store to the synthetic timetable harness. +Codec and PA readbacks plus a completed current-generation DMA buffer gate output. +Two seconds without completion faults playback; it never permits unmute. +Media-player volume/mute preferences cannot override calibrated gain. Zero is a +valid applied volume with both gates closed. Athan and supported Quran streams +share this output control; stream capability, ownership, state and playback epoch +are delegated to the existing partition player. Stop revokes that ownership. Integrations using different player/scheduler IDs can retain the generic hardware package without selecting calibration. diff --git a/firmware/esphome/components/voice_pyramid_audio/__init__.py b/firmware/esphome/components/voice_pyramid_audio/__init__.py index 0240376..32bc6c3 100644 --- a/firmware/esphome/components/voice_pyramid_audio/__init__.py +++ b/firmware/esphome/components/voice_pyramid_audio/__init__.py @@ -3,19 +3,20 @@ from esphome.components.voice_pyramid import Clock, Amplifier from esphome.const import CONF_ID -DEPENDENCIES = ["voice_pyramid", "openathan", "openathan_audio", "audio_dac", "switch"] +DEPENDENCIES = ["voice_pyramid", "openathan", "openathan_audio", "audio_dac", "i2s_audio"] ns = cg.esphome_ns.namespace("voice_pyramid_audio") Audio = ns.class_("Audio", cg.Component, cg.global_ns.namespace("openathan").class_("Playback")) Playback = cg.esphome_ns.namespace("openathan_audio").class_("PartitionAudio") ES8311 = cg.esphome_ns.namespace("es8311").class_("ES8311") -Switch = cg.esphome_ns.namespace("switch_").class_("Switch") +from esphome.components.i2s_audio.speaker import I2SAudioSpeakerBase +from esphome import final_validate as fv OpenAthan = cg.esphome_ns.namespace("openathan_component").class_("OpenAthan") Validation = cg.esphome_ns.namespace("openathan_validation").class_("Validation") CONFIG_SCHEMA = cv.Schema({ cv.GenerateID(): cv.declare_id(Audio), cv.Required("playback_id"): cv.use_id(Playback), cv.Required("dac_id"): cv.use_id(ES8311), - cv.Required("enable_id"): cv.use_id(Switch), + cv.Required("speaker_id"): cv.use_id(I2SAudioSpeakerBase), cv.Required("clock_id"): cv.use_id(Clock), cv.Required("amplifier_id"): cv.use_id(Amplifier), cv.Required("scheduler_id"): cv.use_id(OpenAthan), @@ -23,11 +24,24 @@ }).extend(cv.COMPONENT_SCHEMA) +def _final_validate(config): + full = fv.full_config.get() + path = full.get_path_for_id(config["speaker_id"])[:-1] + if not full.get_config_for_path(path).get("volume_fence"): + raise cv.Invalid("voice_pyramid_audio requires volume_fence on its speaker") + amp_path = full.get_path_for_id(config["amplifier_id"])[:-1] + if full.get_config_for_path(amp_path).get("enable_on_startup", True): + raise cv.Invalid("voice_pyramid_audio requires amplifier enable_on_startup: false") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = (cg.new_Pvariable(config[CONF_ID], "oa_validation") if "validation_id" in config else cg.new_Pvariable(config[CONF_ID])) await cg.register_component(var, config) - for key in ("playback", "dac", "enable", "clock", "amplifier"): + for key in ("playback", "dac", "speaker", "clock", "amplifier"): cg.add(getattr(var, "set_" + key)(await cg.get_variable(config[key + "_id"]))) scheduler = await cg.get_variable(config["scheduler_id"]) cg.add(scheduler.set_settings_store(var.settings_store())) diff --git a/firmware/esphome/components/voice_pyramid_audio/audio.h b/firmware/esphome/components/voice_pyramid_audio/audio.h index 0d8d1de..5cb9acb 100644 --- a/firmware/esphome/components/voice_pyramid_audio/audio.h +++ b/firmware/esphome/components/voice_pyramid_audio/audio.h @@ -3,7 +3,8 @@ #include "volume_store.h" #include "esphome/components/es8311/es8311.h" #include "esphome/components/openathan_audio/partition_audio.h" -#include "esphome/components/switch/switch.h" +#include "esphome/components/i2s_audio/speaker/i2s_audio_speaker.h" +#include namespace esphome::voice_pyramid_audio { class Audio : public Component, public ::openathan::Playback { @@ -11,71 +12,112 @@ class Audio : public Component, public ::openathan::Playback { explicit Audio(std::string storage_namespace = openathan_storage::PRAYER) : settings_store_(std::move(storage_namespace)) {} void set_playback(openathan_audio::PartitionAudio *value) { playback_ = value; } void set_dac(es8311::ES8311 *value) { dac_ = value; } - void set_enable(switch_::Switch *value) { enable_ = value; } + void set_speaker(i2s_audio::I2SAudioSpeakerBase *value) { speaker_ = value; } void set_clock(voice_pyramid::Clock *value) { clock_ = value; } void set_amplifier(voice_pyramid::Amplifier *value) { amplifier_ = value; } ::openathan::SettingsStore *settings_store() { return &settings_store_; } float get_setup_priority() const override { return setup_priority::HARDWARE; } - void setup() override { enable_->turn_off(); } void loop() override { - if (!hardware_healthy_() || phase_ != Phase::IDLE || !applied_) enable_->turn_off(); - if (!hardware_healthy_()) return; - if (!pending_) { - if (applied_ && phase_ == Phase::IDLE) enable_->turn_on(); + if (is_failed()) return; + if ((clock_ && clock_->is_failed()) || (amplifier_ && amplifier_->is_failed()) || + (dac_ && dac_->is_failed()) || (speaker_ && !speaker_->volume_fence_healthy())) { + fail_(); return; + } + if (!hardware_healthy_()) { + // Losing readiness during a transition cannot reset its fault deadline. + if (applied_ || (deadline_ && esp_timer_get_time() >= *deadline_)) fail_(); return; } - const auto level = playback_->volume_level(); + if (!pending_) return; + if (!deadline_) deadline_ = esp_timer_get_time() + 2000000; + if (esp_timer_get_time() >= *deadline_) { fail_(); return; } if (phase_ == Phase::MUTING) { - if (!level || *level != 0.0f) { playback_->request_volume_level(0.0f); return; } uint8_t gain{}, drc{}; - if (!dac_->set_mute_on() || !dac_->write_byte(0x34, 0x00) || !dac_->set_volume(plan_.codec_level) || + if (!amplifier_->set_pa_enabled(false) || !codec_mute_(true) || + !dac_->write_byte(0x34, 0) || !dac_->set_volume(plan_.codec_level) || !dac_->read_byte(0x32, &gain) || gain != static_cast(plan_.codec_level * 255) || - !dac_->read_byte(0x34, &drc) || drc != 0 || - (*pending_ && !dac_->set_mute_off())) { - mark_failed(); return; - } - phase_ = Phase::APPLYING; - playback_->request_volume_level(plan_.software_level); - } else if (phase_ == Phase::APPLYING) { - if (!level || std::fabs(*level - plan_.software_level) > 0.00001f) { - playback_->request_volume_level(plan_.software_level); return; - } - applied_ = pending_; pending_.reset(); phase_ = Phase::IDLE; - enable_->turn_on(); + !dac_->read_byte(0x34, &drc) || drc != 0) { fail_(); return; } + token_ = speaker_->request_volume_with_fence(plan_.software_level); + phase_ = Phase::FENCING; + } + const auto status = speaker_->volume_fence_status(token_); + if (status == i2s_audio::VolumeFenceStatus::FAILED) { fail_(); return; } + if (status == i2s_audio::VolumeFenceStatus::CANCELLED) { + // Stop or task restart invalidates the token. Re-request without starting + // playback; an entirely cleaned-up speaker may acknowledge immediately. + token_ = speaker_->request_volume_with_fence(plan_.software_level); + return; } + if (status != i2s_audio::VolumeFenceStatus::COMPLETE) return; + if (*pending_ && (!codec_mute_(false) || !amplifier_->set_pa_enabled(true))) { fail_(); return; } + // Zero is a valid applied setting with both hardware gates still closed. + applied_ = pending_; pending_.reset(); phase_ = Phase::IDLE; deadline_.reset(); + } + void on_shutdown() override { + if (speaker_) { speaker_->request_volume_with_fence(0); speaker_->stop(); } + if (dac_) codec_mute_(true); + if (amplifier_) amplifier_->set_pa_enabled(false); } - void on_shutdown() override { enable_->turn_off(); dac_->set_mute_on(); } - bool ready() const override { return hardware_healthy_() && phase_ == Phase::IDLE && applied_ && enable_->state; } + bool ready() const override { return hardware_healthy_() && phase_ == Phase::IDLE && applied_.has_value(); } bool playing() const override { return playback_ && playback_->playing(); } bool start(::openathan::Track track) override { return ready() && playback_->start(track); } - void stop() override { if (playback_) playback_->stop(); } + bool stream_supported() const override { return playback_ && playback_->stream_supported(); } + uint32_t playback_epoch() const override { return playback_ ? playback_->playback_epoch() : 0; } + ::openathan::PlaybackSource source() const override { return playback_ ? playback_->source() : ::openathan::PlaybackSource::NONE; } + ::openathan::StreamState stream_state() const override { return playback_ ? playback_->stream_state() : ::openathan::StreamState::IDLE; } + bool start_stream(const std::string &url) override { return ready() && playback_->start_stream(url); } + void stop() override { + if (playback_) playback_->stop(); + if (speaker_) speaker_->stop(); + } std::optional volume_percent() const override { - if (!ready()) return {}; - const auto level = playback_->volume_level(); - return level && std::fabs(*level - plan_.software_level) <= 0.00001f ? applied_ : std::nullopt; + return ready() && std::fabs(speaker_->fenced_volume() - plan_.software_level) <= 0.00001f ? applied_ : std::nullopt; } bool request_volume_percent(unsigned percent) override { if (percent > 100 || is_failed()) return false; if (pending_ == percent || volume_percent() == percent) return true; pending_ = percent; plan_ = volume_plan(percent); phase_ = Phase::MUTING; - if (enable_) enable_->turn_off(); - if (playback_) playback_->request_volume_level(0.0f); + deadline_.reset(); // A distinct latest request starts its own transition. + // Close the verified gates synchronously before a superseded fence can + // reopen output. loop() repeats this check before changing hardware gain. + if (hardware_healthy_()) { + deadline_ = esp_timer_get_time() + 2000000; + if (!amplifier_->set_pa_enabled(false) || !codec_mute_(true)) { fail_(); return false; } + } + // Invalidate the old generation immediately; this zero request is never + // permission to open either gate. Duplicate requests returned above retain + // the current token and deadline; Stop/restart cancellation does too. + if (speaker_) speaker_->request_volume_with_fence(0); return true; } private: - enum class Phase { IDLE, MUTING, APPLYING }; + enum class Phase { IDLE, MUTING, FENCING }; + bool codec_mute_(bool muted) { + uint8_t value{}; + return (muted ? dac_->set_mute_on() : dac_->set_mute_off()) && + dac_->read_byte(0x31, &value) && (value & 0x60) == (muted ? 0x60 : 0); + } + void fail_() { + if (speaker_) { speaker_->request_volume_with_fence(0); speaker_->stop(); } + if (playback_) playback_->stop(); + if (dac_) codec_mute_(true); + if (amplifier_) amplifier_->set_pa_enabled(false); + applied_.reset(); mark_failed(); + } bool hardware_healthy_() const { return !is_failed() && clock_ && !clock_->is_failed() && amplifier_ && amplifier_->ready() && - dac_ && !dac_->is_failed() && playback_ && playback_->ready(); + dac_ && !dac_->is_failed() && playback_ && playback_->ready() && speaker_ && speaker_->volume_fence_healthy(); } openathan_audio::PartitionAudio *playback_{}; es8311::ES8311 *dac_{}; - switch_::Switch *enable_{}; + i2s_audio::I2SAudioSpeakerBase *speaker_{}; voice_pyramid::Clock *clock_{}; voice_pyramid::Amplifier *amplifier_{}; VolumeSettingsStore settings_store_; VolumePlan plan_{}; std::optional applied_, pending_; + std::optional deadline_; + i2s_audio::VolumeFenceToken token_{}; Phase phase_{Phase::IDLE}; }; } // namespace esphome::voice_pyramid_audio diff --git a/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml b/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml index 57514cf..5d5780d 100644 --- a/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml +++ b/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml @@ -8,14 +8,19 @@ external_components: type: local path: ../../components components: [voice_pyramid_audio] + - source: + type: local + path: ../../../../third_party/esphome-i2s-audio/components + components: [i2s_audio] -switch: - - id: !extend pyramid_speaker_enable - restore_mode: ALWAYS_OFF +voice_pyramid: + amplifier: + enable_on_startup: false speaker: - id: !extend pyramid_speaker audio_dac: !remove + volume_fence: true media_player: - id: !extend athan_player @@ -26,7 +31,7 @@ voice_pyramid_audio: id: pyramid_audio playback_id: athan_audio dac_id: pyramid_dac - enable_id: pyramid_speaker_enable + speaker_id: pyramid_speaker clock_id: pyramid_clock amplifier_id: pyramid_amplifier scheduler_id: athan_scheduler diff --git a/firmware/esphome/packages/hardware/voice-pyramid.yaml b/firmware/esphome/packages/hardware/voice-pyramid.yaml index 1fdeadb..b8fad0d 100644 --- a/firmware/esphome/packages/hardware/voice-pyramid.yaml +++ b/firmware/esphome/packages/hardware/voice-pyramid.yaml @@ -7,22 +7,6 @@ external_components: path: ../../components components: [voice_pyramid] -pi4ioe5v6408: - - id: pyramid_io - i2c_id: pyramid_bus - address: 0x43 - -switch: - - platform: gpio - id: pyramid_speaker_enable - internal: true - pin: - pi4ioe5v6408: pyramid_io - number: 0 - mode: - output: true - restore_mode: ALWAYS_ON - voice_pyramid: clock: id: pyramid_clock diff --git a/tests/i2s_stubs/driver/gpio.h b/tests/i2s_stubs/driver/gpio.h new file mode 100644 index 0000000..2d8f651 --- /dev/null +++ b/tests/i2s_stubs/driver/gpio.h @@ -0,0 +1,3 @@ +#pragma once +constexpr int GPIO_MODE_OUTPUT=1; +inline void gpio_reset_pin(int){} inline void gpio_set_direction(int,int){} inline void gpio_set_level(int,int){} diff --git a/tests/i2s_stubs/driver/i2s_std.h b/tests/i2s_stubs/driver/i2s_std.h new file mode 100644 index 0000000..43b3b9a --- /dev/null +++ b/tests/i2s_stubs/driver/i2s_std.h @@ -0,0 +1,43 @@ +#pragma once +#include "fake_i2s.h" + #include "nvs.h" +using esp_err_t = int; +constexpr int ESP_ERR_NO_MEM=1,ESP_ERR_NOT_SUPPORTED=2,ESP_ERR_INVALID_STATE=3; +inline const char*esp_err_to_name(int){return "fake";} +using gpio_num_t=int; using i2s_port_t=int; using i2s_role_t=int; using i2s_slot_mode_t=int; +using i2s_std_slot_mask_t=int; using i2s_slot_bit_width_t=int; using i2s_mclk_multiple_t=int; +using i2s_clock_src_t=int; using i2s_data_bit_width_t=int; using i2s_chan_handle_t=void*; +constexpr int I2S_ROLE_SLAVE=1,I2S_CLK_SRC_DEFAULT=0,I2S_SLOT_MODE_MONO=1,I2S_SLOT_MODE_STEREO=2, + I2S_STD_SLOT_BOTH=3,I2S_SLOT_BIT_WIDTH_AUTO=0,I2S_GPIO_UNUSED=-1; +struct i2s_std_gpio_config_t {int mclk{},bclk{},ws{},dout{},din{};struct {bool mclk_inv{},bclk_inv{},ws_inv{};} invert_flags;}; +struct i2s_chan_config_t {int id,role;size_t dma_desc_num;uint32_t dma_frame_num;bool auto_clear;int intr_priority;}; +struct i2s_std_clk_config_t {uint32_t sample_rate_hz;int clk_src,mclk_multiple;}; +struct i2s_std_slot_config_t {int slot_bit_width{},ws_width{},slot_mask{};bool msb_right{};}; +#define I2S_STD_PCM_SLOT_DEFAULT_CONFIG(...) i2s_std_slot_config_t{} +#define I2S_STD_MSB_SLOT_DEFAULT_CONFIG(...) i2s_std_slot_config_t{} +#define I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(...) i2s_std_slot_config_t{} +struct i2s_std_config_t {i2s_std_clk_config_t clk_cfg; i2s_std_slot_config_t slot_cfg;i2s_std_gpio_config_t gpio_cfg;}; +struct i2s_chan_info_t {size_t total_dma_buf_size;};struct i2s_event_data_t {}; +struct i2s_event_callbacks_t {bool(*on_sent)(i2s_chan_handle_t,i2s_event_data_t*,void*);}; +inline int i2s_new_channel(const i2s_chan_config_t*,void **out,void*) {*out=fake_i2s::channel_failure?nullptr:reinterpret_cast(1);return fake_i2s::channel_failure?1:0;} +inline int i2s_channel_init_std_mode(void*,const i2s_std_config_t*){return 0;} +inline int i2s_del_channel(void*){fake_i2s::dma.clear();fake_i2s::written=0;return 0;} +inline int i2s_channel_disable(void*){if(fake_i2s::on_disable)fake_i2s::on_disable();return 0;} +inline int i2s_channel_enable(void*){return fake_i2s::enable_failure?1:0;} +inline int i2s_channel_get_info(void*,i2s_chan_info_t*out){out->total_dma_buf_size=5*fake_i2s::dma_bytes+(fake_i2s::invalid_dma_layout?1:0);return 0;} +inline int i2s_channel_preload_data(void*,const void*d,size_t n,size_t*out){auto p=static_cast(d);fake_i2s::dma.emplace_back(p,p+n);*out=fake_i2s::preload_partial?n-1:n;return 0;} +inline int i2s_channel_register_event_callback(void*,const i2s_event_callbacks_t*c,void*ctx){ + fake_i2s::callback=c->on_sent;fake_i2s::context=ctx;return fake_i2s::callback_failure?1:0; +} +inline int i2s_channel_write(void*,const void*d,size_t n,size_t*out,TickType_t){ + using namespace fake_i2s; + if(on_write)on_write(); + if(partial_write){*out=n-1;return 0;} + if(!written){ + assert(dma.size()==5);transmitted.push_back(dma.front());dma.pop_front(); + now+=10000;if(!drop_events)callback(nullptr,nullptr,context); + dma.emplace_back(); + } + const auto*p=static_cast(d);dma.back().insert(dma.back().end(),p,p+n);written+=n; + assert(written<=dma_bytes);if(written==dma_bytes)written=0;*out=n;return 0; +} diff --git a/tests/i2s_stubs/esp_idf_version.h b/tests/i2s_stubs/esp_idf_version.h new file mode 100644 index 0000000..122c67a --- /dev/null +++ b/tests/i2s_stubs/esp_idf_version.h @@ -0,0 +1,3 @@ +#pragma once +#define ESP_IDF_VERSION 50505 +#define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c)) diff --git a/tests/i2s_stubs/esphome/components/audio/audio.h b/tests/i2s_stubs/esphome/components/audio/audio.h new file mode 100644 index 0000000..faaa785 --- /dev/null +++ b/tests/i2s_stubs/esphome/components/audio/audio.h @@ -0,0 +1,18 @@ +#pragma once +#include "fake_i2s.h" +namespace esphome::audio { +class AudioStreamInfo { + public: + AudioStreamInfo(uint8_t b=16,uint8_t c=1,uint32_t r=44100):bits_(b),channels_(c),rate_(r){} + uint8_t get_channels()const{return channels_;}uint8_t get_bits_per_sample()const{return bits_;} + uint32_t get_sample_rate()const{return rate_;} + size_t samples_to_bytes(size_t n)const{return n*bits_/8;} + size_t frames_to_bytes(size_t n)const{return samples_to_bytes(n)*channels_;} + uint32_t bytes_to_frames(size_t n)const{return n/frames_to_bytes(1);} + uint32_t ms_to_frames(uint32_t ms)const{return ms*rate_/1000;} + size_t ms_to_bytes(uint32_t ms)const{return frames_to_bytes(ms_to_frames(ms));} + int64_t frames_to_microseconds(uint32_t n)const{return static_cast(n)*1000000/rate_;} + bool operator==(const AudioStreamInfo&)const=default; + private:uint8_t bits_,channels_;uint32_t rate_; +}; +} diff --git a/tests/i2s_stubs/esphome/components/audio/audio_transfer_buffer.h b/tests/i2s_stubs/esphome/components/audio/audio_transfer_buffer.h new file mode 100644 index 0000000..c2e414f --- /dev/null +++ b/tests/i2s_stubs/esphome/components/audio/audio_transfer_buffer.h @@ -0,0 +1,19 @@ +#pragma once +#include "esphome/components/ring_buffer/ring_buffer.h" +namespace esphome::audio { +class RingBufferAudioSource { + public: + static std::unique_ptrcreate(std::shared_ptrr,size_t n,uint8_t){ + if(!r||fake_i2s::source_allocation_failure)return {};return std::unique_ptr(new RingBufferAudioSource(r,n)); + } + size_t fill(TickType_t,bool){ + if(fake_i2s::on_fill)fake_i2s::on_fill(); + size_t n=std::min(size_-data_.size(),ring_->bytes.size()); + for(size_t i=0;ibytes.front());ring_->bytes.pop_front();}return n; + } + size_t available()const{return data_.size();}uint8_t*mutable_data(){return data_.data();} + void consume(size_t n){data_.erase(data_.begin(),data_.begin()+n);} + private:RingBufferAudioSource(std::shared_ptrr,size_t n):ring_(r),size_(n){} + std::shared_ptrring_;size_t size_;std::vectordata_; +}; +} diff --git a/tests/i2s_stubs/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/tests/i2s_stubs/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h new file mode 100644 index 0000000..404d6cc --- /dev/null +++ b/tests/i2s_stubs/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -0,0 +1,2 @@ +#pragma once +#include "i2s_audio_speaker_standard.h" diff --git a/tests/i2s_stubs/esphome/components/ring_buffer/ring_buffer.h b/tests/i2s_stubs/esphome/components/ring_buffer/ring_buffer.h new file mode 100644 index 0000000..7160494 --- /dev/null +++ b/tests/i2s_stubs/esphome/components/ring_buffer/ring_buffer.h @@ -0,0 +1,10 @@ +#pragma once +#include "fake_i2s.h" +namespace esphome::ring_buffer { +class RingBuffer { + public:std::deque bytes; + static std::shared_ptrcreate(size_t){return fake_i2s::allocation_failure||fake_i2s::ring_allocation_failure?nullptr:std::make_shared();} + size_t available()const{return bytes.size();} + size_t write_without_replacement(void*d,size_t n,TickType_t){auto p=static_cast(d);bytes.insert(bytes.end(),p,p+n);return n;} +}; +} diff --git a/tests/i2s_stubs/esphome/components/speaker/speaker.h b/tests/i2s_stubs/esphome/components/speaker/speaker.h new file mode 100644 index 0000000..116b29a --- /dev/null +++ b/tests/i2s_stubs/esphome/components/speaker/speaker.h @@ -0,0 +1,17 @@ +#pragma once +#include "fake_i2s.h" +#include "esphome/components/audio/audio.h" +namespace esphome::speaker { +enum State{STATE_STOPPED,STATE_STARTING,STATE_RUNNING,STATE_STOPPING}; +class Speaker { + public: + virtual ~Speaker()=default; + virtual void start()=0;virtual void stop()=0;virtual void finish()=0; + virtual void set_pause_state(bool)=0;virtual bool get_pause_state()const=0; + virtual size_t play(const uint8_t*,size_t,TickType_t)=0;virtual size_t play(const uint8_t*,size_t)=0; + virtual bool has_buffered_data()const=0;virtual void set_volume(float)=0;virtual void set_mute_state(bool)=0; + void set_audio_stream_info(audio::AudioStreamInfo i){audio_stream_info_=i;} + protected:State state_=STATE_STOPPED;float volume_=1;bool mute_state_{};audio::AudioStreamInfo audio_stream_info_; + std::function audio_output_callback_=[](auto,auto){}; +}; +} diff --git a/tests/i2s_stubs/esphome/core/application.h b/tests/i2s_stubs/esphome/core/application.h new file mode 100644 index 0000000..6f70f09 --- /dev/null +++ b/tests/i2s_stubs/esphome/core/application.h @@ -0,0 +1 @@ +#pragma once diff --git a/tests/i2s_stubs/esphome/core/component.h b/tests/i2s_stubs/esphome/core/component.h new file mode 100644 index 0000000..b6d7e66 --- /dev/null +++ b/tests/i2s_stubs/esphome/core/component.h @@ -0,0 +1,16 @@ +#pragma once +#include +namespace esphome { +namespace setup_priority { constexpr float IO=900,HARDWARE=800,DATA=600,PROCESSOR=400; } +class Component { + public: + virtual ~Component()=default; + virtual void setup(){} virtual void loop(){} virtual void on_shutdown(){} + virtual void dump_config(){} virtual float get_setup_priority()const{return 0;} + void mark_failed(){failed_=true;} bool is_failed()const{return failed_;} bool is_ready()const{return !failed_;} + bool status_has_error()const{return error_;} void status_clear_error(){error_=false;} + void status_momentary_error(const char*,unsigned){error_=true;} + void set_timeout(unsigned,std::function f){f();} + private: bool failed_{},error_{}; +}; +} diff --git a/tests/i2s_stubs/esphome/core/defines.h b/tests/i2s_stubs/esphome/core/defines.h new file mode 100644 index 0000000..6f70f09 --- /dev/null +++ b/tests/i2s_stubs/esphome/core/defines.h @@ -0,0 +1 @@ +#pragma once diff --git a/tests/i2s_stubs/esphome/core/gpio.h b/tests/i2s_stubs/esphome/core/gpio.h new file mode 100644 index 0000000..6f70f09 --- /dev/null +++ b/tests/i2s_stubs/esphome/core/gpio.h @@ -0,0 +1 @@ +#pragma once diff --git a/tests/i2s_stubs/esphome/core/hal.h b/tests/i2s_stubs/esphome/core/hal.h new file mode 100644 index 0000000..58ebdb2 --- /dev/null +++ b/tests/i2s_stubs/esphome/core/hal.h @@ -0,0 +1,4 @@ +#pragma once +#include "fake_i2s.h" +#define IRAM_ATTR +namespace esphome {inline uint32_t millis(){return fake_i2s::now/1000;}} diff --git a/tests/i2s_stubs/esphome/core/helpers.h b/tests/i2s_stubs/esphome/core/helpers.h new file mode 100644 index 0000000..5375cdc --- /dev/null +++ b/tests/i2s_stubs/esphome/core/helpers.h @@ -0,0 +1,14 @@ +#pragma once +#include "fake_i2s.h" +namespace esphome { +templateusing optional=std::optional; +templateclass Parented{public:void set_parent(T*p){parent_=p;}protected:T*parent_{};}; +struct Mutex { + bool locked{}; + void lock(){assert(!locked);locked=true;} + bool try_lock(){if(locked)return false;locked=true;return true;} + void unlock(){assert(locked);locked=false;} +}; +templatestruct RAMAllocator{T*allocate(size_t n){return fake_i2s::allocation_failure?nullptr:new T[n];}void deallocate(T*p,size_t){delete[]p;}}; +templateO remap(I x,I lo,I hi,O a,O b){return a+(x-lo)*(b-a)/(hi-lo);} +} diff --git a/tests/i2s_stubs/esphome/core/log.h b/tests/i2s_stubs/esphome/core/log.h new file mode 100644 index 0000000..72744e6 --- /dev/null +++ b/tests/i2s_stubs/esphome/core/log.h @@ -0,0 +1,7 @@ +#pragma once +#define ESP_LOGE(...) ((void)0) +#define ESP_LOGW(...) ((void)0) +#define ESP_LOGI(...) ((void)0) +#define ESP_LOGD(...) ((void)0) +#define ESP_LOGV(...) ((void)0) +#define ESP_LOGCONFIG(...) ((void)0) diff --git a/tests/i2s_stubs/fake_i2s.h b/tests/i2s_stubs/fake_i2s.h new file mode 100644 index 0000000..dc55589 --- /dev/null +++ b/tests/i2s_stubs/fake_i2s.h @@ -0,0 +1,70 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +struct i2s_event_data_t; +namespace fake_i2s { +struct Queue { size_t capacity, width; std::deque> entries; }; +struct Exit {}; +inline std::vector queues; +inline bool defer_isr_bits{}; +inline int64_t now{}; +inline bool allocation_failure{}, partial_write{}, drop_events{}, callback_failure{}, channel_failure{}, invalid_dma_layout{}; +inline bool ring_allocation_failure{}, source_allocation_failure{}, task_creation_failure{}, preload_partial{}, enable_failure{}; +inline std::function on_write, on_fill, on_disable; +inline void (*task)(void *); inline void *task_arg{}; +inline std::deque> dma; +inline std::vector> transmitted; +inline size_t dma_bytes = 8, written{}; +inline bool (*callback)(void *, i2s_event_data_t *, void *); inline void *context{}; +inline void reset() { + now=0; defer_isr_bits=false; allocation_failure=partial_write=drop_events=callback_failure=channel_failure=invalid_dma_layout=false;queues.clear(); + ring_allocation_failure=source_allocation_failure=task_creation_failure=preload_partial=enable_failure=false; + on_write={}; on_fill={}; on_disable={}; task=nullptr; dma.clear(); transmitted.clear(); written=0; callback=nullptr; +} +} +using TickType_t = uint32_t; using BaseType_t = int; using EventBits_t = uint32_t; +using QueueHandle_t = fake_i2s::Queue *; using EventGroupHandle_t = uint32_t *; using TaskHandle_t = void *; +constexpr int pdTRUE=1, pdFALSE=0; constexpr TickType_t portMAX_DELAY=UINT32_MAX; +#define pdMS_TO_TICKS(x) (x) +using portMUX_TYPE = int; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define portENTER_CRITICAL(x) ((void)(x)) +#define portENTER_CRITICAL_ISR(x) ((void)(x)) +#define portEXIT_CRITICAL_ISR(x) ((void)(x)) +#define portEXIT_CRITICAL(x) ((void)(x)) +inline EventGroupHandle_t xEventGroupCreate() { return new uint32_t(0); } +inline uint32_t xEventGroupGetBits(EventGroupHandle_t g) { return *g; } +inline uint32_t xEventGroupSetBits(EventGroupHandle_t g,uint32_t b) { return *g|=b; } +inline uint32_t xEventGroupClearBits(EventGroupHandle_t g,uint32_t b) {auto old=*g; *g&=~b;return old;} +inline void xEventGroupSetBitsFromISR(EventGroupHandle_t g,uint32_t b,BaseType_t*) { if(!fake_i2s::defer_isr_bits)*g|=b; } +inline QueueHandle_t xQueueCreate(size_t n,size_t w) { + if(fake_i2s::allocation_failure)return nullptr; + auto q=new fake_i2s::Queue{n,w,{}};fake_i2s::queues.push_back(q);return q; +} +inline void xQueueReset(QueueHandle_t q) {q->entries.clear();} +inline int xQueueSend(QueueHandle_t q,const void *data,TickType_t) { + if(q->entries.size()==q->capacity)return pdFALSE; + auto p=static_cast(data);q->entries.emplace_back(p,p+q->width);return pdTRUE; +} +inline int xQueueReceive(QueueHandle_t q,void *data,TickType_t) { + if(q->entries.empty())return pdFALSE; + std::memcpy(data,q->entries.front().data(),q->width);q->entries.pop_front();return pdTRUE; +} +inline size_t uxQueueMessagesWaiting(QueueHandle_t q){return q->entries.size();} +inline int xQueueIsQueueFullFromISR(QueueHandle_t q){return q->entries.size()==q->capacity;} +inline int xQueueReceiveFromISR(QueueHandle_t q,void*d,BaseType_t*){return xQueueReceive(q,d,0);} +inline int xQueueSendToBackFromISR(QueueHandle_t q,const void*d,BaseType_t*){return xQueueSend(q,d,0);} +inline void xTaskCreate(void (*f)(void*),const char*,size_t,void *arg,int,TaskHandle_t*out) { + if(fake_i2s::task_creation_failure){*out=nullptr;return;} + fake_i2s::task=f;fake_i2s::task_arg=arg;*out=reinterpret_cast(1); +} +inline void vTaskDelete(TaskHandle_t) {} +inline void vTaskDelay(TickType_t) {throw fake_i2s::Exit{};} diff --git a/tests/i2s_stubs/freertos/FreeRTOS.h b/tests/i2s_stubs/freertos/FreeRTOS.h new file mode 100644 index 0000000..f1e9f0a --- /dev/null +++ b/tests/i2s_stubs/freertos/FreeRTOS.h @@ -0,0 +1,2 @@ +#pragma once +#include "fake_i2s.h" diff --git a/tests/i2s_stubs/freertos/event_groups.h b/tests/i2s_stubs/freertos/event_groups.h new file mode 100644 index 0000000..f1e9f0a --- /dev/null +++ b/tests/i2s_stubs/freertos/event_groups.h @@ -0,0 +1,2 @@ +#pragma once +#include "fake_i2s.h" diff --git a/tests/i2s_stubs/freertos/queue.h b/tests/i2s_stubs/freertos/queue.h new file mode 100644 index 0000000..f1e9f0a --- /dev/null +++ b/tests/i2s_stubs/freertos/queue.h @@ -0,0 +1,2 @@ +#pragma once +#include "fake_i2s.h" diff --git a/tests/i2s_stubs/gain.h b/tests/i2s_stubs/gain.h new file mode 100644 index 0000000..3800931 --- /dev/null +++ b/tests/i2s_stubs/gain.h @@ -0,0 +1,13 @@ +#pragma once +#include "fake_i2s.h" +namespace esp_audio_libs::gain { +inline int32_t db_to_q31(float db){return static_cast(std::pow(10.0,db/20.0)*INT32_MAX);} +inline void apply(uint8_t*out,uint8_t*in,int32_t factor,uint32_t n,size_t width){ + for(uint32_t i=0;i(static_cast(sample)<<(32-width*8))>>(32-width*8); + const int32_t scaled=static_cast(sample)*factor/(int64_t(1)<<31); + std::memcpy(out+i*width,&scaled,width); + } +} +} diff --git a/tests/i2s_stubs/hal/dma_types.h b/tests/i2s_stubs/hal/dma_types.h new file mode 100644 index 0000000..2049fdd --- /dev/null +++ b/tests/i2s_stubs/hal/dma_types.h @@ -0,0 +1,2 @@ +#pragma once +#define DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED 4092 diff --git a/tests/i2s_stubs/pcm_convert.h b/tests/i2s_stubs/pcm_convert.h new file mode 100644 index 0000000..494c89c --- /dev/null +++ b/tests/i2s_stubs/pcm_convert.h @@ -0,0 +1,7 @@ +#pragma once +#include "fake_i2s.h" +namespace esp_audio_libs::pcm_convert { +inline void copy_frames(uint8_t*out,uint8_t*in,uint8_t iw,uint8_t ic,uint8_t ow,uint8_t,uint32_t frames){ + for(size_t i=0;i +#include +#include +namespace esphome::i2c { +struct Write { uint8_t reg; std::vector data; }; +class I2CDevice { + public: + std::vector writes; + unsigned fail_at{}; + bool fail_read{}, wrong_readback{}; + uint8_t chip_id{0x5A}; + bool write_bytes(uint8_t reg, const uint8_t *data, size_t size) { + writes.push_back({reg,{data,data+size}}); + return !fail_at || writes.size()!=fail_at; + } + bool write_byte(uint8_t reg, uint8_t value) { return write_bytes(reg,&value,1); } + bool read_bytes(uint8_t reg, uint8_t *data, size_t size) { + if (fail_read) return false; + if (reg == 0 && size == 1) { *data = chip_id; return true; } + for (auto it = writes.rbegin(); it != writes.rend(); ++it) { + if (it->reg == reg && it->data.size() == size) { + std::copy(it->data.begin(), it->data.end(), data); + if (wrong_readback) data[0] ^= 1; + return true; + } + } + return false; + } + bool read_byte(uint8_t reg, uint8_t *value) { return read_bytes(reg, value, 1); } +}; +} diff --git a/tests/pyramid_stubs/esphome/components/switch/switch.h b/tests/pyramid_stubs/esphome/components/switch/switch.h deleted file mode 100644 index a215e88..0000000 --- a/tests/pyramid_stubs/esphome/components/switch/switch.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once -namespace esphome::switch_ { -class Switch { - public: - bool state{}; - void turn_on() { state = true; } - void turn_off() { state = false; } -}; -} diff --git a/tests/pyramid_volume_tests.cpp b/tests/pyramid_volume_tests.cpp index c1e3c9e..7eb9c8e 100644 --- a/tests/pyramid_volume_tests.cpp +++ b/tests/pyramid_volume_tests.cpp @@ -2,6 +2,7 @@ #include "nvs_memory.h" #include #include +#include using namespace esphome; using namespace voice_pyramid_audio; @@ -145,49 +146,210 @@ static void isolated_storage() { assert(nvs_test::committed.contains({"oa_validation", "settings"})); assert(nvs_test::committed.contains({"oa_validation", "pyr_volume"})); } -static void adapter() { - switch_::Switch enable; Clock clock; Amplifier amplifier; es8311::ES8311 dac; - openathan_audio::PartitionAudio playback; Audio audio; - audio.set_enable(&enable); audio.set_clock(&clock); audio.set_amplifier(&lifier); - audio.set_dac(&dac); audio.set_playback(&playback); audio.setup(); - assert(!enable.state && !audio.ready() && !audio.start(::openathan::Track::NORMAL)); - assert(audio.request_volume_percent(64)); audio.loop(); assert(dac.volumes.empty()); - playback.available = true; audio.loop(); assert(!audio.ready()); - amplifier.setup(); audio.loop(); assert(!audio.ready() && !enable.state); - audio.loop(); audio.loop(); assert(audio.ready() && enable.state && audio.volume_percent() == 64); - assert(audio.start(::openathan::Track::NORMAL) && audio.playing()); - assert(!audio.request_volume_percent(101)); - playback.apply_requests = false; - assert(audio.request_volume_percent(100) && !enable.state); - const auto gain_writes = dac.volumes.size(); - audio.loop(); assert(dac.volumes.size() == gain_writes && !audio.ready()); - playback.level = 0; audio.loop(); assert(!audio.ready() && !enable.state); - audio.loop(); assert(!audio.ready()); // Dropped software-volume request stays gated. - playback.level = 1; audio.loop(); - assert(audio.ready() && audio.volume_percent() == 100 && dac.volumes.back() == 0.75f); - playback.apply_requests = true; - assert(audio.request_volume_percent(0)); audio.loop(); audio.loop(); - assert(audio.volume_percent() == 0 && playback.level == 0); - assert(audio.request_volume_percent(1)); audio.loop(); audio.loop(); - assert(audio.volume_percent() == 1 && dac.volumes.back() < 0.75f); - audio.stop(); assert(!audio.playing()); - playback.available = false; audio.loop(); assert(!enable.state && !audio.ready()); - playback.available = true; audio.loop(); assert(audio.ready()); - audio.on_shutdown(); assert(!enable.state); - for (unsigned failure = 0; failure < 6; ++failure) { - switch_::Switch failed_enable; Clock failed_clock; Amplifier failed_amp; - es8311::ES8311 failed_dac; openathan_audio::PartitionAudio failed_playback; Audio failed; - failed.set_enable(&failed_enable); failed.set_clock(&failed_clock); failed.set_amplifier(&failed_amp); - failed.set_dac(&failed_dac); failed.set_playback(&failed_playback); - failed_amp.setup(); failed_playback.available = true; failed.setup(); - failed_dac.mute_write_ok = failure != 0; failed_dac.volume_write_ok = failure != 1; - failed_dac.unmute_write_ok = failure != 2; - failed_dac.register_write_ok = failure != 3; - failed_dac.register_read_ok = failure != 4; - failed_dac.wrong_readback = failure == 5; - assert(failed.request_volume_percent(80)); failed.loop(); - assert(failed.is_failed() && !failed_enable.state && !failed.ready()); - assert(!failed.request_volume_percent(20) && !failed.start(::openathan::Track::FAJR)); +int64_t esp_timer_get_time() { return fake_i2s::now; } +namespace esphome::i2s_audio { +struct VolumeFenceTestAccess { + static void exhaust(VolumeFenceState &state) { state.generation_ = UINT32_MAX; } + static void exhaust_session(VolumeFenceState &state) { state.session_ = UINT32_MAX; } +}; +} +using i2s_audio::VolumeFenceStatus; +struct Fixture { + Clock clock; Amplifier amplifier; es8311::ES8311 dac; + i2s_audio::I2SAudioComponent bus; i2s_audio::I2SAudioSpeaker speaker; + openathan_audio::PartitionAudio playback; Audio audio; + Fixture(bool hardware=true) { + fake_i2s::reset(); + speaker.set_parent(&bus); speaker.set_dout_pin(7);speaker.set_slot_bit_width(16); + speaker.set_buffer_duration(500);speaker.set_timeout(100000);speaker.set_volume_fence(true);speaker.setup(); + amplifier.set_enable_on_startup(false); + audio.set_speaker(&speaker);audio.set_clock(&clock);audio.set_amplifier(&lifier); + audio.set_dac(&dac);audio.set_playback(&playback); + if(hardware){amplifier.setup();playback.available=true;} + } + bool enabled() { uint8_t value{};assert(amplifier.read_byte(1,&value));return value&8; } + void apply(unsigned value) {assert(audio.request_volume_percent(value));audio.loop();assert(audio.volume_percent()==value);} + void run() { + speaker.start();speaker.loop(); + if(!fake_i2s::task){assert(!speaker.volume_fence_healthy());speaker.loop();return;} + try {fake_i2s::task(fake_i2s::task_arg);}catch(const fake_i2s::Exit&){} + fake_i2s::on_write={};fake_i2s::on_fill={};speaker.loop(); + } +}; +static void amplifier_control() { + for(bool enabled:{false,true}) { + Amplifier amp;amp.set_enable_on_startup(enabled);amp.setup();assert(amp.ready()); + uint8_t control{};assert(amp.read_byte(1,&control)&&control==(enabled?0x78:0x70)); + assert(amp.write_byte(1,0xF7)&&.set_pa_enabled(true)); + assert(amp.read_byte(1,&control)&&control==0xFF); + assert(amp.set_pa_enabled(false)&&.read_byte(1,&control)&&control==0xF7); + amp.on_shutdown();assert(amp.read_byte(1,&control)&&control==0xF7); + } + for(unsigned failure=0;failure<5;++failure) { + Amplifier amp;amp.set_enable_on_startup(false); + amp.fail_at=failure==0?1:failure==1?2:0;amp.fail_read=failure==2; + amp.wrong_readback=failure==3;if(failure==4)amp.chip_id=0; + amp.setup();assert(amp.is_failed()&&!amp.ready()); + } +} +static void adapter_idle_and_faults() { + Fixture startup(false);assert(startup.audio.request_volume_percent(64)); + fake_i2s::now=3000000;startup.audio.loop();assert(!startup.audio.is_failed()&&!startup.audio.ready()); + startup.amplifier.setup();startup.playback.available=true;startup.audio.loop(); + assert(startup.audio.ready()&&startup.enabled()&&startup.audio.volume_percent()==64); + assert(!startup.audio.request_volume_percent(101)); + startup.apply(0);assert(!startup.enabled()&&startup.dac.mute==0x60&&startup.speaker.fenced_volume()==0); + startup.apply(1);assert(startup.enabled());startup.audio.on_shutdown();assert(!startup.enabled()); + for(unsigned failure=0;failure<11;++failure) { + Fixture f; + f.dac.mute_write_ok=failure!=0;f.dac.volume_write_ok=failure!=1;f.dac.unmute_write_ok=failure!=2; + f.dac.register_write_ok=failure!=3;f.dac.register_read_ok=failure!=4; + f.dac.wrong_readback=failure==5;f.dac.wrong_mute_readback=failure==6; + f.amplifier.fail_at=failure==7?f.amplifier.writes.size()+1:0; + f.amplifier.wrong_readback=failure==8; + if(failure==9)f.amplifier.fail_at=f.amplifier.writes.size()+3; + f.dac.wrong_unmute_readback=failure==10; + f.audio.request_volume_percent(80);f.audio.loop(); + assert(f.audio.is_failed()&&!f.audio.ready()&&!f.audio.start(::openathan::Track::FAJR)); + assert(f.speaker.fenced_volume()==0); + } + Fixture lost;lost.apply(56);lost.playback.available=false;lost.audio.loop();assert(lost.audio.is_failed()&&!lost.enabled()); +} +static void streaming_handoff() { + Fixture f;const std::string url="https://cdn.mp3quran.net/audio/test/001.mp3"; + assert(f.audio.stream_supported()&&!f.audio.start_stream(url)); + f.apply(56);assert(f.audio.start_stream(url)); + assert(f.audio.source()==::openathan::PlaybackSource::QURAN); + assert(f.audio.stream_state()==::openathan::StreamState::PLAYING); + assert(f.audio.playback_epoch()==f.playback.playback_epoch()); + auto epoch=f.audio.playback_epoch();f.audio.stop(); + assert(f.audio.playback_epoch()!=epoch&&f.audio.source()==::openathan::PlaybackSource::NONE); + assert(f.audio.start(::openathan::Track::FAJR)&&f.audio.source()==::openathan::PlaybackSource::ATHAN); +} +static void dma_fence() { + Fixture f;f.apply(100); + unsigned buffers=0;bool requested=false,completed=false,old_tail=false; + const int16_t pcm[4]={20000,20000,20000,20000}; + fake_i2s::on_fill=[&]{f.speaker.loop();f.speaker.play(reinterpret_cast(pcm),sizeof(pcm));}; + fake_i2s::on_write=[&]{ + ++buffers;f.speaker.loop(); + if(buffers==8){ + requested=true;assert(f.audio.request_volume_percent(20));f.audio.loop(); + // The logical media player already reports the new level, while five old + // descriptors remain in the fake driver. It cannot authorize unmute. + f.playback.level=volume_plan(20).software_level; + const auto generation=f.speaker.request_volume_with_fence(volume_plan(20).software_level); + assert(f.speaker.volume_fence_status(generation)==VolumeFenceStatus::PENDING); + } + if(requested){ + f.audio.loop(); + if(!f.audio.ready())assert(!f.enabled()&&f.dac.mute==0x60); + else completed=true; + if(!fake_i2s::dma.empty()) {int16_t sample{};std::memcpy(&sample,fake_i2s::dma.front().data(),2); + if(sample==20000){old_tail=true;assert(!f.enabled());}} } + if(buffers==24)f.speaker.stop(); + }; + f.run();assert(requested&&old_tail&&completed&&f.audio.volume_percent()==20); + // Restored/queued media-player preferences cannot change owned attenuation. + auto before=f.speaker.fenced_volume();f.speaker.set_volume(1);f.speaker.set_mute_state(true); + f.speaker.set_mute_state(false);assert(f.speaker.fenced_volume()==before); +} +static void rapid_pause_stop_restart() { + Fixture f;f.apply(100);assert(f.audio.start(::openathan::Track::NORMAL)); + unsigned buffers=0;bool resumed=false;size_t resumed_after=0; + const int32_t pcm[8]={INT32_MAX/2,INT32_MAX/2,INT32_MAX/2,INT32_MAX/2,INT32_MAX/2,INT32_MAX/2,INT32_MAX/2,INT32_MAX/2}; + f.speaker.set_audio_stream_info({32,1,44100}); + unsigned fills=0; + fake_i2s::on_fill=[&]{f.speaker.loop();++fills; + // A short first chunk followed by a full source fill leaves a staged raw + // frame beyond the current DMA descriptor, then pause emits silence fences. + const size_t bytes=fills==6?sizeof(int32_t):4*sizeof(int32_t); + f.speaker.play(reinterpret_cast(pcm),bytes); + }; + fake_i2s::on_write=[&]{ + ++buffers;f.speaker.loop(); + if(buffers==7){assert(f.audio.request_volume_percent(50));f.audio.loop(); + auto level=f.speaker.fenced_volume();assert(f.audio.request_volume_percent(50));assert(f.speaker.fenced_volume()==level); + assert(f.audio.request_volume_percent(10));f.audio.loop();f.speaker.set_pause_state(true); + } + f.audio.loop(); + if(buffers>6&&!f.audio.ready())assert(!f.enabled()); + if(buffers>12&&f.audio.ready()&&!resumed){assert(f.audio.volume_percent()==10);resumed=true;resumed_after=fake_i2s::transmitted.size();f.speaker.set_pause_state(false);} + if(buffers==26){assert(f.audio.request_volume_percent(40));f.audio.loop();f.audio.stop();} + }; + f.run();assert(resumed&&!f.audio.playing()); + const int32_t factor=esp_audio_libs::gain::db_to_q31(-49*(1-volume_plan(10).software_level)); + const int32_t ceiling=((static_cast(pcm[0])*factor/(int64_t(1)<<31))>>16)+1; + for(size_t i=resumed_after;iwidth==sizeof(i2s_audio::VolumeDmaRecord))q->entries.clear(); + for(unsigned i=0;i<6;++i)fake_i2s::callback(nullptr,nullptr,fake_i2s::context);} + if(fault==6){fake_i2s::defer_isr_bits=true;for(unsigned i=0;i<12;++i)fake_i2s::callback(nullptr,nullptr,fake_i2s::context); + assert(!f.speaker.volume_fence_healthy());}} + f.audio.loop();if(writes>20)f.speaker.stop(); + }; + if(fault==0)fake_i2s::on_fill=[&]{f.speaker.loop();const int16_t pcm[4]={20000,20000,20000,20000}; + f.speaker.play(reinterpret_cast(pcm),sizeof(pcm));}; + if(fault==4)fake_i2s::allocation_failure=true; + f.run();f.audio.loop(); + if(fault==3){f.audio.loop();assert(f.audio.ready()&&f.playback.starts==0);} + else {assert(f.audio.is_failed()&&!f.audio.ready()&&!f.enabled());assert(f.speaker.fenced_volume()==0);} + } + for(unsigned failure=0;failure<7;++failure){ + Fixture f;f.apply(100); + fake_i2s::ring_allocation_failure=failure==0;fake_i2s::source_allocation_failure=failure==1; + fake_i2s::task_creation_failure=failure==2;fake_i2s::preload_partial=failure==3; + fake_i2s::enable_failure=failure==4;fake_i2s::channel_failure=failure==5; + if(failure==6){f.speaker.start();f.speaker.loop();fake_i2s::allocation_failure=true; + try{fake_i2s::task(fake_i2s::task_arg);}catch(const fake_i2s::Exit&){}f.speaker.loop();} + else f.run(); + f.audio.loop();assert(f.audio.is_failed()&&!f.enabled()); + } + Fixture callback;callback.apply(100);fake_i2s::callback_failure=true;callback.run();callback.audio.loop();assert(callback.audio.is_failed()); + Fixture layout;layout.apply(100);fake_i2s::invalid_dma_layout=true;layout.run();layout.audio.loop();assert(layout.audio.is_failed()); + Fixture pending;pending.apply(56);pending.speaker.start();pending.speaker.stop();pending.speaker.loop(); + assert(!fake_i2s::task);auto cleaned=pending.speaker.request_volume_with_fence(.5f); + assert(pending.speaker.volume_fence_status(cleaned)==VolumeFenceStatus::COMPLETE); + Fixture late;late.apply(100);fake_i2s::on_write=[&]{late.speaker.stop();}; + fake_i2s::on_disable=[&]{fake_i2s::defer_isr_bits=true;for(unsigned i=0;i<12;++i)fake_i2s::callback(nullptr,nullptr,fake_i2s::context);}; + late.run();late.audio.loop();assert(late.audio.is_failed()&&!late.enabled()); + Fixture busy;busy.apply(100);busy.bus.lock();busy.run();busy.audio.loop();assert(busy.audio.is_failed());busy.bus.unlock(); + i2s_audio::VolumeFenceState state;auto token=state.request(.5f,1);assert(state.status(token)==VolumeFenceStatus::COMPLETE); + state.begin_session();assert(state.status(token)==VolumeFenceStatus::CANCELLED); + auto current=state.request(.2f,2);state.complete(token);assert(state.status(current)==VolumeFenceStatus::PENDING); + state.end_session();assert(state.status(current)==VolumeFenceStatus::CANCELLED); + i2s_audio::VolumeFenceTestAccess::exhaust(state);assert(!state.request(1,INT32_MAX).generation&&state.failed()); + i2s_audio::VolumeFenceState session;i2s_audio::VolumeFenceTestAccess::exhaust_session(session);assert(!session.begin_session()&&session.failed()); } -int main() { curves(); storage(); interrupted_saves(); isolated_storage(); adapter(); } +int main() { curves(); storage(); interrupted_saves(); isolated_storage();amplifier_control(); + adapter_idle_and_faults();streaming_handoff();dma_fence();rapid_pause_stop_restart();superseded_deadline();dma_faults_and_deadline(); } diff --git a/tests/test_scheduler_config.py b/tests/test_scheduler_config.py index 0c6062d..885bca4 100644 --- a/tests/test_scheduler_config.py +++ b/tests/test_scheduler_config.py @@ -90,6 +90,9 @@ def test_generic_hardware_retains_dac_volume_control(self): self.assertIn("pyramid_speaker->set_audio_dac(pyramid_dac)", source) self.assertIn("athan_test_scheduler->set_playback(athan_test_audio)", source) self.assertNotIn("voice_pyramid_audio::Audio", source) + self.assertIn("pyramid_amplifier->set_enable_on_startup(true)", source) + self.assertNotIn("set_volume_fence", source) + self.assertNotIn("pyramid_speaker_enable", source) def test_explicit_timezone_required(self): self.validate(self.config(timezone=""), False, "explicit timezone") diff --git a/tests/test_volume_fence.py b/tests/test_volume_fence.py new file mode 100644 index 0000000..9ee1424 --- /dev/null +++ b/tests/test_volume_fence.py @@ -0,0 +1,79 @@ +"""Verify vendored provenance and calibrated-only routing using the real schema.""" +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +VENDOR = ROOT / "third_party/esphome-i2s-audio" + + +class ProvenanceTests(unittest.TestCase): + def test_patch_reconstructs_every_upstream_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + shutil.copytree(VENDOR / "components", root / "components") + result = subprocess.run(["patch", "--batch", "--reverse", "-p1", "-i", str(VENDOR / "volume-fence.patch")], + cwd=root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + hashes = json.loads((VENDOR / "upstream-sha256.json").read_text()) + paths = {str(p.relative_to(root / "components")): p for p in (root / "components").rglob("*") + if p.is_file() and p.suffix in (".py", ".h", ".cpp")} + self.assertEqual(set(paths), set(hashes)) + for name, digest in hashes.items(): + self.assertEqual(hashlib.sha256(paths[name].read_bytes()).hexdigest(), digest, name) + metadata = json.loads((VENDOR / "upstream.json").read_text()) + self.assertEqual(metadata["version"], "2026.9.0") + self.assertEqual(metadata["commit"], "c6e4c87e525dd343e470d8dea368957a002f6505") + self.assertEqual(hashlib.sha256((VENDOR / "LICENSE").read_bytes()).hexdigest(), metadata["license_sha256"]) + license_text = (VENDOR / "LICENSE").read_text() + self.assertIn("Copyright (c) 2019 ESPHome", license_text) + self.assertIn("## MIT License", license_text) + self.assertIn("## GPLv3 License", license_text) + + +@unittest.skipUnless(importlib.util.find_spec("esphome"), "Run with the pinned ESPHome environment") +class FenceConfigTests(unittest.TestCase): + def generate(self, extra="", success=True, message=""): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "secrets.yaml").write_bytes((ROOT / "firmware/esphome/compile-secrets.example.yaml").read_bytes()) + path = root / "test.yaml" + path.write_text(f"packages:\n profile: !include {ROOT}/firmware/esphome/scheduler/validation.yaml\n" + extra) + result = subprocess.run([sys.executable, "-m", "esphome", "compile", "--only-generate", str(path)], + env=dict(os.environ, ESPHOME_BUILD_PATH=str(root / "build")), + capture_output=True, text=True) + output = result.stdout + result.stderr + self.assertEqual(result.returncode == 0, success, output) + if message: + self.assertIn(message, output) + if success: + source = (root / "build/openathan-feasibility/src/main.cpp").read_text() + defines = (root / "build/openathan-feasibility/src/esphome/core/defines.h").read_text() + self.assertIn("pyramid_speaker->set_volume_fence(true)", source) + self.assertIn("pyramid_audio->set_speaker(pyramid_speaker)", source) + self.assertIn("pyramid_amplifier->set_enable_on_startup(false)", source) + self.assertIn("USE_OPENATHAN_VOLUME_FENCE", defines) + self.assertNotIn("pyramid_speaker_enable", source) + self.assertNotIn("pyramid_speaker->set_audio_dac", source) + + def test_calibrated_routing(self): + self.generate() + + def test_fence_required(self): + self.generate("speaker:\n - id: !extend pyramid_speaker\n volume_fence: false\n", False, "requires volume_fence") + + def test_startup_gate_required(self): + self.generate("voice_pyramid:\n amplifier:\n enable_on_startup: true\n", False, "enable_on_startup: false") + + def test_direct_dac_rejected(self): + self.generate("speaker:\n - id: !extend pyramid_speaker\n audio_dac: pyramid_dac\n", False, "volume_fence requires standard I2S") + + def test_spdif_rejected(self): + self.generate("speaker:\n - id: !extend pyramid_speaker\n spdif_mode: true\n channel: stereo\n use_apll: true\n i2s_mode: primary\n", False, "volume_fence requires standard I2S") diff --git a/tests/waveshare_stubs/esphome/components/es8311/es8311.h b/tests/waveshare_stubs/esphome/components/es8311/es8311.h index d3669b3..bae2fe6 100644 --- a/tests/waveshare_stubs/esphome/components/es8311/es8311.h +++ b/tests/waveshare_stubs/esphome/components/es8311/es8311.h @@ -11,14 +11,15 @@ class ES8311 : public Component { unsigned unmute_writes{}; unsigned mute_writes{}; bool register_write_ok{true}, register_read_ok{true}, wrong_readback{}; - uint8_t gain{}, drc{}; + uint8_t gain{}, drc{}, mute{}; + bool wrong_mute_readback{}, wrong_unmute_readback{}; std::vector volumes; bool set_volume(float value) { volumes.push_back(value); gain = static_cast(value * 255); return volume_write_ok; } - bool set_mute_off() { ++unmute_writes; return unmute_write_ok; } - bool set_mute_on() { ++mute_writes; return mute_write_ok; } + bool set_mute_off() { ++unmute_writes; if (unmute_write_ok) mute = 0; return unmute_write_ok; } + bool set_mute_on() { ++mute_writes; if (mute_write_ok) mute = 0x60; return mute_write_ok; } bool write_byte(uint8_t reg, uint8_t value) { if (reg == 0x34) drc = value; return register_write_ok; } bool read_byte(uint8_t reg, uint8_t *value) { - *value = (reg == 0x32 ? gain : drc) ^ (wrong_readback ? 1 : 0); + *value = (reg == 0x31 ? mute ^ (wrong_mute_readback || (wrong_unmute_readback && mute == 0) ? 0x60 : 0) : reg == 0x32 ? gain : drc) ^ (wrong_readback ? 1 : 0); return register_read_ok; } }; diff --git a/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h b/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h index b5fe8c4..aac4351 100644 --- a/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h +++ b/tests/waveshare_stubs/esphome/components/openathan_audio/partition_audio.h @@ -9,11 +9,18 @@ class PartitionAudio : public ::openathan::Playback { unsigned starts{}, stops{}, volume{70}; float level{0.7f}; bool apply_requests{true}; + uint32_t epoch{}; + ::openathan::PlaybackSource kind{::openathan::PlaybackSource::NONE}; std::vector requests; bool ready() const override { return available; } bool playing() const override { return active; } - bool start(::openathan::Track) override { ++starts; return active = true; } - void stop() override { ++stops; active = false; } + bool start(::openathan::Track) override { ++starts; ++epoch; kind = ::openathan::PlaybackSource::ATHAN; return active = true; } + void stop() override { ++stops; ++epoch; active = false; kind = ::openathan::PlaybackSource::NONE; } + bool stream_supported() const override { return true; } + uint32_t playback_epoch() const override { return epoch; } + ::openathan::PlaybackSource source() const override { return kind; } + ::openathan::StreamState stream_state() const override { return active && kind == ::openathan::PlaybackSource::QURAN ? ::openathan::StreamState::PLAYING : ::openathan::StreamState::IDLE; } + bool start_stream(const std::string &) override { ++starts; ++epoch; kind = ::openathan::PlaybackSource::QURAN; return active = true; } std::optional volume_percent() const override { return volume; } bool request_volume_percent(unsigned percent) override { volume = percent; return request_volume_level(percent / 100.0f); } std::optional volume_level() const { return available ? std::optional(level) : std::nullopt; } diff --git a/third_party/esphome-i2s-audio/LICENSE b/third_party/esphome-i2s-audio/LICENSE new file mode 100644 index 0000000..215460a --- /dev/null +++ b/third_party/esphome-i2s-audio/LICENSE @@ -0,0 +1,709 @@ +# ESPHome License + +Copyright (c) 2019 ESPHome + +The ESPHome License is made up of two base licenses: MIT and the GNU GENERAL PUBLIC LICENSE. +The C++/runtime codebase of the ESPHome project (file extensions .c, .cpp, .h, .hpp, .tcc, .ino) are +published under the GPLv3 license. The python codebase and all other parts of this codebase are +published under the MIT license. + +Both MIT and GPLv3 licenses are attached to this document. + +## MIT License + +Copyright (c) 2019 ESPHome + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## GPLv3 License + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/third_party/esphome-i2s-audio/UPSTREAM.md b/third_party/esphome-i2s-audio/UPSTREAM.md new file mode 100644 index 0000000..d49cc04 --- /dev/null +++ b/third_party/esphome-i2s-audio/UPSTREAM.md @@ -0,0 +1,49 @@ +# Pinned ESPHome I2S audio component + +Source: [ESPHome 2026.9.0](https://github.com/esphome/esphome/tree/2026.9.0/esphome/components/i2s_audio), +commit `c6e4c87e525dd343e470d8dea368957a002f6505`. +The complete component is retained, including microphone and SPDIF files; the +calibrated Atom package alone selects this local component. Other configurations +continue to resolve the pinned installed ESPHome component. + +`upstream.json` records the commit and license hash; +`upstream-sha256.json` records each original component file. `volume-fence.patch` +is the maintained patch against those files, including the added header. +The Python provenance regression reverses that patch and checks all hashes. +When updating the pinned version, replace the originals, regenerate the patch, +and rerun host transport tests, schema controls and matched firmware builds. +Do not edit the installed SDK cache. + +The patch adds opt-in `volume_fence: true` for standard software-gain speakers. +It rejects SPDIF and a direct `audio_dac` binding. Restored or queued media-player +volume/mute setters cannot change owned gain in this mode. The adapter calls +`request_volume_with_fence(level)` and polls `volume_fence_status(token)`. +A token contains a session and generation; it is pending, complete, cancelled +or failed. Stop/restart cancels obsolete tokens, counter exhaustion fails closed, +and only a fully cleaned-up stopped speaker can acknowledge without DMA. + +Each DMA descriptor uses one synchronized gain/generation snapshot, with gain +applied to consumed raw PCM before bit-depth narrowing. The existing FIFO record +carries its session/generation, including preloaded and padded silence. A complete +matching descriptor confirms that older FIFO buffers drained. Completion batches +are validated before publishing an acknowledgement. Allocation, callback, DMA +layout, write, overflow and queue faults latch failure through task cleanup; +logical readback and elapsed time cannot complete a fence. +ISR queue failures latch under the same ESP-IDF critical-section lock immediately, +without waiting for deferred event-group updates. Driver teardown rechecks faults +before clearing events or allowing a stopped speaker to complete a fence. + +No audio task, PCM buffer or queue is added. For an opted-in speaker the existing +ten-entry record queue grows from 4 to 12 bytes per entry (+80 heap bytes). +The synchronized state and lock add fixed object storage. DMA descriptors, source +staging, ring buffer duration and the 4096-byte task stack retain upstream sizes; +changed task locals still require attended runtime headroom measurement. + +## License + +The unchanged upstream [LICENSE](LICENSE) includes ESPHome copyright, GPLv3 +runtime/C++ terms and MIT Python terms. Modified upstream files identify the +OpenAthan changes dated 2026-10-11. The new C++ fence header uses GPL-3.0-only; +Python changes retain MIT. These files are exceptions to the repository's +Apache-2.0 software license. Preserve these notices with source and firmware +redistribution; existing third-party dependency terms remain applicable. diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/__init__.py b/third_party/esphome-i2s-audio/components/i2s_audio/__init__.py new file mode 100644 index 0000000..c5e82be --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/__init__.py @@ -0,0 +1,300 @@ +from dataclasses import dataclass, field + +from esphome import pins +import esphome.codegen as cg +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + get_esp32_variant, + include_builtin_idf_component, +) +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE +from esphome.core import CORE +from esphome.cpp_generator import MockObj, MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["esp32"] +MULTI_CONF = True + +CONF_PDM = "pdm" +CONF_PDM_DSR = "pdm_dsr" +CONF_ADC_TYPE = "adc_type" + +CONF_I2S_DOUT_PIN = "i2s_dout_pin" +CONF_I2S_DIN_PIN = "i2s_din_pin" +CONF_I2S_MCLK_PIN = "i2s_mclk_pin" +CONF_I2S_BCLK_PIN = "i2s_bclk_pin" +CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" + +CONF_I2S_AUDIO = "i2s_audio" +CONF_I2S_AUDIO_ID = "i2s_audio_id" + +CONF_I2S_MODE = "i2s_mode" +CONF_PRIMARY = "primary" +CONF_SECONDARY = "secondary" + +CONF_USE_APLL = "use_apll" +CONF_BITS_PER_CHANNEL = "bits_per_channel" +CONF_MCLK_MULTIPLE = "mclk_multiple" +CONF_MONO = "mono" +CONF_LEFT = "left" +CONF_RIGHT = "right" +CONF_STEREO = "stereo" +CONF_BOTH = "both" + +i2s_audio_ns = cg.esphome_ns.namespace("i2s_audio") +I2SAudioComponent = i2s_audio_ns.class_("I2SAudioComponent", cg.Component) +I2SAudioBase = i2s_audio_ns.class_( + "I2SAudioBase", cg.Parented.template(I2SAudioComponent) +) +I2SAudioIn = i2s_audio_ns.class_("I2SAudioIn", I2SAudioBase) +I2SAudioOut = i2s_audio_ns.class_("I2SAudioOut", I2SAudioBase) + +i2s_mode_t = cg.global_ns.enum("i2s_mode_t") +I2S_MODE_OPTIONS = { + CONF_PRIMARY: i2s_mode_t.I2S_MODE_MASTER, # NOLINT + CONF_SECONDARY: i2s_mode_t.I2S_MODE_SLAVE, # NOLINT +} + +i2s_role_t = cg.global_ns.enum("i2s_role_t") +I2S_ROLE_OPTIONS = { + CONF_PRIMARY: i2s_role_t.I2S_ROLE_MASTER, # NOLINT + CONF_SECONDARY: i2s_role_t.I2S_ROLE_SLAVE, # NOLINT +} + +# https://github.com/espressif/esp-idf/blob/master/components/soc/{variant}/include/soc/soc_caps.h (SOC_I2S_NUM) +I2S_PORTS = { + VARIANT_ESP32: 2, + VARIANT_ESP32C3: 1, + VARIANT_ESP32C5: 1, + VARIANT_ESP32C6: 1, + VARIANT_ESP32C61: 1, + VARIANT_ESP32H2: 1, + VARIANT_ESP32P4: 3, + VARIANT_ESP32S2: 1, + VARIANT_ESP32S3: 2, +} + +i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") +I2S_CHANNELS = { + CONF_MONO: i2s_channel_fmt_t.I2S_CHANNEL_FMT_ALL_LEFT, # left data to both channels + CONF_LEFT: i2s_channel_fmt_t.I2S_CHANNEL_FMT_ONLY_LEFT, # mono data + CONF_RIGHT: i2s_channel_fmt_t.I2S_CHANNEL_FMT_ONLY_RIGHT, # mono data + CONF_STEREO: i2s_channel_fmt_t.I2S_CHANNEL_FMT_RIGHT_LEFT, # stereo data to both channels +} + +i2s_slot_mode_t = cg.global_ns.enum("i2s_slot_mode_t") +I2S_SLOT_MODE = { + CONF_MONO: i2s_slot_mode_t.I2S_SLOT_MODE_MONO, + CONF_STEREO: i2s_slot_mode_t.I2S_SLOT_MODE_STEREO, +} + +i2s_std_slot_mask_t = cg.global_ns.enum("i2s_std_slot_mask_t") +I2S_STD_SLOT_MASK = { + CONF_LEFT: i2s_std_slot_mask_t.I2S_STD_SLOT_LEFT, + CONF_RIGHT: i2s_std_slot_mask_t.I2S_STD_SLOT_RIGHT, + CONF_BOTH: i2s_std_slot_mask_t.I2S_STD_SLOT_BOTH, +} + +i2s_bits_per_sample_t = cg.global_ns.enum("i2s_bits_per_sample_t") +I2S_BITS_PER_SAMPLE = { + 8: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_8BIT, + 16: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_16BIT, + 24: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_24BIT, + 32: i2s_bits_per_sample_t.I2S_BITS_PER_SAMPLE_32BIT, +} + +i2s_bits_per_chan_t = cg.global_ns.enum("i2s_bits_per_chan_t") +I2S_BITS_PER_CHANNEL = { + "default": i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_DEFAULT, + 8: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_8BIT, + 16: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_16BIT, + 24: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_24BIT, + 32: i2s_bits_per_chan_t.I2S_BITS_PER_CHAN_32BIT, +} + +i2s_slot_bit_width_t = cg.global_ns.enum("i2s_slot_bit_width_t") +I2S_SLOT_BIT_WIDTH = { + "default": i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_AUTO, + 8: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_8BIT, + 16: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_16BIT, + 24: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_24BIT, + 32: i2s_slot_bit_width_t.I2S_SLOT_BIT_WIDTH_32BIT, +} + +i2s_mclk_multiple_t = cg.global_ns.enum("i2s_mclk_multiple_t") +I2S_MCLK_MULTIPLE = { + 128: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_128, + 256: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_256, + 384: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_384, + 512: i2s_mclk_multiple_t.I2S_MCLK_MULTIPLE_512, +} + +_validate_bits = cv.float_with_unit("bits", "bit") + + +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: + if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: + raise cv.Invalid( + f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" + ) + return config + + +def i2s_audio_component_schema( + class_: MockObjClass, + *, + default_sample_rate: int, + default_channel: str, + default_bits_per_sample: str, +) -> cv.Schema: + return cv.Schema( + { + cv.GenerateID(): cv.declare_id(class_), + cv.GenerateID(CONF_I2S_AUDIO_ID): cv.use_id(I2SAudioComponent), + cv.Optional(CONF_CHANNEL, default=default_channel): cv.one_of( + *I2S_CHANNELS + ), + cv.Optional(CONF_SAMPLE_RATE, default=default_sample_rate): cv.int_range( + min=1 + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All( + _validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE) + ), + cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of( + *I2S_MODE_OPTIONS, lower=True + ), + cv.Optional(CONF_USE_APLL, default=False): cv.boolean, + cv.Optional(CONF_MCLK_MULTIPLE, default=256): cv.one_of(*I2S_MCLK_MULTIPLE), + } + ) + + +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: + await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) + cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) + slot_mode = config[CONF_CHANNEL] + if slot_mode != CONF_STEREO: + slot_mode = CONF_MONO + slot_mask = config[CONF_CHANNEL] + if slot_mask not in [CONF_LEFT, CONF_RIGHT]: + slot_mask = CONF_BOTH + cg.add(var.set_slot_mode(I2S_SLOT_MODE[slot_mode])) + cg.add(var.set_std_slot_mask(I2S_STD_SLOT_MASK[slot_mask])) + cg.add(var.set_slot_bit_width(I2S_SLOT_BIT_WIDTH[config[CONF_BITS_PER_SAMPLE]])) + cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE])) + cg.add(var.set_use_apll(config[CONF_USE_APLL])) + cg.add(var.set_mclk_multiple(I2S_MCLK_MULTIPLE[config[CONF_MCLK_MULTIPLE]])) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(I2SAudioComponent), + cv.Optional(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, + }, +) + + +@dataclass +class I2SAudioData: + """I2S audio component state stored in CORE.data.""" + + port_map: dict[str, int] = field(default_factory=dict) + + +def _get_data() -> I2SAudioData: + if CONF_I2S_AUDIO not in CORE.data: + CORE.data[CONF_I2S_AUDIO] = I2SAudioData() + return CORE.data[CONF_I2S_AUDIO] + + +def _assign_ports() -> None: + """Assign I2S port numbers, prioritizing instances with microphone children. + + Microphones (especially PDM) require port 0 on most ESP32 variants. + This runs once and stores the mapping in CORE.data. + """ + data = _get_data() + if data.port_map: + return + + full_config = fv.full_config.get() + i2s_configs = full_config[CONF_I2S_AUDIO] + + # Find i2s_audio instances with microphones that require port 0 + # (PDM and internal ADC only work on I2S port 0) + port0_parent_id = None + for mic_config in full_config.get("microphone", []): + if CONF_I2S_AUDIO_ID not in mic_config: + continue + if mic_config.get(CONF_PDM) or mic_config.get(CONF_ADC_TYPE) == "internal": + if port0_parent_id is not None: + raise cv.Invalid( + "Only one PDM/ADC microphone is supported (requires I2S port 0)" + ) + port0_parent_id = str(mic_config[CONF_I2S_AUDIO_ID]) + + # Assign ports: port 0 parent first (if any), rest get sequential + next_port = 0 + if port0_parent_id is not None: + data.port_map[port0_parent_id] = next_port + next_port += 1 + for config in i2s_configs: + config_id = str(config[CONF_ID]) + if config_id != port0_parent_id: + data.port_map[config_id] = next_port + next_port += 1 + + +def _final_validate(_: ConfigType) -> None: + i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] + variant = get_esp32_variant() + if variant not in I2S_PORTS: + raise cv.Invalid(f"Unsupported variant {variant}") + if len(i2s_audio_configs) > I2S_PORTS[variant]: + raise cv.Invalid( + f"Only {I2S_PORTS[variant]} I2S audio ports are supported on {variant}" + ) + _assign_ports() + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # Assign I2S port from _final_validate computed mapping + data = _get_data() + if (port := data.port_map.get(str(config[CONF_ID]))) is None: + raise ValueError(f"No I2S port assigned for {config[CONF_ID]}") + cg.add(var.set_port(port)) + + # Re-enable ESP-IDF's I2S driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2s") + + # Helps avoid callbacks being skipped due to processor load + add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + if CONF_I2S_LRCLK_PIN in config: + cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) + if CONF_I2S_BCLK_PIN in config: + cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) + if CONF_I2S_MCLK_PIN in config: + cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN])) diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/i2s_audio.h b/third_party/esphome-i2s-audio/components/i2s_audio/i2s_audio.h new file mode 100644 index 0000000..00a9705 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/i2s_audio.h @@ -0,0 +1,81 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include +#include + +namespace esphome::i2s_audio { + +class I2SAudioComponent; + +class I2SAudioBase : public Parented { + public: + void set_i2s_role(i2s_role_t role) { this->i2s_role_ = role; } + void set_slot_mode(i2s_slot_mode_t slot_mode) { this->slot_mode_ = slot_mode; } + void set_std_slot_mask(i2s_std_slot_mask_t std_slot_mask) { this->std_slot_mask_ = std_slot_mask; } + void set_slot_bit_width(i2s_slot_bit_width_t slot_bit_width) { this->slot_bit_width_ = slot_bit_width; } + void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; } + void set_use_apll(uint32_t use_apll) { this->use_apll_ = use_apll; } + void set_mclk_multiple(i2s_mclk_multiple_t mclk_multiple) { this->mclk_multiple_ = mclk_multiple; } + + protected: + i2s_role_t i2s_role_{}; + i2s_slot_mode_t slot_mode_; + i2s_std_slot_mask_t std_slot_mask_; + i2s_slot_bit_width_t slot_bit_width_; + uint32_t sample_rate_; + bool use_apll_; + i2s_mclk_multiple_t mclk_multiple_; +}; + +class I2SAudioIn : public I2SAudioBase {}; + +class I2SAudioOut : public I2SAudioBase {}; + +class I2SAudioComponent final : public Component { + public: + i2s_std_gpio_config_t get_pin_config() const { + return {.mclk = (gpio_num_t) this->mclk_pin_, + .bclk = (gpio_num_t) this->bclk_pin_, + .ws = (gpio_num_t) this->lrclk_pin_, + .dout = I2S_GPIO_UNUSED, // add local ports + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }}; + } + + void set_mclk_pin(int pin) { this->mclk_pin_ = pin; } + void set_bclk_pin(int pin) { this->bclk_pin_ = pin; } + void set_lrclk_pin(int pin) { this->lrclk_pin_ = pin; } + void set_port(int port) { this->port_ = port; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + int get_port() const { return this->port_; } +#else + i2s_port_t get_port() const { return static_cast(this->port_); } +#endif + + void lock() { this->lock_.lock(); } + bool try_lock() { return this->lock_.try_lock(); } + void unlock() { this->lock_.unlock(); } + + protected: + Mutex lock_; + + I2SAudioIn *audio_in_{nullptr}; + I2SAudioOut *audio_out_{nullptr}; + int mclk_pin_{I2S_GPIO_UNUSED}; + int bclk_pin_{I2S_GPIO_UNUSED}; + int lrclk_pin_; + int port_{}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/media_player/__init__.py b/third_party/esphome-i2s-audio/components/i2s_audio/media_player/__init__.py new file mode 100644 index 0000000..b366d4f --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/media_player/__init__.py @@ -0,0 +1,7 @@ +import esphome.config_validation as cv + +CONFIG_SCHEMA = cv.invalid( + "The I2S audio media player has been removed. " + "Use the speaker media player component instead. " + "See https://esphome.io/components/media_player/speaker.html for details." +) diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/microphone/__init__.py b/third_party/esphome-i2s-audio/components/i2s_audio/microphone/__init__.py new file mode 100644 index 0000000..c217317 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/microphone/__init__.py @@ -0,0 +1,159 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import audio, esp32, microphone +from esphome.components.adc import validate_adc_pin +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_CHANNEL, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, +) +from esphome.types import ConfigType + +from .. import ( + CONF_ADC_TYPE, + CONF_I2S_DIN_PIN, + CONF_LEFT, + CONF_MONO, + CONF_PDM, + CONF_PDM_DSR, + CONF_RIGHT, + I2SAudioIn, + i2s_audio_component_schema, + i2s_audio_ns, + register_i2s_audio_component, + validate_mclk_divisible_by_3, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["i2s_audio"] + +CONF_ADC_PIN = "adc_pin" +CONF_CORRECT_DC_OFFSET = "correct_dc_offset" + +I2SAudioMicrophone = i2s_audio_ns.class_( + "I2SAudioMicrophone", I2SAudioIn, microphone.Microphone, cg.Component +) + +INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] +PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] + +i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") +I2S_PDM_DSR = { + 8: i2s_pdm_dsr_t.I2S_PDM_DSR_8S, + 16: i2s_pdm_dsr_t.I2S_PDM_DSR_16S, +} + + +def _validate_esp32_variant(config: ConfigType) -> ConfigType: + variant = esp32.get_esp32_variant() + if config[CONF_ADC_TYPE] == "external": + if config[CONF_PDM] and variant not in PDM_VARIANTS: + raise cv.Invalid(f"{variant} does not support PDM") + if ( + variant == esp32.VARIANT_ESP32 + and config.get(CONF_BITS_PER_SAMPLE) == 8 + and config.get(CONF_CHANNEL) in (CONF_LEFT, CONF_RIGHT) + ): + raise cv.Invalid("8-bit mono mode is not supported on ESP32") + return config + if config[CONF_ADC_TYPE] == "internal": + if variant not in INTERNAL_ADC_VARIANTS: + raise cv.Invalid(f"{variant} does not have an internal ADC") + return config + raise NotImplementedError + + +def _validate_channel(config: ConfigType) -> ConfigType: + if config[CONF_CHANNEL] == CONF_MONO: + raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") + return config + + +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: + if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): + config[CONF_NUM_CHANNELS] = 1 + else: + config[CONF_NUM_CHANNELS] = 2 + + return config + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + audio.set_stream_limits( + min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + min_channels=config.get(CONF_NUM_CHANNELS), + max_channels=config.get(CONF_NUM_CHANNELS), + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +BASE_SCHEMA = microphone.MICROPHONE_SCHEMA.extend( + i2s_audio_component_schema( + I2SAudioMicrophone, + default_sample_rate=16000, + default_channel=CONF_RIGHT, + default_bits_per_sample="32bit", + ).extend( + { + cv.Optional(CONF_CORRECT_DC_OFFSET, default=False): cv.boolean, + } + ) +).extend(cv.COMPONENT_SCHEMA) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "internal": BASE_SCHEMA.extend( + { + cv.Required(CONF_ADC_PIN): validate_adc_pin, + } + ), + "external": BASE_SCHEMA.extend( + { + cv.Required(CONF_I2S_DIN_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_PDM, default=False): cv.boolean, + cv.Optional(CONF_PDM_DSR, default=8): cv.enum( + I2S_PDM_DSR, int=True + ), + } + ), + }, + key=CONF_ADC_TYPE, + ), + _validate_esp32_variant, + _validate_channel, + _set_num_channels_from_config, + _set_stream_limits, + validate_mclk_divisible_by_3, +) + + +def _final_validate(config: ConfigType) -> None: + if config[CONF_ADC_TYPE] == "internal": + raise cv.Invalid( + "Internal ADC is no longer supported. Use an external I2S microphone instead." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await register_i2s_audio_component(var, config) + await microphone.register_microphone(var, config) + + cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN])) + cg.add(var.set_pdm(config[CONF_PDM])) + if esp32.get_esp32_variant() in PDM_VARIANTS: + cg.add(var.set_pdm_dsr(config[CONF_PDM_DSR])) + + cg.add(var.set_correct_dc_offset(config[CONF_CORRECT_DC_OFFSET])) diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.cpp new file mode 100644 index 0000000..c577ed0 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -0,0 +1,429 @@ +#include "i2s_audio_microphone.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esphome/components/audio/audio.h" + +namespace esphome::i2s_audio { + +static const UBaseType_t MAX_LISTENERS = 16; + +static const uint32_t READ_DURATION_MS = 16; + +static const size_t TASK_STACK_SIZE = 4096; +static const ssize_t TASK_PRIORITY = 23; + +static const char *const TAG = "i2s_audio.microphone"; + +enum MicrophoneEventGroupBits : uint32_t { + COMMAND_STOP = (1 << 0), // stops the microphone task, set and cleared by ``loop`` + + TASK_STARTING = (1 << 10), // set by mic task, cleared by ``loop`` + TASK_RUNNING = (1 << 11), // set by mic task, cleared by ``loop`` + TASK_STOPPED = (1 << 13), // set by mic task, cleared by ``loop`` + + ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +void I2SAudioMicrophone::setup() { + this->active_listeners_semaphore_ = xSemaphoreCreateCounting(MAX_LISTENERS, MAX_LISTENERS); + if (this->active_listeners_semaphore_ == nullptr) { + ESP_LOGE(TAG, "Creating semaphore failed"); + this->mark_failed(); + return; + } + + this->event_group_ = xEventGroupCreate(); + if (this->event_group_ == nullptr) { + ESP_LOGE(TAG, "Creating event group failed"); + this->mark_failed(); + return; + } + + this->configure_stream_settings_(); +} + +void I2SAudioMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "Microphone:\n" + " Pin: %d\n" + " PDM: %s\n" + " DC offset correction: %s", + static_cast(this->din_pin_), YESNO(this->pdm_), YESNO(this->correct_dc_offset_)); +} + +void I2SAudioMicrophone::configure_stream_settings_() { + uint8_t channel_count = 1; + uint8_t bits_per_sample = 16; + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + bits_per_sample = this->slot_bit_width_; + } + + if (this->slot_mode_ == I2S_SLOT_MODE_STEREO) { + channel_count = 2; + } + +#ifdef USE_ESP32_VARIANT_ESP32 + // ESP32 reads audio aligned to a multiple of 2 bytes. For example, if configured for 24 bits per sample, then it will + // produce 32 bits per sample, where the actual data is in the most significant bits. Other ESP32 variants produce 24 + // bits per sample in this situation. + if (bits_per_sample < 16) { + bits_per_sample = 16; + } else if ((bits_per_sample > 16) && (bits_per_sample <= 32)) { + bits_per_sample = 32; + } +#endif + + if (this->pdm_) { + bits_per_sample = 16; // PDM mics are always 16 bits per sample + } + + this->audio_stream_info_ = audio::AudioStreamInfo(bits_per_sample, channel_count, this->sample_rate_); +} + +void I2SAudioMicrophone::start() { + if (this->is_failed()) + return; + + xSemaphoreTake(this->active_listeners_semaphore_, 0); +} + +bool I2SAudioMicrophone::start_driver_() { + if (!this->parent_->try_lock()) { + return false; // Waiting for another i2s to return lock + } + this->locked_driver_ = true; + esp_err_t err; + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = this->i2s_role_, + .dma_desc_num = 4, + .dma_frame_num = 256, + .auto_clear = false, + }; + /* Allocate a new RX channel and get the handle of this channel */ + err = i2s_new_channel(&chan_cfg, NULL, &this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error creating channel: %s", esp_err_to_name(err)); + return false; + } + + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; +#ifdef I2S_CLK_SRC_APLL + if (this->use_apll_) { + clk_src = I2S_CLK_SRC_APLL; + } +#endif + i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); +#if SOC_I2S_SUPPORTS_PDM_RX + if (this->pdm_) { + i2s_pdm_rx_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + .dn_sample_mode = this->pdm_dsr_, + }; + + i2s_pdm_rx_slot_config_t slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, this->slot_mode_); + switch (this->std_slot_mask_) { + case I2S_STD_SLOT_LEFT: + slot_cfg.slot_mask = I2S_PDM_SLOT_LEFT; + break; + case I2S_STD_SLOT_RIGHT: + slot_cfg.slot_mask = I2S_PDM_SLOT_RIGHT; + break; + case I2S_STD_SLOT_BOTH: + slot_cfg.slot_mask = I2S_PDM_SLOT_BOTH; + break; + } + + /* Init the channel into PDM RX mode */ + i2s_pdm_rx_config_t pdm_rx_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = + { + .clk = pin_config.ws, + .din = this->din_pin_, + .invert_flags = + { + .clk_inv = pin_config.invert_flags.ws_inv, + }, + }, + }; + err = i2s_channel_init_pdm_rx_mode(this->rx_handle_, &pdm_rx_cfg); + } else +#endif + { + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + i2s_std_slot_config_t std_slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); + std_slot_cfg.slot_bit_width = this->slot_bit_width_; + std_slot_cfg.slot_mask = this->std_slot_mask_; + + pin_config.din = this->din_pin_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = std_slot_cfg, + .gpio_cfg = pin_config, + }; + /* Initialize the channel */ + err = i2s_channel_init_std_mode(this->rx_handle_, &std_cfg); + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error initializing channel: %s", esp_err_to_name(err)); + return false; + } + + /* Before reading data, start the RX channel first */ + err = i2s_channel_enable(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Enabling failed: %s", esp_err_to_name(err)); + return false; + } + + this->configure_stream_settings_(); // redetermine the settings in case some settings were changed after compilation + + return true; +} + +void I2SAudioMicrophone::stop() { + if (this->state_ == microphone::STATE_STOPPED || this->is_failed()) + return; + + xSemaphoreGive(this->active_listeners_semaphore_); +} + +void I2SAudioMicrophone::stop_driver_() { + // There is no harm continuing to unload the driver if an error is ever returned by the various functions. This + // ensures that we stop/unload the driver when it only partially starts. + + esp_err_t err; + if (this->rx_handle_ != nullptr) { + /* Have to stop the channel before deleting it */ + err = i2s_channel_disable(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Error stopping: %s", esp_err_to_name(err)); + } + /* If the handle is not needed any more, delete it to release the channel resources */ + err = i2s_del_channel(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Error deleting channel: %s", esp_err_to_name(err)); + } + this->rx_handle_ = nullptr; + } + if (this->locked_driver_) { + this->parent_->unlock(); + this->locked_driver_ = false; + } +} + +void I2SAudioMicrophone::mic_task(void *params) { + I2SAudioMicrophone *this_microphone = (I2SAudioMicrophone *) params; + xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_STARTING); + + { // Ensures the samples vector is freed when the task stops + + const size_t bytes_to_read = this_microphone->audio_stream_info_.ms_to_bytes(READ_DURATION_MS); + std::vector samples; + samples.reserve(bytes_to_read); + + xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_RUNNING); + + while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) { + if (this_microphone->data_callbacks_.size() > 0) { + samples.resize(bytes_to_read); + size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS); + samples.resize(bytes_read); + if (this_microphone->correct_dc_offset_) { + this_microphone->fix_dc_offset_(samples); + } + this_microphone->data_callbacks_.call(samples); + } else { + vTaskDelay(pdMS_TO_TICKS(READ_DURATION_MS)); + } + } + } + + xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_STOPPED); + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { + /** + * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: + * + * y(n) = x(n) - x(n-1) + R * y(n-1) + * R = 1 - (pi * 2 * frequency / samplerate) + * + * From https://en.wikipedia.org/wiki/Hearing_range: + * The human range is commonly given as 20Hz up. + * + * From https://en.wikipedia.org/wiki/High-resolution_audio: + * A reasonable upper bound for sample rate seems to be 96kHz. + * + * Calculate R value for 20Hz on a 96kHz sample rate: + * R = 1 - (pi * 2 * 20 / 96000) + * R = 0.9986910031 + * + * Transform floating point to bit-shifting approximation: + * output = input - prev_input + R * prev_output + * output = input - prev_input + (prev_output - (prev_output >> S)) + * + * Approximate bit-shift value S from R: + * R = 1 - (1 >> S) + * R = 1 - (1 / 2^S) + * R = 1 - 2^-S + * 0.9986910031 = 1 - 2^-S + * S = 9.57732 ~= 10 + * + * Actual R from S: + * R = 1 - 2^-10 = 0.9990234375 + * + * Confirm this has effect outside human hearing on 96000kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 96000) + * f = 14.9208Hz + * + * Confirm this has effect outside human hearing on PDM 16kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 16000) + * f = 2.4868Hz + * + */ + const uint8_t dc_filter_shift = 10; + const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); + const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); + for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { + const uint32_t byte_index = sample_index * bytes_per_sample; + int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); + int32_t output = input - this->dc_offset_prev_input_ + + (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); + this->dc_offset_prev_input_ = input; + this->dc_offset_prev_output_ = output; + audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); + } +} + +size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) { + size_t bytes_read = 0; + esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms); + if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (timeout_ms != 0))) { + // Ignore ESP_ERR_TIMEOUT if timeout_ms = 0, as it will read the data on the next call + if (!this->status_has_warning()) { + // Avoid spamming the logs with the error message if its repeated + ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); + } + this->status_set_warning(); + return 0; + } + if ((bytes_read == 0) && (timeout_ms > 0)) { + this->status_set_warning(); + return 0; + } + this->status_clear_warning(); +#ifdef USE_ESP32_VARIANT_ESP32 + // For ESP32 16-bit standard mono mode, adjacent samples need to be swapped. + if (this->slot_mode_ == I2S_SLOT_MODE_MONO && this->slot_bit_width_ == I2S_SLOT_BIT_WIDTH_16BIT && !this->pdm_) { + int16_t *samples = reinterpret_cast(buf); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; + } + } +#endif + return bytes_read; +} + +void I2SAudioMicrophone::loop() { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & MicrophoneEventGroupBits::TASK_STARTING) { + ESP_LOGV(TAG, "Task started, attempting to allocate buffer"); + xEventGroupClearBits(this->event_group_, MicrophoneEventGroupBits::TASK_STARTING); + } + + if (event_group_bits & MicrophoneEventGroupBits::TASK_RUNNING) { + ESP_LOGV(TAG, "Task is running and reading data"); + + xEventGroupClearBits(this->event_group_, MicrophoneEventGroupBits::TASK_RUNNING); + this->state_ = microphone::STATE_RUNNING; + } + + if ((event_group_bits & MicrophoneEventGroupBits::TASK_STOPPED)) { + ESP_LOGV(TAG, "Task finished, freeing resources and uninstalling driver"); + + vTaskDelete(this->task_handle_); + this->task_handle_ = nullptr; + this->stop_driver_(); + xEventGroupClearBits(this->event_group_, ALL_BITS); + this->status_clear_error(); + + this->state_ = microphone::STATE_STOPPED; + } + + // Start the microphone if any semaphores are taken + if ((uxSemaphoreGetCount(this->active_listeners_semaphore_) < MAX_LISTENERS) && + (this->state_ == microphone::STATE_STOPPED)) { + this->state_ = microphone::STATE_STARTING; + } + + // Stop the microphone if all semaphores are returned + if ((uxSemaphoreGetCount(this->active_listeners_semaphore_) == MAX_LISTENERS) && + (this->state_ == microphone::STATE_RUNNING)) { + this->state_ = microphone::STATE_STOPPING; + } + + switch (this->state_) { + case microphone::STATE_STARTING: + if (this->status_has_error()) { + break; + } + + if (!this->start_driver_()) { + ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); + this->status_momentary_error("driver_fail", 1000); + this->stop_driver_(); // Stop/frees whatever possibly started + break; + } + + if (this->task_handle_ == nullptr) { + xTaskCreate(I2SAudioMicrophone::mic_task, "mic_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->task_handle_); + + if (this->task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task_fail", 1000); + this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + } + } + + break; + case microphone::STATE_RUNNING: + break; + case microphone::STATE_STOPPING: + xEventGroupSetBits(this->event_group_, MicrophoneEventGroupBits::COMMAND_STOP); + break; + case microphone::STATE_STOPPED: + break; + } +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.h b/third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.h new file mode 100644 index 0000000..37895ac --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -0,0 +1,76 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "../i2s_audio.h" + +#include "esphome/components/microphone/microphone.h" +#include "esphome/core/component.h" + +#include +#include +#include +#include + +namespace esphome::i2s_audio { + +class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphone, public Component { + public: + void setup() override; + void dump_config() override; + void start() override; + void stop() override; + + void loop() override; + + void set_correct_dc_offset(bool correct_dc_offset) { this->correct_dc_offset_ = correct_dc_offset; } + + void set_din_pin(int8_t pin) { this->din_pin_ = (gpio_num_t) pin; } + + void set_pdm(bool pdm) { this->pdm_ = pdm; } + +#if SOC_I2S_SUPPORTS_PDM_RX + void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } +#endif + + protected: + /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. + /// @return True if succesful, false otherwise + bool start_driver_(); + + /// @brief Stops the I2S driver. + void stop_driver_(); + + /// @brief Attempts to correct a microphone DC offset; e.g., a microphones silent level is offset from 0. Applies a + /// correction offset that is updated using an exponential moving average for all samples away from 0. + /// @param data + void fix_dc_offset_(std::vector &data); + + size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms); + + /// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings. + void configure_stream_settings_(); + + static void mic_task(void *params); + + SemaphoreHandle_t active_listeners_semaphore_{nullptr}; + EventGroupHandle_t event_group_{nullptr}; + + TaskHandle_t task_handle_{nullptr}; + + gpio_num_t din_pin_{I2S_GPIO_UNUSED}; + i2s_chan_handle_t rx_handle_; + bool pdm_{false}; +#if SOC_I2S_SUPPORTS_PDM_RX + i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; +#endif + + bool correct_dc_offset_; + bool locked_driver_{false}; + int32_t dc_offset_prev_input_{0}; + int32_t dc_offset_prev_output_{0}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/__init__.py b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/__init__.py new file mode 100644 index 0000000..e128b2e --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/__init__.py @@ -0,0 +1,283 @@ +# Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +from esphome import pins +import esphome.codegen as cg +from esphome.components import audio, esp32, speaker +from esphome.config_helpers import filter_source_files_from_defines +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_DURATION, + CONF_CHANNEL, + CONF_ID, + CONF_MODE, + CONF_NEVER, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, + CONF_TIMEOUT, +) +from esphome.types import ConfigType + +from .. import ( + CONF_I2S_DOUT_PIN, + CONF_I2S_MODE, + CONF_LEFT, + CONF_MCLK_MULTIPLE, + CONF_MONO, + CONF_PRIMARY, + CONF_RIGHT, + CONF_STEREO, + CONF_USE_APLL, + I2SAudioOut, + i2s_audio_component_schema, + i2s_audio_ns, + register_i2s_audio_component, + validate_mclk_divisible_by_3, +) + +AUTO_LOAD = ["audio"] +CODEOWNERS = ["@jesserockz", "@kahrendt"] +DEPENDENCIES = ["i2s_audio"] + +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut +) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) + +CONF_DAC_TYPE = "dac_type" +CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_SPDIF_MODE = "spdif_mode" +CONF_VOLUME_FENCE = "volume_fence" + +I2SAudioSpeakerBase = i2s_audio_ns.class_( + "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut +) +I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) +I2SAudioSpeakerSPDIF = i2s_audio_ns.class_("I2SAudioSpeakerSPDIF", I2SAudioSpeakerBase) + +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) + +I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True) + +i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t") +INTERNAL_DAC_OPTIONS = { + CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN, + CONF_RIGHT: i2s_dac_mode_t.I2S_DAC_CHANNEL_RIGHT_EN, + CONF_STEREO: i2s_dac_mode_t.I2S_DAC_CHANNEL_BOTH_EN, +} + +i2s_comm_format_t = cg.global_ns.enum("i2s_comm_format_t") +I2C_COMM_FMT_OPTIONS = { + "stand_i2s": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_I2S, + "stand_msb": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MSB, + "stand_pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_SHORT, + "stand_pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_LONG, + "stand_max": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MAX, + "i2s_msb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_MSB, + "i2s_lsb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_LSB, + "pcm": i2s_comm_format_t.I2S_COMM_FORMAT_PCM, + "pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_SHORT, + "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, +} + +INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] + + +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: + if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): + config[CONF_NUM_CHANNELS] = 1 + else: + config[CONF_NUM_CHANNELS] = 2 + + return config + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + if config.get(CONF_SPDIF_MODE, False): + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate + audio.set_stream_limits( + min_bits_per_sample=16, + max_bits_per_sample=32, + min_channels=2, + max_channels=2, + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + return config + + # The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest + # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. + min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + + # The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it + # in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams + # are accepted rather than forcing an upstream conversion. + max_bits_per_sample = 32 + + if config[CONF_I2S_MODE] == CONF_PRIMARY: + # Primary mode can reconfigure the bus to the incoming sample rate and channel count. + audio.set_stream_limits( + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=max_bits_per_sample, + min_channels=1, + max_channels=2, + min_sample_rate=16000, + max_sample_rate=48000, + )(config) + else: + # Secondary mode has unmodifiable min/max sample rates + audio.set_stream_limits( + min_bits_per_sample=min_bits_per_sample, + max_bits_per_sample=max_bits_per_sample, + min_channels=1, + max_channels=2, + min_sample_rate=config.get(CONF_SAMPLE_RATE), + max_sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +def _select_speaker_class(config: ConfigType) -> ConfigType: + """Override ID type when SPDIF mode is enabled.""" + if config.get(CONF_SPDIF_MODE, False): + config[CONF_ID].type = I2SAudioSpeakerSPDIF + return config + + +def _validate_esp32_variant(config: ConfigType) -> ConfigType: + variant = esp32.get_esp32_variant() + if config[CONF_DAC_TYPE] == "internal": + if variant not in INTERNAL_DAC_VARIANTS: + raise cv.Invalid(f"{variant} does not have an internal DAC") + elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8: + # The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an + # 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it + # at config time rather than emitting corrupted output at runtime. + raise cv.Invalid("8-bit audio is not supported on the original ESP32") + return config + + +BASE_SCHEMA = ( + speaker.SPEAKER_SCHEMA.extend( + i2s_audio_component_schema( + I2SAudioSpeaker, + default_sample_rate=16000, + default_channel=CONF_MONO, + default_bits_per_sample="16bit", + ) + ) + .extend( + { + cv.Optional( + CONF_BUFFER_DURATION, default="500ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TIMEOUT, default="500ms"): cv.Any( + cv.positive_time_period_milliseconds, + cv.one_of(CONF_NEVER, lower=True), + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "internal": BASE_SCHEMA.extend( + { + cv.Required(CONF_MODE): cv.enum(INTERNAL_DAC_OPTIONS, lower=True), + } + ), + "external": BASE_SCHEMA.extend( + { + cv.Required( + CONF_I2S_DOUT_PIN + ): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_COMM_FMT, default="stand_i2s"): cv.one_of( + *I2C_COMM_FMT_OPTIONS, lower=True + ), + cv.Optional(CONF_SPDIF_MODE, default=False): cv.boolean, + cv.Optional(CONF_VOLUME_FENCE, default=False): cv.boolean, + } + ), + }, + key=CONF_DAC_TYPE, + ), + _validate_esp32_variant, + _set_num_channels_from_config, + _set_stream_limits, + _select_speaker_class, + validate_mclk_divisible_by_3, +) + + +def _final_validate(config: ConfigType) -> None: + if config[CONF_DAC_TYPE] == "internal": + raise cv.Invalid( + "Internal DAC is no longer supported. Use an external I2S DAC instead." + ) + if config.get(CONF_VOLUME_FENCE) and (config.get(CONF_SPDIF_MODE) or config.get("audio_dac")): + raise cv.Invalid("volume_fence requires standard I2S with software gain owned by its adapter") + if config[CONF_I2S_COMM_FMT] == "stand_max": + raise cv.Invalid("I2S standard max format is no longer supported.") + + if config.get(CONF_SPDIF_MODE, False): + # SPDIF mode specific validations + if config[CONF_SAMPLE_RATE] not in [44100, 48000]: + raise cv.Invalid( + "SPDIF mode only supports 44100 Hz or 48000 Hz sample rates" + ) + if config[CONF_CHANNEL] != CONF_STEREO: + raise cv.Invalid("SPDIF mode only supports stereo channel configuration") + if not config[CONF_USE_APLL]: + raise cv.Invalid( + "SPDIF mode requires 'use_apll: true' for accurate clock generation" + ) + if config[CONF_I2S_MODE] != CONF_PRIMARY: + raise cv.Invalid("SPDIF mode requires 'i2s_mode: primary'") + if config[CONF_I2S_COMM_FMT] != "stand_i2s": + raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") + if config[CONF_MCLK_MULTIPLE] != 256: + raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await register_i2s_audio_component(var, config) + await speaker.register_speaker(var, config) + + cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN])) + + if config.get(CONF_VOLUME_FENCE): + cg.add_define("USE_OPENATHAN_VOLUME_FENCE") + cg.add(var.set_volume_fence(True)) + + is_spdif = config.get(CONF_SPDIF_MODE, False) + if is_spdif: + cg.add_define("USE_I2S_AUDIO_SPDIF_MODE") + else: + fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long + if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]: + fmt = I2SCommFmt.MSB + elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]: + fmt = I2SCommFmt.PCM + cg.add(var.set_i2s_comm_fmt(fmt)) + + if config[CONF_TIMEOUT] != CONF_NEVER: + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + + +# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, +# set only when spdif_mode is enabled. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "spdif_encoder.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + "i2s_audio_spdif.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + } +) diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.cpp new file mode 100644 index 0000000..ed5145d --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -0,0 +1,504 @@ +#include "i2s_audio_spdif.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.spdif"; + +// SPDIF mode adds overhead as each sample is encapsulated in a subframe; +// each DMA buffer can hold only 192 samples (~4ms each vs. ~15ms for standard I2S). +// To match the standard I2S buffering duration, we use more buffers to minimize +// the impact of the overhead, such as stuttering or audio/silence oscillation. +// 15 buffers x 4ms = 60ms of DMA buffering (same as 4 x 15ms for standard) +static constexpr size_t SPDIF_DMA_BUFFERS_COUNT = 15; + +// Number of DMA events between upstream callbacks (~16ms = 4 events x 4ms each). +// Matches non-SPDIF timing to prevent overwhelming upstream sync algorithms. +static constexpr uint32_t SPDIF_DMA_EVENTS_PER_CALLBACK = 4; + +// Brief retry wait used by play() to catch short free-space windows during rapid track transitions. +static constexpr uint32_t SPDIF_PLAY_RETRY_WAIT_MS = 5; + +static constexpr size_t SPDIF_I2S_EVENT_QUEUE_COUNT = 2 * SPDIF_DMA_BUFFERS_COUNT; + +// Static callback functions for SPDIF encoder (avoids std::function overhead) +static esp_err_t spdif_preload_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { + auto *speaker = static_cast(user_ctx); + size_t bytes_written = 0; + esp_err_t err = i2s_channel_preload_data(speaker->get_tx_handle(), data, size, &bytes_written); + if (err != ESP_OK || bytes_written != size) { + ESP_LOGV(TAG, "Preload failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait) { + auto *speaker = static_cast(user_ctx); + size_t bytes_written = 0; + esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); + if (err != ESP_OK) { + ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + } + return err; +} + +void I2SAudioSpeakerSPDIF::setup() { + I2SAudioSpeakerBase::setup(); + if (this->is_failed()) { + return; + } + + this->spdif_encoder_ = new SPDIFEncoder(); + if (!this->spdif_encoder_->setup()) { + ESP_LOGE(TAG, "Encoder setup failed"); + this->mark_failed(); + return; + } + + // Configure channel status block with the sample rate + this->spdif_encoder_->set_sample_rate(this->sample_rate_); + + // Separate callbacks for preload (during underflow recovery) and normal writes + this->spdif_encoder_->set_preload_callback(spdif_preload_cb, this); + this->spdif_encoder_->set_write_callback(spdif_write_cb, this); +} + +void I2SAudioSpeakerSPDIF::dump_config() { + I2SAudioSpeakerBase::dump_config(); + ESP_LOGCONFIG(TAG, + " SPDIF Mode: YES\n" + " Sample Rate: %" PRIu32 " Hz", + this->sample_rate_); +} + +void I2SAudioSpeakerSPDIF::on_task_stopped() { this->spdif_silence_start_ = 0; } + +size_t I2SAudioSpeakerSPDIF::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + if (this->is_failed()) { + ESP_LOGE(TAG, "Setup failed; cannot play audio"); + return 0; + } + + // In SPDIF mode, keep accepting upstream audio while the speaker task is active. + // This avoids transient drops during stop/start transitions. + const bool task_active = (this->speaker_task_handle_ != nullptr); + + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { + this->start(); + } + + if (!task_active && this->state_ != speaker::STATE_RUNNING) { + // Unable to write data to a running speaker, so delay the max amount of time so it can get ready + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t bytes_written = 0; + if (this->state_ == speaker::STATE_RUNNING || task_active) { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + // In SPDIF mode, a tiny wait helps avoid transient 0-byte writes during short backpressure windows. + TickType_t effective_ticks_to_wait = ticks_to_wait; + if (effective_ticks_to_wait == 0) { + effective_ticks_to_wait = pdMS_TO_TICKS(1); + } + bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, effective_ticks_to_wait); + if (bytes_written == 0 && length > 0) { + // Retry once to catch short free-space windows during rapid seek/track transitions. + bytes_written = + temp_ring_buffer->write_without_replacement((void *) data, length, pdMS_TO_TICKS(SPDIF_PLAY_RETRY_WAIT_MS)); + } + } + } + + return bytes_written; +} + +void I2SAudioSpeakerSPDIF::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + // Reset SPDIF encoder at task start to ensure clean state + // (previous task may have left stale data in encoder buffer) + if (this->spdif_encoder_ != nullptr) { + this->spdif_encoder_->reset(); + } + + // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. + xQueueReset(this->write_records_queue_); + + // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); + + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. + const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; + const size_t bytes_to_fill_single_dma_buffer = + this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); + + bool successful_setup = false; + std::unique_ptr audio_source; + + { + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer, + static_cast(bytes_per_frame)); + if (audio_source != nullptr) { + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. + this->spdif_encoder_->set_preload_mode(true); + for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); + if (preload_err != ESP_OK) { + break; // DMA preload buffer full or error + } + const uint32_t silence_record = 0; + xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + } + this->spdif_encoder_->set_preload_mode(false); + this->spdif_encoder_->reset(); // Clean encoder state for the main loop + + // Now register the callback and enable the channel + xQueueReset(this->i2s_event_queue_); + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + i2s_channel_enable(this->tx_handle_); + + // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). + // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. + // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard + // I2S speaker pattern (PR #16317): fill what you can, then silence-pad whatever is still + // missing to complete the DMA buffer. + const uint32_t block_duration_us = this->current_stream_info_.frames_to_microseconds(SPDIF_BLOCK_SAMPLES); + // Sized to absorb the worst case where every DMA buffer is full when we issue the write. + const TickType_t write_timeout_ticks = + pdMS_TO_TICKS(((block_duration_us * (SPDIF_DMA_BUFFERS_COUNT + 1)) + 999) / 1000); + // Brief read budget when the ring buffer is empty (~half a block). + const TickType_t read_timeout_ticks = pdMS_TO_TICKS(((block_duration_us / 2) + 999) / 1000); + + // SPDIF Callback Decimation: fire every 4th DMA event (~16ms), matching non-SPDIF timing. + uint32_t spdif_pending_frames = 0; + int64_t spdif_pending_timestamp = 0; + uint32_t spdif_dma_event_count = 0; + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data + // to keep the receiver synced. Exits only via break (stream info change, silence timeout, + // lockstep desync, dropped event, or partial-write failure). + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion + // event; that desyncs the lockstep queues permanently and the only safe recovery is a full + // task restart. + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); + break; + } + // User-initiated stop. In SPDIF continuous mode, transition to silence output rather + // than tearing the task down. + this->spdif_silence_start_ = millis(); + ESP_LOGV(TAG, "COMMAND_STOP received, continuing in silence mode"); + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + // SPDIF continuous mode never tears the channel down on graceful stop. Clear the flag and + // let the audio simply drain through the always-fill loop into the silence-timeout path. + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + // Drain ISR completion events, popping a matching record for each. + int64_t write_timestamp; + bool lockstep_broken = false; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + // Lockstep: pop the matching record (real audio frames packed into this DMA block). + // Records are pushed by the task right after each successful block commit, so the FIFO + // order matches DMA completion order. Empty records queue here means lockstep broke. + uint32_t real_frames = 0; + if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { + ESP_LOGV(TAG, "Event without matching write record"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; + break; + } + + // Per-block timestamp adjustment: shift back by the silence-padding portion of the block + // so the reported timestamp reflects when the last real sample left the wire. + uint32_t frames_sent = real_frames; + if (real_frames < SPDIF_BLOCK_SAMPLES) { + const uint32_t frames_zeroed = SPDIF_BLOCK_SAMPLES - real_frames; + write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed); + } + + spdif_dma_event_count++; + // Accumulate frames; keep the latest timestamp so the callback reports when the last + // sample left the wire, not the first. + if (frames_sent > 0) { + spdif_pending_timestamp = write_timestamp; + spdif_pending_frames += frames_sent; + } + + bool decimation_reached = (spdif_dma_event_count >= SPDIF_DMA_EVENTS_PER_CALLBACK); + // Partial blocks mark an end-of-stream boundary (silence-padded tail). Fire immediately + // so the back-shifted timestamp isn't overwritten by a later full audio block landing + // in the same decimation window. + bool partial_flush = (real_frames > 0 && real_frames < SPDIF_BLOCK_SAMPLES); + + if (decimation_reached || partial_flush) { + if (spdif_pending_frames > 0) { + this->audio_output_callback_(spdif_pending_frames, spdif_pending_timestamp); + spdif_pending_frames = 0; + } + spdif_dma_event_count = 0; + } + } + if (lockstep_broken) { + ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); + break; + } + + // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write + // paces the task at the DMA consumption rate. + uint32_t real_frames_in_block = 0; + bool block_committed = false; + bool partial_write_failure = false; + + if (!this->pause_state_) { + while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { + if (audio_source->available() == 0) { + size_t bytes_read = audio_source->fill(read_timeout_ticks, false); + if (bytes_read == 0) { + break; // No upstream data within the read budget; silence-pad the remainder. + } + uint8_t *new_data = audio_source->mutable_data(); + this->apply_software_volume_(new_data, bytes_read); + this->swap_esp32_mono_samples_(new_data, bytes_read); + } + + const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block; + const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed); + const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed); + + uint32_t blocks_sent = 0; + size_t pcm_consumed = 0; + esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks, + &blocks_sent, &pcm_consumed); + if (err != ESP_OK) { + // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; + // resuming would credit the next iteration's bytes against an old block. Bail and + // let loop() restart the task with a clean encoder. + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; + } + + if (pcm_consumed > 0) { + audio_source->consume(pcm_consumed); + real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed); + } + if (blocks_sent > 0) { + block_committed = true; + break; + } + } + } + + if (partial_write_failure) { + break; + } + + if (!block_committed) { + // Pad whatever real audio we managed to feed (if any) with silence to complete one block, + // or emit a full silence block if the encoder is empty. + esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); + if (err != ESP_OK) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + break; + } + } + + // One block committed to DMA; push exactly one record carrying its real-audio frame count. + // Failure here means the records queue is full, which violates the lockstep invariant. + if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + + // Silence-timeout tracking and graceful-stop reset. + if (real_frames_in_block == 0) { + if (this->spdif_silence_start_ == 0) { + this->spdif_silence_start_ = millis(); + } + + if (this->timeout_.has_value()) { + const uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration >= this->timeout_.value()) { + ESP_LOGV(TAG, "Silence timeout reached (%" PRIu32 "ms) - stopping speaker", silence_duration); + break; + } + } + } else if (this->spdif_silence_start_ != 0) { + uint32_t silence_duration = millis() - this->spdif_silence_start_; + if (silence_duration > 100) { + ESP_LOGV(TAG, "Exiting silence mode after %" PRIu32 "ms, have audio data", silence_duration); + } + this->spdif_silence_start_ = 0; + } + } + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + // Reset SPDIF encoder state to prevent stale state on next start + if (this->spdif_encoder_ != nullptr) { + this->spdif_encoder_->set_preload_mode(false); + this->spdif_encoder_->reset(); + } + + audio_source.reset(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + // SPDIF never narrows the bit depth; the encoder consumes the input format directly. + this->output_stream_info_ = audio_stream_info; + + // SPDIF mode validation + if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { + ESP_LOGE(TAG, "Only supports a single sample rate (configured: %" PRIu32 " Hz, stream: %" PRIu32 " Hz)", + this->sample_rate_, audio_stream_info.get_sample_rate()); + return ESP_ERR_NOT_SUPPORTED; + } + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); + return ESP_ERR_NOT_SUPPORTED; + } + if (audio_stream_info.get_channels() != 2) { + ESP_LOGE(TAG, "Only supports stereo (2 channels)"); + return ESP_ERR_NOT_SUPPORTED; + } + + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); + + if (!this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // SPDIF mode: fixed configuration for BMC encoding + // For new driver, dma_frame_num is in I2S frames (8 bytes each for 32-bit stereo) + uint32_t dma_buffer_length = SPDIF_BLOCK_I2S_FRAMES; // One SPDIF block = 384 I2S frames = 3072 bytes + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames = %lu bytes total", (size_t) SPDIF_DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length, + (unsigned long) (SPDIF_DMA_BUFFERS_COUNT * dma_buffer_length * 8)); // 8 bytes per frame for 32-bit stereo + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = this->i2s_role_, + .dma_desc_num = SPDIF_DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // SPDIF: double sample rate for BMC, 32-bit stereo, only data pin needed + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = this->sample_rate_ * 2, + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_std_slot_config_t slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO); + + i2s_std_gpio_config_t gpio_cfg = { + .mclk = GPIO_NUM_NC, + .bclk = GPIO_NUM_NC, + .ws = GPIO_NUM_NC, + .dout = this->dout_pin_, + .din = GPIO_NUM_NC, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, SPDIF_I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + // Channel is NOT enabled here. The speaker task will preload DMA buffers + // with SPDIF-encoded silence before enabling, ensuring the first data on + // the wire is valid SPDIF (not raw zeros from auto_clear) and preventing + // phantom DMA events before real audio data is available. + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_SPDIF_MODE diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.h b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.h new file mode 100644 index 0000000..ca77741 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_spdif.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include "i2s_audio_speaker.h" +#include "spdif_encoder.h" + +namespace esphome::i2s_audio { + +/// @brief SPDIF speaker implementation. +/// Encodes PCM audio into IEC 60958-3 S/PDIF bitstream using BMC encoding, +/// outputting through a single I2S data pin. Maintains continuous output +/// (silence when no audio) to keep SPDIF receivers synchronized. +class I2SAudioSpeakerSPDIF : public I2SAudioSpeakerBase { + public: + void setup() override; + void dump_config() override; + + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + void on_task_stopped() override; + + SPDIFEncoder *spdif_encoder_{nullptr}; + uint32_t spdif_silence_start_{0}; // Timestamp when silence mode started (0 = not in silence) +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_SPDIF_MODE diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp new file mode 100644 index 0000000..af08c3c --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -0,0 +1,540 @@ +// Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +#include "i2s_audio_speaker.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +// esp-audio-libs +#include + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.speaker"; + +// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. +// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; + +void I2SAudioSpeakerBase::setup() { + this->event_group_ = xEventGroupCreate(); + + if (this->event_group_ == nullptr) { + ESP_LOGE(TAG, "Event group creation failed"); + this->mark_failed(); + return; + } + +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) request_volume_with_fence(0.0f); +#endif + // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // When no audio_dac is configured, this initializes software volume control. + this->set_volume(this->volume_); +} + +void I2SAudioSpeakerBase::dump_config() { + ESP_LOGCONFIG(TAG, + "Speaker:\n" + " Pin: %d\n" + " Buffer duration: %" PRIu32, + static_cast(this->dout_pin_), this->buffer_duration_ms_); + if (this->timeout_.has_value()) { + ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); + } +} + +void I2SAudioSpeakerBase::loop() { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Latch faults before cleanup clears event bits. Keep loop running to release + // the task, ring buffer and I2S channel; mark_failed() would skip that cleanup. + if (volume_fence_enabled_) volume_fence_healthy(); +#endif + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); +#ifdef USE_OPENATHAN_VOLUME_FENCE + end_volume_session_(); +#endif + } + + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_ && !volume_fence_healthy()) { + xEventGroupClearBits(event_group_, SpeakerEventGroupBits::COMMAND_START); + return; + } + begin_volume_session_(); +#endif + this->state_ = speaker::STATE_STARTING; + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } + + // Handle the task's state + if (event_group_bits & SpeakerEventGroupBits::TASK_STARTING) { + ESP_LOGD(TAG, "Starting"); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + } + if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) { + ESP_LOGV(TAG, "Started"); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + this->state_ = speaker::STATE_RUNNING; + } + if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { + ESP_LOGV(TAG, "Stopping"); + // Lockstep-breaking error bits are latched by the task and cleared along with all other bits + // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); + } + if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { + ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); + } + if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { + ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); + } + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + this->state_ = speaker::STATE_STOPPING; + } + if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPED) { + ESP_LOGD(TAG, "Stopped"); + + vTaskDelete(this->speaker_task_handle_); + this->speaker_task_handle_ = nullptr; + + this->stop_i2s_driver_(); +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Driver teardown quiesces callbacks. Capture faults raised after loop's + // initial snapshot before clearing event bits or completing an idle fence. + if (volume_fence_enabled_) volume_fence_healthy(); + end_volume_session_(); +#endif + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } + this->status_clear_error(); + + this->on_task_stopped(); + + this->state_ = speaker::STATE_STOPPED; + } + + if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } + + // Handle the speaker's state + switch (this->state_) { + case speaker::STATE_STARTING: +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_ && !volume_fence_healthy() && speaker_task_handle_ == nullptr) { + // Failed initialization/task creation already releases the parent lock. + // Only a still-live channel leaves this speaker owning the bus. + if (tx_handle_ != nullptr) stop_i2s_driver_(); + end_volume_session_(); + state_ = speaker::STATE_STOPPED; + break; + } +#endif + if (this->status_has_error()) { + break; + } + + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { + ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); +#ifdef USE_OPENATHAN_VOLUME_FENCE + fail_volume_fence_(); +#endif + this->status_momentary_error("driver-failure", 1000); + break; + } + + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); + + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); +#ifdef USE_OPENATHAN_VOLUME_FENCE + fail_volume_fence_(); +#endif + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + } + break; + case speaker::STATE_RUNNING: // Intentional fallthrough + case speaker::STATE_STOPPING: // Intentional fallthrough + case speaker::STATE_STOPPED: + break; + } +} + +void I2SAudioSpeakerBase::set_volume(float volume) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) return; // Calibrated owner, including restored player preferences. +#endif + this->volume_ = volume; +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + if (volume > 0.0f) { + this->audio_dac_->set_mute_off(); + } + this->audio_dac_->set_volume(volume); + } else +#endif // USE_AUDIO_DAC + { + // Fallback to software volume control by using a Q31 fixed point scaling factor. + // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely + // and avoid any floating-point precision issues that could cause slight volume reduction. + if (volume >= 1.0f) { + this->q31_volume_factor_ = INT32_MAX; + } else if (volume <= 0.0f) { + this->q31_volume_factor_ = 0; + } else { + this->q31_volume_factor_ = + esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); + } + } +} + +void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) return; +#endif + this->mute_state_ = mute_state; +#ifdef USE_AUDIO_DAC + if (this->audio_dac_) { + if (mute_state) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } else +#endif // USE_AUDIO_DAC + { + if (mute_state) { + // Fallback to software volume control and scale by 0 + this->q31_volume_factor_ = 0; + } else { + // Revert to previous volume when unmuting + this->set_volume(this->volume_); + } + } +} + +size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_ && !volume_fence_healthy()) return 0; +#endif + if (this->is_failed()) { + ESP_LOGE(TAG, "Setup failed; cannot play audio"); + return 0; + } + + if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) { + this->start(); + } + + if (this->state_ != speaker::STATE_RUNNING) { + // Unable to write data to a running speaker, so delay the max amount of time so it can get ready + vTaskDelay(ticks_to_wait); + ticks_to_wait = 0; + } + + size_t bytes_written = 0; + if (this->state_ == speaker::STATE_RUNNING) { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + // The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write + bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait); + } + } + + return bytes_written; +} + +bool I2SAudioSpeakerBase::has_buffered_data() const { + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { + return temp_ring_buffer->available() > 0; + } + return false; +} + +void I2SAudioSpeakerBase::speaker_task(void *params) { + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params; + this_speaker->run_speaker_task(); +} + +void I2SAudioSpeakerBase::start() { + if (!this->is_ready() || this->is_failed() || this->status_has_error()) + return; + if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) + return; + +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_ && !volume_fence_healthy()) return; + begin_volume_session_(); +#endif + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); +} + +void I2SAudioSpeakerBase::stop() { this->stop_(false); } + +void I2SAudioSpeakerBase::finish() { this->stop_(true); } + +void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { + if (!this->is_ready() || this->is_failed()) + return; + +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) { + portENTER_CRITICAL(&volume_mutex_); + volume_fence_.cancel(); + portEXIT_CRITICAL(&volume_mutex_); + } +#endif + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending + if (wait_on_empty) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + } else { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + } +} + +esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size) { + esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err)); + this->parent_->unlock(); + return err; + } + + err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize I2S channel"); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + this->parent_->unlock(); + return err; + } + + if (this->i2s_event_queue_ == nullptr) { + this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); + } else { + // Reset queue to clear any stale events from previous task + xQueueReset(this->i2s_event_queue_); + } + + // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue + // so a fully-saturated DMA pipeline cannot overflow either side before drain. + if (this->write_records_queue_ == nullptr) { + size_t record_size = sizeof(uint32_t); +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) record_size = sizeof(VolumeDmaRecord); +#endif + this->write_records_queue_ = xQueueCreate(event_queue_size, record_size); + } else { + xQueueReset(this->write_records_queue_); + } + + if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + this->parent_->unlock(); + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +void I2SAudioSpeakerBase::stop_i2s_driver_() { + if (this->tx_handle_ != nullptr) { + i2s_channel_disable(this->tx_handle_); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); + } + this->parent_->unlock(); +} + +bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { + int64_t now = esp_timer_get_time(); + + BaseType_t need_yield1 = pdFALSE; + BaseType_t need_yield2 = pdFALSE; + BaseType_t need_yield3 = pdFALSE; + + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; + + if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { + // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` + // and any per-buffer record queue maintained by the task are permanently desynced, so the task + // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP + // (so the task bails immediately, closing the race where loop() could clear the error bit + // before the task observes it). +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Event-group updates from ISR are deferred to FreeRTOS's timer task. + // Latch under the same ISR-safe lock before any fence acknowledgement. + if (this_speaker->volume_fence_enabled_) { + portENTER_CRITICAL_ISR(&this_speaker->volume_mutex_); + this_speaker->volume_fence_.fail(); + portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); + } +#endif + int64_t dummy; + xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); + xEventGroupSetBitsFromISR(this_speaker->event_group_, + SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, + &need_yield2); + } + + const BaseType_t sent = xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (this_speaker->volume_fence_enabled_ && sent != pdTRUE) { + portENTER_CRITICAL_ISR(&this_speaker->volume_mutex_); + this_speaker->volume_fence_.fail(); + portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); + } +#endif + + return need_yield1 | need_yield2 | need_yield3; +} + +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { + apply_volume_factor_(data, bytes_read, q31_volume_factor_); +} + +void I2SAudioSpeakerBase::apply_volume_factor_(uint8_t *data, size_t bytes_read, int32_t factor) { + if (factor == INT32_MAX) { + return; // Max volume, no processing needed + } + + const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); + const uint32_t len = bytes_read / bytes_per_sample; + + esp_audio_libs::gain::apply(data, data, factor, len, bytes_per_sample); +} + +void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { +#ifdef USE_ESP32_VARIANT_ESP32 + // For ESP32 16-bit mono mode, adjacent samples need to be swapped. + if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { + int16_t *samples = reinterpret_cast(data); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; + } + } +#endif // USE_ESP32_VARIANT_ESP32 +} + +#ifdef USE_OPENATHAN_VOLUME_FENCE +VolumeFenceToken I2SAudioSpeakerBase::request_volume_with_fence(float level) { + if (!volume_fence_enabled_ || !std::isfinite(level) || level < 0 || level > 1) return {}; + const int32_t factor = level >= 1 ? INT32_MAX : level <= 0 ? 0 : + esp_audio_libs::gain::db_to_q31(remap(level, 0, 1, SOFTWARE_VOLUME_MIN_DB, 0)); + portENTER_CRITICAL(&volume_mutex_); + auto token = volume_fence_.request(level, factor); + portEXIT_CRITICAL(&volume_mutex_); + return token; +} + +bool I2SAudioSpeakerBase::volume_fence_healthy() { + constexpr uint32_t faults = ERR_ESP_NO_MEM | ERR_DROPPED_EVENT | ERR_PARTIAL_WRITE | ERR_LOCKSTEP_DESYNC; + if (is_failed() || (event_group_ && (xEventGroupGetBits(event_group_) & faults))) fail_volume_fence_(); + portENTER_CRITICAL(&volume_mutex_); + bool healthy = volume_fence_enabled_ && !volume_fence_.failed(); + portEXIT_CRITICAL(&volume_mutex_); + return healthy; +} + +VolumeFenceStatus I2SAudioSpeakerBase::volume_fence_status(VolumeFenceToken token) { + if (!volume_fence_healthy()) return VolumeFenceStatus::FAILED; + if (event_group_ && (xEventGroupGetBits(event_group_) & (COMMAND_STOP | COMMAND_STOP_GRACEFULLY | + TASK_STOPPING | TASK_STOPPED))) + return VolumeFenceStatus::CANCELLED; + portENTER_CRITICAL(&volume_mutex_); + auto status = volume_fence_.status(token); + portEXIT_CRITICAL(&volume_mutex_); + return status; +} + +VolumeGain I2SAudioSpeakerBase::volume_gain_snapshot_() { + portENTER_CRITICAL(&volume_mutex_); + auto gain = volume_fence_.snapshot(); + portEXIT_CRITICAL(&volume_mutex_); + return gain; +} +float I2SAudioSpeakerBase::fenced_volume() { + portENTER_CRITICAL(&volume_mutex_); + float level = volume_fence_.level(); + portEXIT_CRITICAL(&volume_mutex_); + return level; +} +void I2SAudioSpeakerBase::complete_volume_fence_(VolumeFenceToken token) { + portENTER_CRITICAL(&volume_mutex_); + // Records from other sessions are never admissible, even if their gain agrees. + if (token.session != volume_fence_.snapshot().token.session) volume_fence_.fail(); + else volume_fence_.complete(token); + portEXIT_CRITICAL(&volume_mutex_); +} +void I2SAudioSpeakerBase::fail_volume_fence_() { + if (!volume_fence_enabled_) return; + portENTER_CRITICAL(&volume_mutex_); + volume_fence_.fail(); + portEXIT_CRITICAL(&volume_mutex_); +} +void I2SAudioSpeakerBase::begin_volume_session_() { + if (!volume_fence_enabled_) return; + portENTER_CRITICAL(&volume_mutex_); + if (!volume_fence_.active()) volume_fence_.begin_session(); + portEXIT_CRITICAL(&volume_mutex_); +} +void I2SAudioSpeakerBase::end_volume_session_() { + if (!volume_fence_enabled_) return; + portENTER_CRITICAL(&volume_mutex_); + volume_fence_.end_session(); + portEXIT_CRITICAL(&volume_mutex_); +} +#endif + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h new file mode 100644 index 0000000..1a84714 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -0,0 +1,195 @@ +// Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +#pragma once + +#ifdef USE_ESP32 + +#include "../i2s_audio.h" +#ifdef USE_OPENATHAN_VOLUME_FENCE +#include "volume_fence.h" +#endif + +#include +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/ring_buffer/ring_buffer.h" +#include "esphome/components/speaker/speaker.h" + +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +namespace esphome::i2s_audio { + +// Shared constants used by both standard and SPDIF speaker implementations +static constexpr size_t TASK_STACK_SIZE = 4096; +static constexpr ssize_t TASK_PRIORITY = 19; + +enum SpeakerEventGroupBits : uint32_t { + COMMAND_START = (1 << 0), // indicates loop should start speaker task + COMMAND_STOP = (1 << 1), // stops the speaker task + COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written + + TASK_STARTING = (1 << 10), + TASK_RUNNING = (1 << 11), + TASK_STOPPING = (1 << 12), + TASK_STOPPED = (1 << 13), + + ERR_ESP_NO_MEM = (1 << 19), + + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event + ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested + ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + + ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits +}; + +/// @brief Abstract base class for I2S audio speaker implementations. +/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle) +/// for derived standard I2S and SPDIF speaker classes. +class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component { + public: + float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } + + void setup() override; + void dump_config() override; + void loop() override; + + void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } + void set_timeout(uint32_t ms) { this->timeout_ = ms; } + void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } + + /// @brief Get the I2S TX channel handle + i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; } + + void start() override; + void stop() override; + void finish() override; + + void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; } + bool get_pause_state() const override { return this->pause_state_; } + + /// @brief Plays the provided audio data. + /// Starts the speaker task, if necessary. Writes the audio data to the ring buffer. + /// @param data Audio data in the format set by the parent speaker classes ``set_audio_stream_info`` method. + /// @param length The length of the audio data in bytes. + /// @param ticks_to_wait The FreeRTOS ticks to wait before writing as much data as possible to the ring buffer. + /// @return The number of bytes that were actually written to the ring buffer. + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + size_t play(const uint8_t *data, size_t length) override { return play(data, length, 0); } + + bool has_buffered_data() const override; + + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is + /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a + /// Q15 fixed-point factor. + /// @param volume between 0.0 and 1.0 + void set_volume(float volume) override; + + /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is + /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a + /// Q15 fixed-point factor. + /// @param mute_state true for muting, false for unmuting + void set_mute_state(bool mute_state) override; + +#ifdef USE_OPENATHAN_VOLUME_FENCE + void set_volume_fence(bool enabled) { volume_fence_enabled_ = enabled; } + VolumeFenceToken request_volume_with_fence(float level); + VolumeFenceStatus volume_fence_status(VolumeFenceToken token); + bool volume_fence_healthy(); + float fenced_volume(); +#endif + + protected: +#ifdef USE_OPENATHAN_VOLUME_FENCE + VolumeGain volume_gain_snapshot_(); + void complete_volume_fence_(VolumeFenceToken token); + void fail_volume_fence_(); + void begin_volume_session_(); + void end_volume_session_(); + bool volume_fence_enabled_{}; + portMUX_TYPE volume_mutex_ = portMUX_INITIALIZER_UNLOCKED; + VolumeFenceState volume_fence_; +#endif + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). + /// @param params I2SAudioSpeakerBase component pointer + static void speaker_task(void *params); + + /// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior. + virtual void run_speaker_task() = 0; + + /// @brief Sends a stop command to the speaker task via ``event_group_``. + /// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal. + void stop_(bool wait_on_empty); + + /// @brief Callback function used to send playback timestamps to the speaker task. + /// @param handle (i2s_chan_handle_t) + /// @param event (i2s_event_data_t) + /// @param user_ctx (void*) User context pointer that the callback accesses + /// @return True if a higher priority task was interrupted + static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + /// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration. + /// @param audio_stream_info Stream information for the I2S driver. + /// @return ESP_OK if successful, or an error code + virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0; + + /// @brief Shared I2S channel allocation, initialization, and event queue setup. + /// Called by derived start_i2s_driver_() implementations after building mode-specific configs. + /// @param chan_cfg I2S channel configuration + /// @param std_cfg I2S standard mode configuration (clock, slot, GPIO) + /// @param event_queue_size Size of the event queue + /// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent. + esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + size_t event_queue_size); + + /// @brief Stops the I2S driver and unlocks the I2S port + void stop_i2s_driver_(); + + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. + virtual void on_task_stopped() {} + + /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void apply_software_volume_(uint8_t *data, size_t bytes_read); + void apply_volume_factor_(uint8_t *data, size_t bytes_read, int32_t factor); + + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. + /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is + /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. + /// @param data Pointer to audio sample data (modified in place) + /// @param bytes_read Number of bytes of audio data + void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); + + TaskHandle_t speaker_task_handle_{nullptr}; + EventGroupHandle_t event_group_{nullptr}; + + // Lockstepped DMA buffer queues: i2s_event is outgoing, write_records is incoming + QueueHandle_t i2s_event_queue_{nullptr}; + QueueHandle_t write_records_queue_{nullptr}; + + std::weak_ptr audio_ring_buffer_; + + uint32_t buffer_duration_ms_; + + optional timeout_; + + bool pause_state_{false}; + + int32_t q31_volume_factor_{INT32_MAX}; + + audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) + // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as + // current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than + // the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts. + audio::AudioStreamInfo output_stream_info_; + + gpio_num_t dout_pin_; + i2s_chan_handle_t tx_handle_{nullptr}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp new file mode 100644 index 0000000..77dcdbb --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -0,0 +1,569 @@ +// Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +#include "i2s_audio_speaker_standard.h" + +#ifdef USE_ESP32 + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/components/audio/audio_transfer_buffer.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include "esp_timer.h" + +// esp-audio-libs +#include + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.speaker.std"; + +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; +static constexpr size_t DMA_BUFFERS_COUNT = 5; +// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in +// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the +// speaker task reads the size actually allocated back from the driver rather than relying on this value. +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif +// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, +// doubled so that a transient backlog never overruns the queue (which would desync the lockstep +// invariant between i2s_event_queue_ and write_records_queue_). +static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; +// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every +// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter +// without masking real failures. +static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); + +// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF +// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust +// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back +// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a +// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { + const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); + return std::min(frames_from_duration, max_frames); +} + +void I2SAudioSpeaker::dump_config() { + I2SAudioSpeakerBase::dump_config(); + const char *fmt_str; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + fmt_str = "pcm"; + break; + case I2SCommFmt::MSB: + fmt_str = "msb"; + break; + default: + fmt_str = "std"; + break; + } + ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + // The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to + // it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this + // is not necessarily the sample data width (which depends on the incoming stream). + ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast(this->slot_bit_width_)); + } +} + +void I2SAudioSpeaker::run_speaker_task() { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + + const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; + // Ensure ring buffer duration is at least the duration of all DMA buffers + const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); + + // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. + const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); + // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and + // avoids unnecessary single-frame splices. + const size_t ring_buffer_size = + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + + // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. + const uint8_t channels = this->current_stream_info_.get_channels(); + const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8; + const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8; + const bool narrowing = input_bytes_per_sample != output_bytes_per_sample; + + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it + // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through + // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually + // allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is + // in the READY state here because start_i2s_driver() initialized it before this task was created. + size_t dma_buffer_bytes; +#ifdef USE_OPENATHAN_VOLUME_FENCE + bool verified_dma_layout = false; +#endif + i2s_chan_info_t chan_info; + if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) { + // total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count. + dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; +#ifdef USE_OPENATHAN_VOLUME_FENCE + verified_dma_layout = chan_info.total_dma_buf_size % DMA_BUFFERS_COUNT == 0 && + dma_buffer_bytes % this->output_stream_info_.frames_to_bytes(1) == 0; +#endif + } else { + // Should not happen for a READY channel; fall back to the requested size. + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); + } +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_ && !verified_dma_layout) { + // A guessed descriptor size cannot establish FIFO completion boundaries. + xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC | TASK_STOPPING | TASK_STOPPED); + while (true) vTaskDelay(pdMS_TO_TICKS(10)); + } +#endif + // dma_buffer_bytes counts output-format bytes; convert with the output stream info. + const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); + // Soft cap for each source read: enough input-format bytes to fill one DMA buffer's worth of frames. + const size_t dma_buffer_input_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); + + bool successful_setup = false; + + std::unique_ptr audio_source; + + // Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it. + RAMAllocator silence_allocator; + uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes); + + if (silence_buffer != nullptr) { + memset(silence_buffer, 0, dma_buffer_bytes); + + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, + static_cast(bytes_per_frame)); + + if (audio_source != nullptr) { + // audio_source is nullptr if the ring buffer fails to allocate + this->audio_ring_buffer_ = temp_ring_buffer; + successful_setup = true; + } + } + + if (successful_setup) { + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. + // This guarantees that every on_sent event has a corresponding write record from the start, so + // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { + size_t bytes_loaded = 0; + esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); + if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { + ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, + (unsigned) bytes_loaded); + successful_setup = false; + break; + } + uint32_t zero_real_frames = 0; + const void *record = &zero_real_frames; +#ifdef USE_OPENATHAN_VOLUME_FENCE + VolumeDmaRecord fenced_record{0, volume_gain_snapshot_().token}; + if (volume_fence_enabled_) record = &fenced_record; +#endif + if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { + // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. + ESP_LOGV(TAG, "Failed to push preload write record"); + successful_setup = false; + break; + } + } + } + + if (successful_setup) { + // Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer + // generates a queued event that pairs with the first preloaded silence record. + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + if (i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this) != ESP_OK) + successful_setup = false; + + if (!successful_setup || i2s_channel_enable(this->tx_handle_) != ESP_OK) { + ESP_LOGV(TAG, "Failed to enable I2S channel"); + successful_setup = false; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + bool stop_gracefully = false; + // Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful + // stop to wait until every real-audio buffer has been confirmed played by an ISR event. + uint32_t pending_real_buffers = 0; + uint32_t last_data_received_time = millis(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // Main speaker task loop. Continues while: + // - Paused, OR + // - No timeout configured, OR + // - Timeout hasn't elapsed since last data + // + // Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio + // and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA + // consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``. + // + // While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence; + // the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS), + // so the lockstep invariant is preserved without burning CPU. + while (this->pause_state_ || !this->timeout_.has_value() || + (millis() - last_data_received_time) <= this->timeout_.value()) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion + // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); + break; + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + stop_gracefully = true; + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + // Audio stream info changed, stop the speaker task so it will restart with the proper settings. + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ + // entry by construction (preloaded records at startup, plus exactly one record pushed per + // iteration alongside exactly one DMA-buffer-sized write). + int64_t write_timestamp; + bool lockstep_broken = false; +#ifdef USE_OPENATHAN_VOLUME_FENCE + VolumeFenceToken completed_token{}; + if (volume_fence_enabled_ && uxQueueMessagesWaiting(i2s_event_queue_) > + uxQueueMessagesWaiting(write_records_queue_)) { + xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); + break; + } +#endif + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + uint32_t real_frames = 0; + void *record = &real_frames; +#ifdef USE_OPENATHAN_VOLUME_FENCE + VolumeDmaRecord fenced_record{}; + if (volume_fence_enabled_) record = &fenced_record; +#endif + if (xQueueReceive(this->write_records_queue_, record, 0) != pdTRUE) { + // Should never happen: would indicate the lockstep invariant is broken. + ESP_LOGV(TAG, "Event without matching write record"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; + break; + } +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) { + real_frames = fenced_record.real_frames; + completed_token = fenced_record.token; + } +#endif + if (real_frames > frames_per_dma_buffer || (real_frames && !pending_real_buffers)) { + xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); + lockstep_broken = true; + break; + } + if (real_frames > 0) { + pending_real_buffers--; + // Real audio is packed at the start of each DMA buffer with any silence padding on the + // tail, so the real audio finished playing earlier than the buffer-completion timestamp + // by the duration of the trailing zeros. + const uint32_t silence_frames = frames_per_dma_buffer - real_frames; + const int64_t adjusted_ts = + write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames); + this->audio_output_callback_(real_frames, adjusted_ts); + } + } + if (lockstep_broken) { + break; + } + +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Publish only after this entire completion batch has validated. A later + // missing record in the same batch must not briefly authorize unmute. + if (volume_fence_enabled_ && completed_token.generation && volume_fence_healthy()) + complete_volume_fence_(completed_token); +#endif + // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring + // buffer has nothing left to hand over, and every real-audio buffer we submitted has been + // confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer + // awaiting fill(). + if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() && + pending_real_buffers == 0) { + ESP_LOGV(TAG, "Exiting: graceful stop complete"); + break; + } + + // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently + // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any + // remainder with silence. All writes pack into the next free DMA descriptor in order, so the + // descriptor ends up holding [real audio][silence padding]. ``bytes_written_total`` counts + // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. +#ifdef USE_OPENATHAN_VOLUME_FENCE + const VolumeGain buffer_gain = volume_gain_snapshot_(); +#endif + size_t bytes_written_total = 0; + uint32_t real_frames_total = 0; + bool partial_write_failure = false; + + if (!this->pause_state_) { + while (bytes_written_total < dma_buffer_bytes) { + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); + bool scale_on_fill = true; +#ifdef USE_OPENATHAN_VOLUME_FENCE + scale_on_fill = !volume_fence_enabled_; +#endif + if (bytes_read > 0 && scale_on_fill) { + // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. + uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; + this->apply_software_volume_(new_data, bytes_read); + } + + // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source + // currently exposes. Frame counts are shared between input and output; only the byte widths differ. + const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + const uint32_t frames_room = + this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + const uint32_t frames_to_write = std::min(frames_available, frames_room); + if (frames_to_write == 0) { + // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer + // with silence so the lockstep invariant (one write per iteration) is preserved. + break; + } + + const size_t input_bytes = this->current_stream_info_.frames_to_bytes(frames_to_write); + const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write); + + uint8_t *chunk = audio_source->mutable_data(); +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Leave every staged tail raw. A paused silence fence must not leave + // already-scaled louder samples waiting to play after resume. + if (volume_fence_enabled_) apply_volume_factor_(chunk, input_bytes, buffer_gain.factor); +#endif + if (narrowing) { + // Narrow the bit depth in place: output exactly aliases input with the same channel count and a + // smaller width, which copy_frames handles as a single forward pass. Only the frames about to be + // consumed are overwritten, so any unprocessed tail stays intact for the next iteration. + esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels, + output_bytes_per_sample, channels, frames_to_write); + } + this->swap_esp32_mono_samples_(chunk, output_bytes); + + size_t bw = 0; + esp_err_t write_error = i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (write_error != ESP_OK || bw != output_bytes) { + // A short real-audio write breaks DMA descriptor alignment for every subsequent event; + // the only safe recovery is to restart the task. + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; + } + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; + } + if (real_frames_total > 0) { + last_data_received_time = millis(); + } + } + + if (partial_write_failure) { + break; + } + + const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; + if (silence_bytes > 0) { + size_t bw = 0; + esp_err_t write_error = i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (write_error != ESP_OK || bw != silence_bytes) { + // Same descriptor-alignment hazard as a partial real-audio write. + ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + break; + } + } + + // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this + // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep + // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + const void *record = &real_frames_total; +#ifdef USE_OPENATHAN_VOLUME_FENCE + const VolumeDmaRecord fenced_record{real_frames_total, buffer_gain.token}; + if (volume_fence_enabled_) record = &fenced_record; +#endif + if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { + ESP_LOGV(TAG, "Exiting: write records queue full"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + if (real_frames_total > 0) { + pending_real_buffers++; + } + } + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); + + audio_source.reset(); + + if (silence_buffer != nullptr) { + silence_allocator.deallocate(silence_buffer, dma_buffer_bytes); + silence_buffer = nullptr; + } + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED); + + while (true) { + // Continuously delay until the loop method deletes the task + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { + this->current_stream_info_ = audio_stream_info; + + if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT + // Can't reconfigure I2S bus, so the sample rate must match the configured value + ESP_LOGE(TAG, "Incompatible stream settings"); + return ESP_ERR_NOT_SUPPORTED; + } + + // When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place + // before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and + // the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed + // through unchanged (the slot may still be wider than the data, the existing behavior). + uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + const uint8_t configured_bits = static_cast(this->slot_bit_width_); + if (output_bits_per_sample > configured_bits) { + output_bits_per_sample = configured_bits; + } + } + this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(), + audio_stream_info.get_sample_rate()); + +#ifdef USE_ESP32_VARIANT_ESP32 + // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample + // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples + // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple + // of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine + // as long as it narrows to a 16- or 32-bit slot. + if (output_bits_per_sample % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample); + return ESP_ERR_NOT_SUPPORTED; + } +#endif // USE_ESP32_VARIANT_ESP32 + + if (!this->parent_->try_lock()) { + ESP_LOGE(TAG, "Parent bus is busy"); + return ESP_ERR_INVALID_STATE; + } + + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); + + i2s_role_t i2s_role = this->i2s_role_; + i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; + +#if SOC_CLK_APLL_SUPPORTED + if (this->use_apll_) { + clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; + } +#endif // SOC_CLK_APLL_SUPPORTED + + // Log DMA configuration for debugging + ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT, + (unsigned long) dma_buffer_length); + + i2s_chan_config_t chan_cfg = { + .id = this->parent_->get_port(), + .role = i2s_role, + .dma_desc_num = DMA_BUFFERS_COUNT, + .dma_frame_num = dma_buffer_length, + .auto_clear = true, + .intr_priority = 3, + }; + + // Build standard I2S clock/slot/gpio configuration + i2s_std_clk_config_t clk_cfg = { + .sample_rate_hz = audio_stream_info.get_sample_rate(), + .clk_src = clk_src, + .mclk_multiple = this->mclk_multiple_, + }; + + i2s_slot_mode_t slot_mode = this->slot_mode_; + i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; + if (audio_stream_info.get_channels() == 1) { + slot_mode = I2S_SLOT_MODE_MONO; + } else if (audio_stream_info.get_channels() == 2) { + slot_mode = I2S_SLOT_MODE_STEREO; + slot_mask = I2S_STD_SLOT_BOTH; + } + + // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); + i2s_std_slot_config_t slot_cfg; + switch (this->i2s_comm_fmt_) { + case I2SCommFmt::PCM: + slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); + break; + case I2SCommFmt::MSB: + slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); + break; + default: + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); + break; + } + +#ifdef USE_ESP32_VARIANT_ESP32 + // There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the + // bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems + // to make it play at the correct speed while sending more bits per slot. + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + uint32_t configured_bit_width = static_cast(this->slot_bit_width_); + slot_cfg.ws_width = configured_bit_width; + if (configured_bit_width > 16) { + slot_cfg.msb_right = false; + } + } +#else + slot_cfg.slot_bit_width = this->slot_bit_width_; + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + slot_cfg.ws_width = static_cast(this->slot_bit_width_); + } +#endif // USE_ESP32_VARIANT_ESP32 + slot_cfg.slot_mask = slot_mask; + + i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config(); + gpio_cfg.dout = this->dout_pin_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = slot_cfg, + .gpio_cfg = gpio_cfg, + }; + + esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); + if (err != ESP_OK) { + return err; + } + + // The speaker task will enable the channel after preloading. + + return ESP_OK; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.h new file mode 100644 index 0000000..4b52dcd --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -0,0 +1,32 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "i2s_audio_speaker.h" + +namespace esphome::i2s_audio { + +enum class I2SCommFmt : uint8_t { + STANDARD, // Philips / I2S standard + PCM, // PCM short + MSB, // MSB / left-justified +}; + +/// @brief Standard I2S speaker implementation. +/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. +class I2SAudioSpeaker final : public I2SAudioSpeakerBase { + public: + void dump_config() override; + + void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; } + + protected: + void run_speaker_task() override; + esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.cpp new file mode 100644 index 0000000..30146e0 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.cpp @@ -0,0 +1,492 @@ +#include "spdif_encoder.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include "esphome/core/log.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio.spdif_encoder"; + +// S/PDIF preamble patterns (8 BMC bits each) +// These are the BMC-encoded sync patterns that violate normal BMC rules for easy detection. +// All preambles end at phase HIGH (last bit = 1), enabling consistent data encoding. +// Preamble is placed at bits 24-31 of word[0] for MSB-first transmission. +static constexpr uint8_t PREAMBLE_B = 0x17; // Block start (left channel, frame 0) +static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) +static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel + +// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. +static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; + +// Constexpr BMC encoder for compile-time LUT generation. +// Encodes with start phase=true (HIGH). The complement property allows phase=false +// via XOR: bmc_encode(v, N, false) == bmc_encode(v, N, true) ^ mask +static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { + uint16_t bmc = 0; + bool phase = true; + for (uint8_t i = 0; i < num_bits; i++) { + bool bit = (data >> i) & 1; + uint8_t bmc_pair = phase ? (bit ? 0b01 : 0b00) : (bit ? 0b10 : 0b11); + bmc |= static_cast(bmc_pair) << ((num_bits - 1 - i) * 2); + if (!bit) + phase = !phase; + } + return bmc; +} + +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) +static constexpr auto BMC_LUT_4 = [] { + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } + return t; +}(); + +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) +static constexpr auto BMC_LUT_8 = [] { + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } + return t; +}(); + +// Initialize S/PDIF buffer +bool SPDIFEncoder::setup() { + this->spdif_block_buf_ = std::make_unique(SPDIF_BLOCK_SIZE_U32); + if (!this->spdif_block_buf_) { + ESP_LOGE(TAG, "Buffer allocation failed (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); + return false; + } + ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); + + // Build initial channel status block with default sample rate and width + this->build_channel_status_(); + + this->reset(); + return true; +} + +void SPDIFEncoder::reset() { + this->spdif_block_ptr_ = this->spdif_block_buf_.get(); + this->frame_in_block_ = 0; + this->block_buf_is_silence_block_ = false; +} + +void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { + if (this->sample_rate_ != sample_rate) { + this->sample_rate_ = sample_rate; + this->build_channel_status_(); + ESP_LOGD(TAG, "Sample rate set to %lu Hz", (unsigned long) sample_rate); + } +} + +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + +void SPDIFEncoder::build_channel_status_() { + // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; + + this->channel_status_.fill(0); + + // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 + // All bits are 0, which is already set + + // Byte 1: Category code = 0x00 (general) + // Already 0 + + // Byte 2: Source/channel unspecified + // Already 0 + + // Byte 3: Sample frequency code (bits 0-3) + clock accuracy (bits 4-5) + // Clock accuracy = 00 (Level II, ±1000 ppm) - appropriate for ESP32 + uint8_t freq_code; + switch (this->sample_rate_) { + case 44100: + freq_code = 0x0; // 0000 + break; + case 48000: + freq_code = 0x2; // 0010 + break; + default: + // Other values are possible but they're not supported by ESPHome + freq_code = 0x1; // 0001 = not indicated + ESP_LOGW(TAG, "Unsupported sample rate %lu Hz, channel status will indicate 'not specified'", + (unsigned long) this->sample_rate_); + break; + } + // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 + this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 + + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; +} + +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} + +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ + +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); + } +} + +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); + } else { + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); + } +} + +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; + } +} + +esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { + // Use the appropriate callback and context based on preload mode + SPDIFBlockCallback callback; + void *ctx; + + if (this->preload_mode_) { + callback = this->preload_callback_; + ctx = this->preload_callback_ctx_; + } else { + callback = this->write_callback_; + ctx = this->write_callback_ctx_; + } + + if (callback == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = callback(ctx, this->spdif_block_buf_.get(), SPDIF_BLOCK_SIZE_BYTES, ticks_to_wait); + + if (err == ESP_OK) { + // Reset pointer for next block; position tracking continues from where it left off + this->spdif_block_ptr_ = this->spdif_block_buf_.get(); + } + + return err; +} + +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; + } + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; +} + +HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed) { + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} + +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); + } + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); + } + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; + } + return this->send_block_(ticks_to_wait); +} + +esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; + } +} + +} // namespace esphome::i2s_audio + +#endif // USE_I2S_AUDIO_SPDIF_MODE diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.h b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.h new file mode 100644 index 0000000..9e23a85 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/spdif_encoder.h @@ -0,0 +1,148 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_SPDIF_MODE) + +#include +#include +#include +#include +#include "esp_err.h" +#include "esphome/core/helpers.h" + +namespace esphome::i2s_audio { + +// A SPDIF sample is 64-bits +static constexpr uint8_t SPDIF_BITS_PER_SAMPLE = 64; +// Number of samples in a SPDIF block +static constexpr uint16_t SPDIF_BLOCK_SAMPLES = 192; +// To emulate bi-phase mark code (BMC) (aka differential Manchester encoding) we send twice +// as many bits per sample so that we can generate the transitions this encoding requires. +static constexpr uint8_t EMULATED_BMC_BITS_PER_SAMPLE = SPDIF_BITS_PER_SAMPLE * 2; +static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULATED_BMC_BITS_PER_SAMPLE / 8); +static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 +// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) +static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames + +/// Callback signature for block completion (raw function pointer for minimal overhead) +/// @param user_ctx User context pointer passed during callback registration +/// @param data Pointer to SPDIF encoded block data +/// @param size Size of the block in bytes (always SPDIF_BLOCK_SIZE_BYTES) +/// @param ticks_to_wait FreeRTOS ticks to wait for write completion +/// @return ESP_OK on success, or an error code +using SPDIFBlockCallback = esp_err_t (*)(void *user_ctx, uint32_t *data, size_t size, TickType_t ticks_to_wait); + +class SPDIFEncoder { + public: + /// @brief Initialize the SPDIF working buffer + /// @return true if setup was successful, false if allocation failed + bool setup(); + + /// @brief Set callback for normal writes (used when channel is running) + /// @param callback Function pointer to call when a block is ready + /// @param user_ctx Context pointer passed to callback (typically 'this' pointer of speaker) + void set_write_callback(SPDIFBlockCallback callback, void *user_ctx) { + this->write_callback_ = callback; + this->write_callback_ctx_ = user_ctx; + } + + /// @brief Set callback for preload writes (used when preloading to DMA before enabling channel) + /// @param callback Function pointer to call when a block is ready for preload + /// @param user_ctx Context pointer passed to callback (typically 'this' pointer of speaker) + void set_preload_callback(SPDIFBlockCallback callback, void *user_ctx) { + this->preload_callback_ = callback; + this->preload_callback_ctx_ = user_ctx; + } + + /// @brief Enable or disable preload mode + /// When in preload mode, completed blocks use the preload callback instead of write callback + void set_preload_mode(bool preload) { this->preload_mode_ = preload; } + + /// @brief Check if currently in preload mode + bool is_preload_mode() const { return this->preload_mode_; } + + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + + /// @brief Convert PCM audio data to SPDIF BMC encoded data + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) + /// @param size Size of source data in bytes + /// @param ticks_to_wait Timeout for blocking writes + /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent + /// @param bytes_consumed Optional pointer to receive the number of PCM bytes consumed from src + /// @return esp_err_t as returned from the callback + esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, + size_t *bytes_consumed = nullptr); + + /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, + /// or send a full silence block if nothing is pending. Always produces exactly one block on success. + /// @param ticks_to_wait Timeout for blocking writes + /// @return esp_err_t as returned from the callback + esp_err_t flush_with_silence(TickType_t ticks_to_wait); + + /// @brief Reset the SPDIF block buffer and position tracking, discarding any partial block + void reset(); + + /// @brief Set the sample rate for Channel Status Block encoding + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) + /// Call this before writing audio data to ensure correct channel status. + void set_sample_rate(uint32_t sample_rate); + + /// @brief Get the currently configured sample rate + uint32_t get_sample_rate() const { return this->sample_rate_; } + + protected: + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); + + /// @brief Send the completed block via the appropriate callback + esp_err_t send_block_(TickType_t ticks_to_wait); + + /// @brief Build the channel status block from current configuration + void build_channel_status_(); + + // Member ordering optimized to minimize padding (largest alignment first) + + // 4-byte aligned members (pointers and uint32_t) + SPDIFBlockCallback write_callback_{nullptr}; + SPDIFBlockCallback preload_callback_{nullptr}; + void *write_callback_ctx_{nullptr}; + void *preload_callback_ctx_{nullptr}; + std::unique_ptr spdif_block_buf_; // Working buffer for SPDIF block (heap allocated) + uint32_t *spdif_block_ptr_{nullptr}; // Current position in block buffer + uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding + + // 1-byte aligned members (grouped together to avoid internal padding) + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; + + // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) + // Placed last since std::array has 1-byte alignment + std::array channel_status_{}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_I2S_AUDIO_SPDIF_MODE diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/volume_fence.h b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/volume_fence.h new file mode 100644 index 0000000..caf5bac --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/volume_fence.h @@ -0,0 +1,74 @@ +// OpenAthan additions, 2026-10-11. GPL-3.0-only; see ../../../LICENSE. +#pragma once +#include +#include + +namespace esphome::i2s_audio { +struct VolumeFenceToken { + uint32_t session{}, generation{}; + bool operator==(const VolumeFenceToken &other) const { + return session == other.session && generation == other.generation; + } +}; +enum class VolumeFenceStatus { PENDING, COMPLETE, CANCELLED, FAILED }; +struct VolumeGain { + VolumeFenceToken token; + int32_t factor{}; +}; +struct VolumeDmaRecord { + uint32_t real_frames{}; + VolumeFenceToken token; +}; + +// Caller holds the speaker's critical section. This state allocates no memory. +// One gain snapshot covers a complete DMA descriptor, whose record travels in +// the same FIFO as its completion. No timer or logical player state completes it. +class VolumeFenceState { + public: + VolumeFenceToken request(float level, int32_t factor) { + if (failed_ || !advance_(generation_)) return {}; + cancelled_ = false; + level_ = level; + factor_ = factor; + completed_ = active_ ? 0 : generation_; + return {session_, generation_}; + } + VolumeFenceStatus status(VolumeFenceToken token) const { + if (failed_ || !token.generation) return VolumeFenceStatus::FAILED; + if (cancelled_ || !(token == snapshot().token)) return VolumeFenceStatus::CANCELLED; + return completed_ == generation_ ? VolumeFenceStatus::COMPLETE : VolumeFenceStatus::PENDING; + } + bool begin_session() { + if (failed_ || !advance_(session_)) return false; + cancelled_ = false; + active_ = true; + completed_ = 0; + return true; + } + void end_session() { + if (!active_) return; + active_ = false; + if (advance_(session_)) completed_ = generation_; + } + void complete(VolumeFenceToken token) { + if (!failed_ && token == snapshot().token) completed_ = generation_; + } + void cancel() { cancelled_ = true; } + bool active() const { return active_; } + void fail() { failed_ = true; factor_ = 0; level_ = 0; } + bool failed() const { return failed_; } + VolumeGain snapshot() const { return {{session_, generation_}, factor_}; } + float level() const { return level_; } + private: + bool advance_(uint32_t &counter) { + if (counter == std::numeric_limits::max()) { fail(); return false; } + ++counter; + return true; + } + uint32_t session_{}, generation_{}, completed_{}; + int32_t factor_{}; + float level_{}; + friend struct VolumeFenceTestAccess; + bool active_{}, failed_{}, cancelled_{}; +}; +} // namespace esphome::i2s_audio diff --git a/third_party/esphome-i2s-audio/upstream-sha256.json b/third_party/esphome-i2s-audio/upstream-sha256.json new file mode 100644 index 0000000..b692246 --- /dev/null +++ b/third_party/esphome-i2s-audio/upstream-sha256.json @@ -0,0 +1,17 @@ +{ + "i2s_audio/__init__.py": "e19cf4bc24bae17e10d931628906c78b63c039dd72f72c9828bfc963c2bb9974", + "i2s_audio/i2s_audio.h": "dc886cdf77c5a63189ad7c1b3f339e6321f6cc298dd74ef5ea1a76f09125c363", + "i2s_audio/media_player/__init__.py": "2b1a69814e18e7398ca955d5131eb6e2ae4a98c5e0ebfc586f363116b4fdae7c", + "i2s_audio/microphone/__init__.py": "6311e4b88b76c3d1bdb6ac7ac852d4113f90ad0b46d9e94499a0edb8ad4d0b20", + "i2s_audio/microphone/i2s_audio_microphone.cpp": "305e3619c6411d75080c2807270bf3a31832d0ed44196516ebe1d190dca94e5e", + "i2s_audio/microphone/i2s_audio_microphone.h": "f7922dd0f566590e70f9ff42321bbdf68c17deefd5af29d457bfa5aed526208d", + "i2s_audio/speaker/__init__.py": "f05467ee1d560dc33a77d12398b77d330c0a1f57b9d7814ea04b2330c0d58dd6", + "i2s_audio/speaker/i2s_audio_spdif.cpp": "a65a0f7a35a49ad91aee841ccf975209e932180b23206873ff8cdd557a14b249", + "i2s_audio/speaker/i2s_audio_spdif.h": "570682fbab326666c4b18be86979c357a36a40c7aa9b8e1f7d72b5e27e463e49", + "i2s_audio/speaker/i2s_audio_speaker.cpp": "bed97784e51d5454679786a35aa76416d045d05c52d4b61fcd6ef6f7d6648919", + "i2s_audio/speaker/i2s_audio_speaker.h": "1bcdba7e8cc07747887e67a34200cc31997f8e115ec9be9042d2b4ffa63acbd5", + "i2s_audio/speaker/i2s_audio_speaker_standard.cpp": "bcaa3b2f7b9436a88ea4fb1ec73778c6683ecb531e63dd5ae52b39939027515e", + "i2s_audio/speaker/i2s_audio_speaker_standard.h": "83646c7ce1acf26e79499c95d7ae467443434b0e9bdc1436c5fd4a9a35e30f12", + "i2s_audio/speaker/spdif_encoder.cpp": "7a2eface6bb48701b203340fc14377db1eb818445ed00123d316782c9854a847", + "i2s_audio/speaker/spdif_encoder.h": "509ffb6613239f4eabd087ca46897842dbdad60c298dadd974f962d6d1924a2f" +} diff --git a/third_party/esphome-i2s-audio/upstream.json b/third_party/esphome-i2s-audio/upstream.json new file mode 100644 index 0000000..33f6e94 --- /dev/null +++ b/third_party/esphome-i2s-audio/upstream.json @@ -0,0 +1,7 @@ +{ + "version": "2026.9.0", + "commit": "c6e4c87e525dd343e470d8dea368957a002f6505", + "source": "https://github.com/esphome/esphome/tree/2026.9.0/esphome/components/i2s_audio", + "license_sha256": "1f312390122725ee382f85af00e3df84f2490cd8fa61b9ea172bc6591cc0ac63", + "maintained_patch": "volume-fence.patch" +} diff --git a/third_party/esphome-i2s-audio/volume-fence.patch b/third_party/esphome-i2s-audio/volume-fence.patch new file mode 100644 index 0000000..bbdfb1b --- /dev/null +++ b/third_party/esphome-i2s-audio/volume-fence.patch @@ -0,0 +1,416 @@ +--- a/components/i2s_audio/speaker/__init__.py ++++ b/components/i2s_audio/speaker/__init__.py +@@ -0,0 +1 @@ ++# Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +@@ -47,0 +49 @@ ++CONF_VOLUME_FENCE = "volume_fence" +@@ -198,0 +201 @@ ++ cv.Optional(CONF_VOLUME_FENCE, default=False): cv.boolean, +@@ -216,0 +220,2 @@ ++ if config.get(CONF_VOLUME_FENCE) and (config.get(CONF_SPDIF_MODE) or config.get("audio_dac")): ++ raise cv.Invalid("volume_fence requires standard I2S with software gain owned by its adapter") +@@ -250,0 +256,4 @@ ++ if config.get(CONF_VOLUME_FENCE): ++ cg.add_define("USE_OPENATHAN_VOLUME_FENCE") ++ cg.add(var.set_volume_fence(True)) ++ +--- a/components/i2s_audio/speaker/i2s_audio_speaker.cpp ++++ b/components/i2s_audio/speaker/i2s_audio_speaker.cpp +@@ -0,0 +1 @@ ++// Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +@@ -36,0 +38,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) request_volume_with_fence(0.0f); ++#endif +@@ -55,0 +60,5 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ // Latch faults before cleanup clears event bits. Keep loop running to release ++ // the task, ring buffer and I2S channel; mark_failed() would skip that cleanup. ++ if (volume_fence_enabled_) volume_fence_healthy(); ++#endif +@@ -60,0 +70,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ end_volume_session_(); ++#endif +@@ -63,0 +76,7 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_ && !volume_fence_healthy()) { ++ xEventGroupClearBits(event_group_, SpeakerEventGroupBits::COMMAND_START); ++ return; ++ } ++ begin_volume_session_(); ++#endif +@@ -100,0 +120,6 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ // Driver teardown quiesces callbacks. Capture faults raised after loop's ++ // initial snapshot before clearing event bits or completing an idle fence. ++ if (volume_fence_enabled_) volume_fence_healthy(); ++ end_volume_session_(); ++#endif +@@ -123,0 +149,10 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_ && !volume_fence_healthy() && speaker_task_handle_ == nullptr) { ++ // Failed initialization/task creation already releases the parent lock. ++ // Only a still-live channel leaves this speaker owning the bus. ++ if (tx_handle_ != nullptr) stop_i2s_driver_(); ++ end_volume_session_(); ++ state_ = speaker::STATE_STOPPED; ++ break; ++ } ++#endif +@@ -134,0 +170,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ fail_volume_fence_(); ++#endif +@@ -143,0 +182,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ fail_volume_fence_(); ++#endif +@@ -155,0 +197,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) return; // Calibrated owner, including restored player preferences. ++#endif +@@ -180,0 +225,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) return; ++#endif +@@ -202,0 +250,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_ && !volume_fence_healthy()) return 0; ++#endif +@@ -248,0 +299,4 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_ && !volume_fence_healthy()) return; ++ begin_volume_session_(); ++#endif +@@ -259,0 +314,7 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) { ++ portENTER_CRITICAL(&volume_mutex_); ++ volume_fence_.cancel(); ++ portEXIT_CRITICAL(&volume_mutex_); ++ } ++#endif +@@ -296 +357,5 @@ +- this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); ++ size_t record_size = sizeof(uint32_t); ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) record_size = sizeof(VolumeDmaRecord); ++#endif ++ this->write_records_queue_ = xQueueCreate(event_queue_size, record_size); +@@ -344,0 +410,9 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ // Event-group updates from ISR are deferred to FreeRTOS's timer task. ++ // Latch under the same ISR-safe lock before any fence acknowledgement. ++ if (this_speaker->volume_fence_enabled_) { ++ portENTER_CRITICAL_ISR(&this_speaker->volume_mutex_); ++ this_speaker->volume_fence_.fail(); ++ portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); ++ } ++#endif +@@ -352 +426,8 @@ +- xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); ++ const BaseType_t sent = xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (this_speaker->volume_fence_enabled_ && sent != pdTRUE) { ++ portENTER_CRITICAL_ISR(&this_speaker->volume_mutex_); ++ this_speaker->volume_fence_.fail(); ++ portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); ++ } ++#endif +@@ -358 +439,5 @@ +- if (this->q31_volume_factor_ == INT32_MAX) { ++ apply_volume_factor_(data, bytes_read, q31_volume_factor_); ++} ++ ++void I2SAudioSpeakerBase::apply_volume_factor_(uint8_t *data, size_t bytes_read, int32_t factor) { ++ if (factor == INT32_MAX) { +@@ -365 +450 @@ +- esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); ++ esp_audio_libs::gain::apply(data, data, factor, len, bytes_per_sample); +@@ -382,0 +468,70 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++VolumeFenceToken I2SAudioSpeakerBase::request_volume_with_fence(float level) { ++ if (!volume_fence_enabled_ || !std::isfinite(level) || level < 0 || level > 1) return {}; ++ const int32_t factor = level >= 1 ? INT32_MAX : level <= 0 ? 0 : ++ esp_audio_libs::gain::db_to_q31(remap(level, 0, 1, SOFTWARE_VOLUME_MIN_DB, 0)); ++ portENTER_CRITICAL(&volume_mutex_); ++ auto token = volume_fence_.request(level, factor); ++ portEXIT_CRITICAL(&volume_mutex_); ++ return token; ++} ++ ++bool I2SAudioSpeakerBase::volume_fence_healthy() { ++ constexpr uint32_t faults = ERR_ESP_NO_MEM | ERR_DROPPED_EVENT | ERR_PARTIAL_WRITE | ERR_LOCKSTEP_DESYNC; ++ if (is_failed() || (event_group_ && (xEventGroupGetBits(event_group_) & faults))) fail_volume_fence_(); ++ portENTER_CRITICAL(&volume_mutex_); ++ bool healthy = volume_fence_enabled_ && !volume_fence_.failed(); ++ portEXIT_CRITICAL(&volume_mutex_); ++ return healthy; ++} ++ ++VolumeFenceStatus I2SAudioSpeakerBase::volume_fence_status(VolumeFenceToken token) { ++ if (!volume_fence_healthy()) return VolumeFenceStatus::FAILED; ++ if (event_group_ && (xEventGroupGetBits(event_group_) & (COMMAND_STOP | COMMAND_STOP_GRACEFULLY | ++ TASK_STOPPING | TASK_STOPPED))) ++ return VolumeFenceStatus::CANCELLED; ++ portENTER_CRITICAL(&volume_mutex_); ++ auto status = volume_fence_.status(token); ++ portEXIT_CRITICAL(&volume_mutex_); ++ return status; ++} ++ ++VolumeGain I2SAudioSpeakerBase::volume_gain_snapshot_() { ++ portENTER_CRITICAL(&volume_mutex_); ++ auto gain = volume_fence_.snapshot(); ++ portEXIT_CRITICAL(&volume_mutex_); ++ return gain; ++} ++float I2SAudioSpeakerBase::fenced_volume() { ++ portENTER_CRITICAL(&volume_mutex_); ++ float level = volume_fence_.level(); ++ portEXIT_CRITICAL(&volume_mutex_); ++ return level; ++} ++void I2SAudioSpeakerBase::complete_volume_fence_(VolumeFenceToken token) { ++ portENTER_CRITICAL(&volume_mutex_); ++ // Records from other sessions are never admissible, even if their gain agrees. ++ if (token.session != volume_fence_.snapshot().token.session) volume_fence_.fail(); ++ else volume_fence_.complete(token); ++ portEXIT_CRITICAL(&volume_mutex_); ++} ++void I2SAudioSpeakerBase::fail_volume_fence_() { ++ if (!volume_fence_enabled_) return; ++ portENTER_CRITICAL(&volume_mutex_); ++ volume_fence_.fail(); ++ portEXIT_CRITICAL(&volume_mutex_); ++} ++void I2SAudioSpeakerBase::begin_volume_session_() { ++ if (!volume_fence_enabled_) return; ++ portENTER_CRITICAL(&volume_mutex_); ++ if (!volume_fence_.active()) volume_fence_.begin_session(); ++ portEXIT_CRITICAL(&volume_mutex_); ++} ++void I2SAudioSpeakerBase::end_volume_session_() { ++ if (!volume_fence_enabled_) return; ++ portENTER_CRITICAL(&volume_mutex_); ++ volume_fence_.end_session(); ++ portEXIT_CRITICAL(&volume_mutex_); ++} ++#endif ++ +--- a/components/i2s_audio/speaker/i2s_audio_speaker.h ++++ b/components/i2s_audio/speaker/i2s_audio_speaker.h +@@ -0,0 +1 @@ ++// Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +@@ -5,0 +7,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++#include "volume_fence.h" ++#endif +@@ -91,0 +96,8 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ void set_volume_fence(bool enabled) { volume_fence_enabled_ = enabled; } ++ VolumeFenceToken request_volume_with_fence(float level); ++ VolumeFenceStatus volume_fence_status(VolumeFenceToken token); ++ bool volume_fence_healthy(); ++ float fenced_volume(); ++#endif ++ +@@ -92,0 +105,10 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ VolumeGain volume_gain_snapshot_(); ++ void complete_volume_fence_(VolumeFenceToken token); ++ void fail_volume_fence_(); ++ void begin_volume_session_(); ++ void end_volume_session_(); ++ bool volume_fence_enabled_{}; ++ portMUX_TYPE volume_mutex_ = portMUX_INITIALIZER_UNLOCKED; ++ VolumeFenceState volume_fence_; ++#endif +@@ -134,0 +157 @@ ++ void apply_volume_factor_(uint8_t *data, size_t bytes_read, int32_t factor); +--- a/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp ++++ b/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +@@ -0,0 +1 @@ ++// Modified by OpenAthan, 2026-10-11: opt-in DMA volume fence; see UPSTREAM.md. +@@ -101,0 +103,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ bool verified_dma_layout = false; ++#endif +@@ -105,0 +110,4 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ verified_dma_layout = chan_info.total_dma_buf_size % DMA_BUFFERS_COUNT == 0 && ++ dma_buffer_bytes % this->output_stream_info_.frames_to_bytes(1) == 0; ++#endif +@@ -109,0 +118,7 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_ && !verified_dma_layout) { ++ // A guessed descriptor size cannot establish FIFO completion boundaries. ++ xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC | TASK_STOPPING | TASK_STOPPED); ++ while (true) vTaskDelay(pdMS_TO_TICKS(10)); ++ } ++#endif +@@ -151 +166,6 @@ +- if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { ++ const void *record = &zero_real_frames; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ VolumeDmaRecord fenced_record{0, volume_gain_snapshot_().token}; ++ if (volume_fence_enabled_) record = &fenced_record; ++#endif ++ if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { +@@ -164,3 +184,4 @@ +- i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); +- +- if (i2s_channel_enable(this->tx_handle_) != ESP_OK) { ++ if (i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this) != ESP_OK) ++ successful_setup = false; ++ ++ if (!successful_setup || i2s_channel_enable(this->tx_handle_) != ESP_OK) { +@@ -221,0 +243,8 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ VolumeFenceToken completed_token{}; ++ if (volume_fence_enabled_ && uxQueueMessagesWaiting(i2s_event_queue_) > ++ uxQueueMessagesWaiting(write_records_queue_)) { ++ xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); ++ break; ++ } ++#endif +@@ -224 +253,6 @@ +- if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { ++ void *record = &real_frames; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ VolumeDmaRecord fenced_record{}; ++ if (volume_fence_enabled_) record = &fenced_record; ++#endif ++ if (xQueueReceive(this->write_records_queue_, record, 0) != pdTRUE) { +@@ -227,0 +262,11 @@ ++ lockstep_broken = true; ++ break; ++ } ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) { ++ real_frames = fenced_record.real_frames; ++ completed_token = fenced_record.token; ++ } ++#endif ++ if (real_frames > frames_per_dma_buffer || (real_frames && !pending_real_buffers)) { ++ xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); +@@ -245,0 +291,6 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ // Publish only after this entire completion batch has validated. A later ++ // missing record in the same batch must not briefly authorize unmute. ++ if (volume_fence_enabled_ && completed_token.generation && volume_fence_healthy()) ++ complete_volume_fence_(completed_token); ++#endif +@@ -260,0 +312,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ const VolumeGain buffer_gain = volume_gain_snapshot_(); ++#endif +@@ -268 +322,5 @@ +- if (bytes_read > 0) { ++ bool scale_on_fill = true; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ scale_on_fill = !volume_fence_enabled_; ++#endif ++ if (bytes_read > 0 && scale_on_fill) { +@@ -289,0 +348,5 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ // Leave every staged tail raw. A paused silence fence must not leave ++ // already-scaled louder samples waiting to play after resume. ++ if (volume_fence_enabled_) apply_volume_factor_(chunk, input_bytes, buffer_gain.factor); ++#endif +@@ -300,2 +363,2 @@ +- i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); +- if (bw != output_bytes) { ++ esp_err_t write_error = i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); ++ if (write_error != ESP_OK || bw != output_bytes) { +@@ -325,2 +388,2 @@ +- i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); +- if (bw != silence_bytes) { ++ esp_err_t write_error = i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); ++ if (write_error != ESP_OK || bw != silence_bytes) { +@@ -337 +400,6 @@ +- if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { ++ const void *record = &real_frames_total; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ const VolumeDmaRecord fenced_record{real_frames_total, buffer_gain.token}; ++ if (volume_fence_enabled_) record = &fenced_record; ++#endif ++ if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { +--- /dev/null ++++ b/components/i2s_audio/speaker/volume_fence.h +@@ -0,0 +1,74 @@ ++// OpenAthan additions, 2026-10-11. GPL-3.0-only; see ../../../LICENSE. ++#pragma once ++#include ++#include ++ ++namespace esphome::i2s_audio { ++struct VolumeFenceToken { ++ uint32_t session{}, generation{}; ++ bool operator==(const VolumeFenceToken &other) const { ++ return session == other.session && generation == other.generation; ++ } ++}; ++enum class VolumeFenceStatus { PENDING, COMPLETE, CANCELLED, FAILED }; ++struct VolumeGain { ++ VolumeFenceToken token; ++ int32_t factor{}; ++}; ++struct VolumeDmaRecord { ++ uint32_t real_frames{}; ++ VolumeFenceToken token; ++}; ++ ++// Caller holds the speaker's critical section. This state allocates no memory. ++// One gain snapshot covers a complete DMA descriptor, whose record travels in ++// the same FIFO as its completion. No timer or logical player state completes it. ++class VolumeFenceState { ++ public: ++ VolumeFenceToken request(float level, int32_t factor) { ++ if (failed_ || !advance_(generation_)) return {}; ++ cancelled_ = false; ++ level_ = level; ++ factor_ = factor; ++ completed_ = active_ ? 0 : generation_; ++ return {session_, generation_}; ++ } ++ VolumeFenceStatus status(VolumeFenceToken token) const { ++ if (failed_ || !token.generation) return VolumeFenceStatus::FAILED; ++ if (cancelled_ || !(token == snapshot().token)) return VolumeFenceStatus::CANCELLED; ++ return completed_ == generation_ ? VolumeFenceStatus::COMPLETE : VolumeFenceStatus::PENDING; ++ } ++ bool begin_session() { ++ if (failed_ || !advance_(session_)) return false; ++ cancelled_ = false; ++ active_ = true; ++ completed_ = 0; ++ return true; ++ } ++ void end_session() { ++ if (!active_) return; ++ active_ = false; ++ if (advance_(session_)) completed_ = generation_; ++ } ++ void complete(VolumeFenceToken token) { ++ if (!failed_ && token == snapshot().token) completed_ = generation_; ++ } ++ void cancel() { cancelled_ = true; } ++ bool active() const { return active_; } ++ void fail() { failed_ = true; factor_ = 0; level_ = 0; } ++ bool failed() const { return failed_; } ++ VolumeGain snapshot() const { return {{session_, generation_}, factor_}; } ++ float level() const { return level_; } ++ private: ++ bool advance_(uint32_t &counter) { ++ if (counter == std::numeric_limits::max()) { fail(); return false; } ++ ++counter; ++ return true; ++ } ++ uint32_t session_{}, generation_{}, completed_{}; ++ int32_t factor_{}; ++ float level_{}; ++ friend struct VolumeFenceTestAccess; ++ bool active_{}, failed_{}, cancelled_{}; ++}; ++} // namespace esphome::i2s_audio From bc74b397c011ce2d6959f9e886e2ede25c18ef24 Mon Sep 17 00:00:00 2001 From: Abubakr Eirabie Date: Sun, 11 Oct 2026 13:59:34 -0400 Subject: [PATCH 4/4] Fix DMA volume fencing and preserve Quran during gain changes --- .../atom-volume-fence-2026-10-11.md | 64 ++++++++ .../components/openathan_device/quran.cpp | 2 +- .../components/voice_pyramid_audio/README.md | 5 + .../components/voice_pyramid_audio/audio.h | 3 +- .../include/openathan/playback.h | 2 + tests/i2s_stubs/driver/i2s_std.h | 36 +++-- tests/i2s_stubs/fake_i2s.h | 14 +- .../esphome/components/json/json_util.h | 4 + .../esphome/components/openathan/openathan.h | 28 ++++ tests/pyramid_quran_worker.cpp | 13 ++ tests/pyramid_volume_tests.cpp | 138 ++++++++++++++++- third_party/esphome-i2s-audio/UPSTREAM.md | 8 +- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 25 ++++ .../i2s_audio/speaker/i2s_audio_speaker.h | 4 + .../speaker/i2s_audio_speaker_standard.cpp | 62 +++++++- .../esphome-i2s-audio/volume-fence.patch | 140 +++++++++++++++--- tools/check_quran.py | 33 +++++ 17 files changed, 540 insertions(+), 41 deletions(-) create mode 100644 tests/pyramid_quran_stubs/esphome/components/json/json_util.h create mode 100644 tests/pyramid_quran_stubs/esphome/components/openathan/openathan.h create mode 100644 tests/pyramid_quran_worker.cpp diff --git a/docs/development/atom-volume-fence-2026-10-11.md b/docs/development/atom-volume-fence-2026-10-11.md index 14277bf..dae770d 100644 --- a/docs/development/atom-volume-fence-2026-10-11.md +++ b/docs/development/atom-volume-fence-2026-10-11.md @@ -92,3 +92,67 @@ and fault handling. Check cold restart and runtime memory separately. Register verification and simulated FIFO completion do not establish physical silence, analog transients, distortion, comfortable maximum output or release readiness. Merge, release, website work and installation remain separate actions. + +## PR #44 review regression fixes — 2026-10-11 + +The review found two gaps in the earlier fence validation: independent circular +DMA completions could break FIFO ordering while the speaker task stalled, and +Quran interpreted a healthy muted gain transition as a speaker failure. +Fenced playback now stages a complete descriptor in the existing silence buffer +and submits one full-buffer write. An ISR counter tracks completed descriptors +awaiting reuse independently of drained application events; exceeding the driver's +four-entry queue capacity fails before publication. The driver overflow callback +also latches failure synchronously. Short writes, registration failures and ordering +faults cannot acknowledge gain through cleanup. + +`Playback::healthy()` defaults to `ready()` for existing adapters. The Atom adapter +keeps health true during a subsequent healthy transition, while new starts and +applied-volume readback remain gated. The production Quran controller checks this +health when deciding whether to stop an active stream; real faults still stop it. +Volume calibration, defaults, storage/migration and HTTP contracts are unchanged. + +### New automated evidence + +- All 20 UBSan CTests, 120 pinned Python tests and 13 configuration schemas passed; + the maintained patch reverses to the original upstream hashes. +- The transport models five circular DMA descriptors, a separate four-entry driver + queue, callback ordering, automatic clearing and independently advancing DMA. + Reference-sized 882-byte descriptors cover safe backlog and six/seven-completion + stalls. Lost ordering keeps both gates closed and fails without old-gain output. + Deferred ISR faults during acknowledgement, fragmented input, partial writes, + pause/restart and ordinary unfenced playback are covered. +- The actual Quran controller and Atom adapter preserve stream ownership/epoch + during zero, rapid and ordinary gain changes. Stop, disconnection, maintenance + and hardware failures still revoke playback; metadata completing during pending + gain cannot start a new stream. Production worker/HTTPS regressions also pass. +- Restoring either the old DMA implementation or Quran readiness check makes its + new regression fail. The earlier browser results above were not repeated locally + for this correction; no device-UI assets changed. + +### Measurements for the review fixes + +Before is PR head `12231c9`: retained measurements whose compiled source inputs +were verified against that commit. All ten fixed variants were rebuilt with the +same compile-only settings/audio, ESPHome 2026.9.0, ESP-IDF 5.5.5 and compiler +`esp-14.2.0_20260121`. Dependency hashes match every pair and capacity checks pass. + +| Variant | Before OTA bytes | After OTA bytes | Delta | Remaining 1.5 MiB budget | Static RAM delta | +| --- | ---: | ---: | ---: | ---: | ---: | +| Atom reference | 1,305,296 | 1,305,920 | +624 | 266,944 | +136 | +| Atom real schedule | 1,123,808 | 1,124,368 | +560 | 448,496 | +136 | +| Atom synthetic timetable | 1,141,344 | 1,141,920 | +576 | 430,944 | +136 | +| Atom provisioning validation | 1,307,040 | 1,307,632 | +592 | 265,232 | +136 | +| Atom upgrade qualification | 1,311,824 | 1,312,400 | +576 | 260,464 | +136 | +| Atom startup-failure qualification | 1,311,824 | 1,312,400 | +576 | 260,464 | +136 | +| Waveshare production | 1,309,872 | 1,309,936 | +64 | 262,928 | +0 | +| Waveshare audio | 1,238,352 | 1,238,416 | +64 | 334,448 | +0 | +| Waveshare display | 1,288,080 | 1,288,160 | +80 | 284,704 | +0 | +| Waveshare diagnostics | 1,374,928 | 1,374,992 | +64 | 197,872 | +0 | + +Reference slot headroom is 791,232 bytes. Both 2 MiB application slots and the +separate 3.5 MiB shared audio partition remain intact. Reusing the existing scratch +buffer adds no PCM buffer, audio task or queue; the backlog bookkeeping adds two +32-bit fields. The shared playback interface also changes the Waveshare control +images slightly. Static RAM and compile success do not establish runtime heap, +fragmentation, stack or PSRAM headroom. No hardware access, installation, playback, +merge or release occurred; attended acceptance above remains pending. diff --git a/firmware/esphome/components/openathan_device/quran.cpp b/firmware/esphome/components/openathan_device/quran.cpp index 83899fa..58a46bc 100644 --- a/firmware/esphome/components/openathan_device/quran.cpp +++ b/firmware/esphome/components/openathan_device/quran.cpp @@ -199,7 +199,7 @@ void Quran::loop(bool connected, bool blocked) { auto *playback = athan_ ? athan_->playback() : nullptr; if (!playback) return; if (job_ && (!connected || blocked || job_->epoch != playback->playback_epoch())) job_->cancel = true; - if ((!connected || !playback->ready()) && playback->source() == ::openathan::PlaybackSource::QURAN) { + if ((!connected || !playback->healthy()) && playback->source() == ::openathan::PlaybackSource::QURAN) { athan_->stop(); playback_error_ = !connected ? "Wi-Fi disconnected. Press Play after reconnecting." : "The speaker is unavailable. Check the device before retrying."; } if (blocked && (resolving_ || playback->source() == ::openathan::PlaybackSource::QURAN)) athan_->stop(); diff --git a/firmware/esphome/components/voice_pyramid_audio/README.md b/firmware/esphome/components/voice_pyramid_audio/README.md index 8e4548f..7b8ff73 100644 --- a/firmware/esphome/components/voice_pyramid_audio/README.md +++ b/firmware/esphome/components/voice_pyramid_audio/README.md @@ -23,3 +23,8 @@ package without selecting calibration. See the [calibration contract and dated build evidence](../../../../docs/development/atom-volume-calibration-2026-10-11.md) for the gain curve, settings compatibility, amplifier gating and outstanding physical acceptance. + +A healthy active Quran stream survives muted volume transitions. Playback health +is separate from readiness to start new audio: new starts and applied-volume +readback stay gated until the current fence completes. Real hardware or transport +faults still stop playback and keep the output gates closed. diff --git a/firmware/esphome/components/voice_pyramid_audio/audio.h b/firmware/esphome/components/voice_pyramid_audio/audio.h index 5cb9acb..4fa8f9a 100644 --- a/firmware/esphome/components/voice_pyramid_audio/audio.h +++ b/firmware/esphome/components/voice_pyramid_audio/audio.h @@ -58,7 +58,8 @@ class Audio : public Component, public ::openathan::Playback { if (dac_) codec_mute_(true); if (amplifier_) amplifier_->set_pa_enabled(false); } - bool ready() const override { return hardware_healthy_() && phase_ == Phase::IDLE && applied_.has_value(); } + bool healthy() const override { return hardware_healthy_() && applied_.has_value(); } + bool ready() const override { return healthy() && phase_ == Phase::IDLE; } bool playing() const override { return playback_ && playback_->playing(); } bool start(::openathan::Track track) override { return ready() && playback_->start(track); } bool stream_supported() const override { return playback_ && playback_->stream_supported(); } diff --git a/lib/openathan-core/include/openathan/playback.h b/lib/openathan-core/include/openathan/playback.h index efe9aba..0f064dd 100644 --- a/lib/openathan-core/include/openathan/playback.h +++ b/lib/openathan-core/include/openathan/playback.h @@ -10,6 +10,8 @@ class Playback { public: virtual ~Playback() = default; virtual bool ready() const = 0; + // Active playback health may remain true while new starts are temporarily gated. + virtual bool healthy() const { return ready(); } virtual bool playing() const = 0; // Replace current audio; true means the request was accepted, not audibly played. virtual bool start(Track track) = 0; diff --git a/tests/i2s_stubs/driver/i2s_std.h b/tests/i2s_stubs/driver/i2s_std.h index 43b3b9a..3a38d2d 100644 --- a/tests/i2s_stubs/driver/i2s_std.h +++ b/tests/i2s_stubs/driver/i2s_std.h @@ -18,26 +18,44 @@ struct i2s_std_slot_config_t {int slot_bit_width{},ws_width{},slot_mask{};bool m #define I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(...) i2s_std_slot_config_t{} struct i2s_std_config_t {i2s_std_clk_config_t clk_cfg; i2s_std_slot_config_t slot_cfg;i2s_std_gpio_config_t gpio_cfg;}; struct i2s_chan_info_t {size_t total_dma_buf_size;};struct i2s_event_data_t {}; -struct i2s_event_callbacks_t {bool(*on_sent)(i2s_chan_handle_t,i2s_event_data_t*,void*);}; +struct i2s_event_callbacks_t { + bool(*on_sent)(i2s_chan_handle_t,i2s_event_data_t*,void*){}; + bool(*on_send_q_ovf)(i2s_chan_handle_t,i2s_event_data_t*,void*){}; +}; +namespace fake_i2s { +// ESP-IDF 5.5.5 circular DMA: callbacks precede queue overflow and auto-clear. +inline void advance() { + assert(dma.size()==5); + if(on_transmit)on_transmit(next_transmit,dma[next_transmit]); + transmitted.push_back(dma[next_transmit]);now+=10000; + if(!drop_events)callback(nullptr,nullptr,context); + if(free_descriptors.size()==4) { + free_descriptors.pop_front(); + if(overflow_callback)overflow_callback(nullptr,nullptr,context); + } + std::fill(dma[next_transmit].begin(),dma[next_transmit].end(),0); + free_descriptors.push_back(next_transmit);next_transmit=(next_transmit+1)%5; +} +} inline int i2s_new_channel(const i2s_chan_config_t*,void **out,void*) {*out=fake_i2s::channel_failure?nullptr:reinterpret_cast(1);return fake_i2s::channel_failure?1:0;} inline int i2s_channel_init_std_mode(void*,const i2s_std_config_t*){return 0;} -inline int i2s_del_channel(void*){fake_i2s::dma.clear();fake_i2s::written=0;return 0;} +inline int i2s_del_channel(void*){fake_i2s::dma.clear();fake_i2s::written=0;fake_i2s::free_descriptors.clear();fake_i2s::next_transmit=0;fake_i2s::current_valid=false;return 0;} inline int i2s_channel_disable(void*){if(fake_i2s::on_disable)fake_i2s::on_disable();return 0;} inline int i2s_channel_enable(void*){return fake_i2s::enable_failure?1:0;} inline int i2s_channel_get_info(void*,i2s_chan_info_t*out){out->total_dma_buf_size=5*fake_i2s::dma_bytes+(fake_i2s::invalid_dma_layout?1:0);return 0;} inline int i2s_channel_preload_data(void*,const void*d,size_t n,size_t*out){auto p=static_cast(d);fake_i2s::dma.emplace_back(p,p+n);*out=fake_i2s::preload_partial?n-1:n;return 0;} inline int i2s_channel_register_event_callback(void*,const i2s_event_callbacks_t*c,void*ctx){ - fake_i2s::callback=c->on_sent;fake_i2s::context=ctx;return fake_i2s::callback_failure?1:0; + fake_i2s::overflow_callback=c->on_send_q_ovf;fake_i2s::callback=c->on_sent;fake_i2s::context=ctx;return fake_i2s::callback_failure?1:0; } inline int i2s_channel_write(void*,const void*d,size_t n,size_t*out,TickType_t){ using namespace fake_i2s; if(on_write)on_write(); if(partial_write){*out=n-1;return 0;} - if(!written){ - assert(dma.size()==5);transmitted.push_back(dma.front());dma.pop_front(); - now+=10000;if(!drop_events)callback(nullptr,nullptr,context); - dma.emplace_back(); + // IDF may abandon a partially filled descriptor when its queue is near full. + if(written==dma_bytes||!current_valid||free_descriptors.size()>=3) { + if(free_descriptors.empty())advance(); + current_write=free_descriptors.front();free_descriptors.pop_front();written=0;current_valid=true; } - const auto*p=static_cast(d);dma.back().insert(dma.back().end(),p,p+n);written+=n; - assert(written<=dma_bytes);if(written==dma_bytes)written=0;*out=n;return 0; + assert(n<=dma_bytes-written); + std::memcpy(dma[current_write].data()+written,d,n);written+=n;*out=n;return 0; } diff --git a/tests/i2s_stubs/fake_i2s.h b/tests/i2s_stubs/fake_i2s.h index dc55589..59c5dd4 100644 --- a/tests/i2s_stubs/fake_i2s.h +++ b/tests/i2s_stubs/fake_i2s.h @@ -19,15 +19,20 @@ inline int64_t now{}; inline bool allocation_failure{}, partial_write{}, drop_events{}, callback_failure{}, channel_failure{}, invalid_dma_layout{}; inline bool ring_allocation_failure{}, source_allocation_failure{}, task_creation_failure{}, preload_partial{}, enable_failure{}; inline std::function on_write, on_fill, on_disable; +inline std::function on_receive; inline void (*task)(void *); inline void *task_arg{}; inline std::deque> dma; inline std::vector> transmitted; -inline size_t dma_bytes = 8, written{}; +inline size_t dma_bytes = 8, written{}, next_transmit{}, current_write{}; +inline bool current_valid{}; +inline std::deque free_descriptors; +inline std::function &)> on_transmit; +inline bool (*overflow_callback)(void *, i2s_event_data_t *, void *); inline bool (*callback)(void *, i2s_event_data_t *, void *); inline void *context{}; inline void reset() { - now=0; defer_isr_bits=false; allocation_failure=partial_write=drop_events=callback_failure=channel_failure=invalid_dma_layout=false;queues.clear(); + now=0; dma_bytes=8; defer_isr_bits=false; allocation_failure=partial_write=drop_events=callback_failure=channel_failure=invalid_dma_layout=false;queues.clear(); ring_allocation_failure=source_allocation_failure=task_creation_failure=preload_partial=enable_failure=false; - on_write={}; on_fill={}; on_disable={}; task=nullptr; dma.clear(); transmitted.clear(); written=0; callback=nullptr; + on_write={}; on_fill={}; on_disable={}; on_receive={}; on_transmit={}; free_descriptors.clear(); next_transmit=current_write=0; current_valid=false; overflow_callback=nullptr; task=nullptr; dma.clear(); transmitted.clear(); written=0; callback=nullptr; } } using TickType_t = uint32_t; using BaseType_t = int; using EventBits_t = uint32_t; @@ -56,7 +61,8 @@ inline int xQueueSend(QueueHandle_t q,const void *data,TickType_t) { } inline int xQueueReceive(QueueHandle_t q,void *data,TickType_t) { if(q->entries.empty())return pdFALSE; - std::memcpy(data,q->entries.front().data(),q->width);q->entries.pop_front();return pdTRUE; + std::memcpy(data,q->entries.front().data(),q->width);q->entries.pop_front(); + if(fake_i2s::on_receive)fake_i2s::on_receive(q->width);return pdTRUE; } inline size_t uxQueueMessagesWaiting(QueueHandle_t q){return q->entries.size();} inline int xQueueIsQueueFullFromISR(QueueHandle_t q){return q->entries.size()==q->capacity;} diff --git a/tests/pyramid_quran_stubs/esphome/components/json/json_util.h b/tests/pyramid_quran_stubs/esphome/components/json/json_util.h new file mode 100644 index 0000000..9f75bed --- /dev/null +++ b/tests/pyramid_quran_stubs/esphome/components/json/json_util.h @@ -0,0 +1,4 @@ +#pragma once +#define ARDUINOJSON_ENABLE_STD_STRING 1 +#define ARDUINOJSON_USE_LONG_LONG 1 +#include diff --git a/tests/pyramid_quran_stubs/esphome/components/openathan/openathan.h b/tests/pyramid_quran_stubs/esphome/components/openathan/openathan.h new file mode 100644 index 0000000..4ea1e06 --- /dev/null +++ b/tests/pyramid_quran_stubs/esphome/components/openathan/openathan.h @@ -0,0 +1,28 @@ +#pragma once +#include "esphome/components/json/json_util.h" +#include "../../../../../firmware/esphome/components/openathan/storage_config.h" +#include +#include "openathan/scheduler.h" +namespace esphome::openathan_component { +struct Settings { bool healthy() const { return true; } }; +struct Clock { bool valid{true}; int64_t utc{1000}; }; +struct Next { int64_t utc{9000}; }; +struct Status { bool playing{}, automatic_ready{true}; std::optional next{Next{}}; ::openathan::Fault fault{::openathan::Fault::NONE}; }; +class OpenAthan { + public: + bool health{true}, active{true}, ready{true}, failed{}; + Clock clock; + Status sample; + Settings settings; + ::openathan::Playback *audio{}; + ::openathan::Playback *playback() const { return audio; } + void stop() { if (audio) audio->stop(); } + Status status() const {return sample;} + Clock read() const {return clock;} + Settings *settings_service(){return &settings;} + bool activated()const{return active;} + bool is_failed()const{return failed;} + bool upgrade_health()const{return health;} + const char *setup_state()const{return "active";} +}; +} diff --git a/tests/pyramid_quran_worker.cpp b/tests/pyramid_quran_worker.cpp new file mode 100644 index 0000000..970e759 --- /dev/null +++ b/tests/pyramid_quran_worker.cpp @@ -0,0 +1,13 @@ +// Metadata-worker controls compiled with the production Quran host environment. +#include +#include +#include + +void prepare_volume_quran_metadata() { + http_replies["https://www.mp3quran.net/api/v3/reciters?language=eng&reciter=1"] = + {200, "", R"({"reciters":[{"id":1,"name":"Example","moshaf":[{"id":9,"name":"Edition","server":"https://cdn.mp3quran.net/example/","surah_list":"18"}]}]})"}; +} +void finish_volume_quran_metadata() { + run_worker(); + assert(live_http_clients == 0); +} diff --git a/tests/pyramid_volume_tests.cpp b/tests/pyramid_volume_tests.cpp index 7eb9c8e..11fc109 100644 --- a/tests/pyramid_volume_tests.cpp +++ b/tests/pyramid_volume_tests.cpp @@ -245,7 +245,7 @@ static void dma_fence() { f.audio.loop(); if(!f.audio.ready())assert(!f.enabled()&&f.dac.mute==0x60); else completed=true; - if(!fake_i2s::dma.empty()) {int16_t sample{};std::memcpy(&sample,fake_i2s::dma.front().data(),2); + if(!fake_i2s::dma.empty()) {int16_t sample{};std::memcpy(&sample,fake_i2s::dma[fake_i2s::next_transmit].data(),2); if(sample==20000){old_tail=true;assert(!f.enabled());}} } if(buffers==24)f.speaker.stop(); @@ -351,5 +351,137 @@ static void dma_faults_and_deadline() { i2s_audio::VolumeFenceTestAccess::exhaust(state);assert(!state.request(1,INT32_MAX).generation&&state.failed()); i2s_audio::VolumeFenceState session;i2s_audio::VolumeFenceTestAccess::exhaust_session(session);assert(!session.begin_session()&&session.failed()); } -int main() { curves(); storage(); interrupted_saves(); isolated_storage();amplifier_control(); - adapter_idle_and_faults();streaming_handoff();dma_fence();rapid_pause_stop_restart();superseded_deadline();dma_faults_and_deadline(); } +static void independent_dma_stalls() { + for (unsigned completions : {0U, 3U, 4U, 6U, 7U}) { + Fixture f;fake_i2s::dma_bytes=882;f.apply(100);unsigned writes=0, fills=0;bool fault=false; + fake_i2s::defer_isr_bits=true; + const int16_t pcm[4]={20000,20000,20000,20000}; + fake_i2s::on_transmit=[&](size_t,const auto &buffer){ + f.audio.loop(); + if(f.audio.volume_percent()==50 && f.enabled()) { + for(size_t i=0;i(pcm),sizeof(pcm)); + if(++fills==1) { + assert(f.audio.request_volume_percent(50));f.audio.loop(); + for(unsigned i=0;i4) { + assert(fault);f.audio.loop();assert(f.audio.is_failed()&&!f.enabled()&&f.dac.mute==0x60); + } + } + }; + fake_i2s::on_write=[&]{f.speaker.loop();f.audio.loop();if(++writes==20)f.speaker.stop();}; + f.run();f.audio.loop(); + if(completions>4)assert(fault&&f.audio.is_failed()&&!f.enabled()); + else assert(!fault&&!f.audio.is_failed()&&f.audio.volume_percent()==50); + } + // A concurrent driver fault while the task drains acknowledgements cannot + // authorize unmute, even when ISR event-group delivery is deferred. + { + Fixture f;f.apply(100);unsigned writes=0;bool injected=false; + fake_i2s::on_write=[&]{ + f.speaker.loop(); + if(++writes==8){assert(f.audio.request_volume_percent(20));f.audio.loop();} + f.audio.loop(); + }; + fake_i2s::on_receive=[&](size_t width){ + if(writes>=9&&!injected&&width==sizeof(i2s_audio::VolumeDmaRecord)) { + injected=true;fake_i2s::defer_isr_bits=true; + fake_i2s::overflow_callback(nullptr,nullptr,fake_i2s::context); + f.audio.loop();assert(!f.enabled()&&f.dac.mute==0x60); + } + }; + f.run();f.audio.loop();assert(injected&&f.audio.is_failed()&&!f.enabled()); + } + // A dropped driver descriptor must latch failure even without deferred event bits. + Fixture f;f.apply(100);f.speaker.start();f.speaker.loop(); + fake_i2s::on_write=[&]{ + assert(fake_i2s::overflow_callback);fake_i2s::defer_isr_bits=true; + fake_i2s::overflow_callback(nullptr,nullptr,fake_i2s::context); + assert(!f.speaker.volume_fence_healthy());f.audio.loop();assert(!f.enabled()); + }; + try{fake_i2s::task(fake_i2s::task_arg);}catch(const fake_i2s::Exit&){} + f.speaker.loop();f.audio.loop();assert(f.audio.is_failed()); +} +static void unfenced_transport_control() { + Fixture f;f.speaker.set_volume_fence(false);unsigned writes=0; + const int16_t pcm[4]={20000,20000,20000,20000}; + fake_i2s::on_fill=[&]{f.speaker.loop();f.speaker.play(reinterpret_cast(pcm),sizeof(pcm));}; + fake_i2s::on_write=[&]{f.speaker.loop();if(++writes==12)f.speaker.stop();}; + f.run();assert(!f.speaker.is_failed()&&!fake_i2s::overflow_callback); + bool heard=false; + for(const auto &buffer:fake_i2s::transmitted)for(size_t i=0;i());assert(state["state"]=="playing"); + if(writes==24)f.speaker.stop(); + }; + f.run();assert(pending_seen&&f.audio.volume_percent()==target&&f.playback.starts==1); + if(!target)assert(!f.enabled()&&f.dac.mute==0x60); + // Disconnection, maintenance and genuine hardware faults still revoke playback. + if(target==0)service.loop(false,false); + else if(target==20)service.loop(true,true); + else {f.playback.available=false;service.loop(true,false);} + assert(f.audio.source()==::openathan::PlaybackSource::NONE); + } + Fixture f;f.apply(64);assert(f.audio.start_stream("https://cdn.mp3quran.net/example/018.mp3")); + openathan_component::OpenAthan athan;athan.audio=&f.audio; + openathan_device::Quran service;service.begin(&athan,"stop-test");service.loop(true,false); + unsigned writes=0; + fake_i2s::on_write=[&]{f.speaker.loop(); + if(++writes==8){assert(f.audio.request_volume_percent(20));f.audio.loop();f.audio.stop();} + f.audio.loop();service.loop(true,false); + }; + f.run();f.audio.loop();f.audio.loop();assert(!f.audio.playing()&&f.playback.starts==1&&f.audio.volume_percent()==20); + // A metadata job completing while a gain change is pending cannot start audio. + { + Fixture f;f.apply(64); + openathan_component::OpenAthan athan;athan.audio=&f.audio; + openathan_device::Quran service;service.begin(&athan,"pending-job");service.loop(true,false); + prepare_volume_quran_metadata();JsonDocument selection,state; + selection["reciter"]=1;selection["edition"]=9;selection["surah"]=18; + service.snapshot(state.to());selection["expected_playback_token"]=state["playback_token"]; + std::string error;assert(service.action("play",selection.as(),error)==200); + f.speaker.loop(); // Process Play's Stop before starting the transport fixture. + unsigned writes=0;bool rejected=false; + fake_i2s::on_write=[&]{f.speaker.loop(); + if(++writes==8){assert(f.audio.request_volume_percent(20));f.audio.loop();assert(!f.audio.ready()); + finish_volume_quran_metadata();service.loop(true,false); + service.snapshot(state.to());assert(state["state"]=="error"); + assert(state["error"]=="The speaker could not start Quran playback"); + assert(f.playback.starts==0);rejected=true;f.speaker.stop();} + }; + f.run();assert(rejected&&f.playback.starts==0); + } + +} +#endif +int main() { +#ifdef OPENATHAN_QURAN_VOLUME_INTEGRATION + quran_volume_continuity(); +#endif + curves(); storage(); interrupted_saves(); isolated_storage();amplifier_control(); + adapter_idle_and_faults();streaming_handoff();dma_fence();rapid_pause_stop_restart();superseded_deadline();dma_faults_and_deadline();independent_dma_stalls();unfenced_transport_control(); } diff --git a/third_party/esphome-i2s-audio/UPSTREAM.md b/third_party/esphome-i2s-audio/UPSTREAM.md index d49cc04..d2a791b 100644 --- a/third_party/esphome-i2s-audio/UPSTREAM.md +++ b/third_party/esphome-i2s-audio/UPSTREAM.md @@ -25,10 +25,16 @@ and only a fully cleaned-up stopped speaker can acknowledge without DMA. Each DMA descriptor uses one synchronized gain/generation snapshot, with gain applied to consumed raw PCM before bit-depth narrowing. The existing FIFO record carries its session/generation, including preloaded and padded silence. A complete -matching descriptor confirms that older FIFO buffers drained. Completion batches +matching descriptor confirms that older FIFO buffers drained. Fenced playback +composes one complete descriptor in the existing silence scratch buffer and +submits it in one write, preventing partial-buffer abandonment under driver backlog. Completion batches are validated before publishing an acknowledgement. Allocation, callback, DMA layout, write, overflow and queue faults latch failure through task cleanup; logical readback and elapsed time cannot complete a fence. +An ISR counter tracks completed descriptors awaiting a committed buffer write, +independently of drained application events. Exceeding the driver's N-1 queue +capacity fails before event publication; the driver overflow callback also fails +closed. This covers ESP-IDF 5.5.5 invoking on_sent before overflow handling. ISR queue failures latch under the same ESP-IDF critical-section lock immediately, without waiting for deferred event-group updates. Driver teardown rechecks faults before clearing events or allowing a stopped speaker to complete a fence. diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp index af08c3c..d0cebe2 100644 --- a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -401,6 +401,18 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (this_speaker->volume_fence_enabled_) { + portENTER_CRITICAL_ISR(&this_speaker->volume_mutex_); + // Check before publishing the event: IDF invokes on_sent before its own + // queue-overflow callback, and another core can consume this event at once. + if (this_speaker->volume_completed_descriptors_ >= this_speaker->volume_completion_capacity_) + this_speaker->volume_fence_.fail(); + else + ++this_speaker->volume_completed_descriptors_; + portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); + } +#endif if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` // and any per-buffer record queue maintained by the task are permanently desynced, so the task @@ -435,6 +447,19 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } +#ifdef USE_OPENATHAN_VOLUME_FENCE +bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_send_q_ovf_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { + auto *speaker = static_cast(user_ctx); + if (!speaker->volume_fence_enabled_) return false; + portENTER_CRITICAL_ISR(&speaker->volume_mutex_); + speaker->volume_fence_.fail(); + portEXIT_CRITICAL_ISR(&speaker->volume_mutex_); + BaseType_t need_yield = pdFALSE; + xEventGroupSetBitsFromISR(speaker->event_group_, ERR_LOCKSTEP_DESYNC | COMMAND_STOP, &need_yield); + return need_yield; +} +#endif + void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { apply_volume_factor_(data, bytes_read, q31_volume_factor_); } diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h index 1a84714..3afb1cf 100644 --- a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -108,6 +108,10 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void fail_volume_fence_(); void begin_volume_session_(); void end_volume_session_(); + // ISR completions not yet reclaimed by a committed whole-descriptor write. + uint32_t volume_completed_descriptors_{}; + uint32_t volume_completion_capacity_{}; + static bool i2s_on_send_q_ovf_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); bool volume_fence_enabled_{}; portMUX_TYPE volume_mutex_ = portMUX_INITIALIZER_UNLOCKED; VolumeFenceState volume_fence_; diff --git a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 77dcdbb..93c21cf 100644 --- a/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -180,7 +180,17 @@ void I2SAudioSpeaker::run_speaker_task() { if (successful_setup) { // Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer // generates a queued event that pairs with the first preloaded silence record. - const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) { + // Full preload leaves IDF's descriptor queue empty. Its capacity is N-1. + portENTER_CRITICAL(&volume_mutex_); + volume_completed_descriptors_ = 0; + volume_completion_capacity_ = DMA_BUFFERS_COUNT - 1; + portEXIT_CRITICAL(&volume_mutex_); + callbacks.on_send_q_ovf = i2s_on_send_q_ovf_cb; + } +#endif if (i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this) != ESP_OK) successful_setup = false; @@ -191,6 +201,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (!successful_setup) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) fail_volume_fence_(); +#endif xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); } else { bool stop_gracefully = false; @@ -215,6 +228,9 @@ void I2SAudioSpeaker::run_speaker_task() { // so the lockstep invariant is preserved without burning CPU. while (this->pause_state_ || !this->timeout_.has_value() || (millis() - last_data_received_time) <= this->timeout_.value()) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_ && !volume_fence_healthy()) break; +#endif uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { @@ -244,6 +260,7 @@ void I2SAudioSpeaker::run_speaker_task() { VolumeFenceToken completed_token{}; if (volume_fence_enabled_ && uxQueueMessagesWaiting(i2s_event_queue_) > uxQueueMessagesWaiting(write_records_queue_)) { + fail_volume_fence_(); xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); break; } @@ -285,6 +302,9 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) fail_volume_fence_(); +#endif break; } @@ -311,6 +331,8 @@ void I2SAudioSpeaker::run_speaker_task() { // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. #ifdef USE_OPENATHAN_VOLUME_FENCE const VolumeGain buffer_gain = volume_gain_snapshot_(); + // Reuse the existing silence buffer as a complete descriptor staging area. + if (volume_fence_enabled_) memset(silence_buffer, 0, dma_buffer_bytes); #endif size_t bytes_written_total = 0; uint32_t real_frames_total = 0; @@ -360,7 +382,14 @@ void I2SAudioSpeaker::run_speaker_task() { this->swap_esp32_mono_samples_(chunk, output_bytes); size_t bw = 0; - esp_err_t write_error = i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + esp_err_t write_error = ESP_OK; +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) { + memcpy(silence_buffer + bytes_written_total, chunk, output_bytes); + bw = output_bytes; + } else +#endif + write_error = i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (write_error != ESP_OK || bw != output_bytes) { // A short real-audio write breaks DMA descriptor alignment for every subsequent event; // the only safe recovery is to restart the task. @@ -382,8 +411,24 @@ void I2SAudioSpeaker::run_speaker_task() { break; } +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) { + size_t bw = 0; + if (!volume_fence_healthy() || + i2s_channel_write(tx_handle_, silence_buffer, dma_buffer_bytes, &bw, WRITE_TIMEOUT_TICKS) != ESP_OK || + bw != dma_buffer_bytes) { + fail_volume_fence_(); + xEventGroupSetBits(event_group_, ERR_PARTIAL_WRITE); + break; + } + } +#endif const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; - if (silence_bytes > 0) { + bool write_silence = silence_bytes > 0; +#ifdef USE_OPENATHAN_VOLUME_FENCE + write_silence = write_silence && !volume_fence_enabled_; +#endif + if (write_silence) { size_t bw = 0; esp_err_t write_error = i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (write_error != ESP_OK || bw != silence_bytes) { @@ -403,10 +448,21 @@ void I2SAudioSpeaker::run_speaker_task() { if (volume_fence_enabled_) record = &fenced_record; #endif if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) fail_volume_fence_(); +#endif ESP_LOGV(TAG, "Exiting: write records queue full"); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); break; } +#ifdef USE_OPENATHAN_VOLUME_FENCE + if (volume_fence_enabled_) { + portENTER_CRITICAL(&volume_mutex_); + if (!volume_completed_descriptors_) volume_fence_.fail(); + else --volume_completed_descriptors_; + portEXIT_CRITICAL(&volume_mutex_); + } +#endif if (real_frames_total > 0) { pending_real_buffers++; } diff --git a/third_party/esphome-i2s-audio/volume-fence.patch b/third_party/esphome-i2s-audio/volume-fence.patch index bbdfb1b..8c2955f 100644 --- a/third_party/esphome-i2s-audio/volume-fence.patch +++ b/third_party/esphome-i2s-audio/volume-fence.patch @@ -98,7 +98,20 @@ + if (volume_fence_enabled_) record_size = sizeof(VolumeDmaRecord); +#endif + this->write_records_queue_ = xQueueCreate(event_queue_size, record_size); -@@ -344,0 +410,9 @@ +@@ -338,0 +404,12 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (this_speaker->volume_fence_enabled_) { ++ portENTER_CRITICAL_ISR(&this_speaker->volume_mutex_); ++ // Check before publishing the event: IDF invokes on_sent before its own ++ // queue-overflow callback, and another core can consume this event at once. ++ if (this_speaker->volume_completed_descriptors_ >= this_speaker->volume_completion_capacity_) ++ this_speaker->volume_fence_.fail(); ++ else ++ ++this_speaker->volume_completed_descriptors_; ++ portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); ++ } ++#endif +@@ -344,0 +422,9 @@ +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Event-group updates from ISR are deferred to FreeRTOS's timer task. + // Latch under the same ISR-safe lock before any fence acknowledgement. @@ -108,7 +121,7 @@ + portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); + } +#endif -@@ -352 +426,8 @@ +@@ -352 +438,8 @@ - xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); + const BaseType_t sent = xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); +#ifdef USE_OPENATHAN_VOLUME_FENCE @@ -118,17 +131,31 @@ + portEXIT_CRITICAL_ISR(&this_speaker->volume_mutex_); + } +#endif -@@ -358 +439,5 @@ +@@ -356,0 +450,13 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_send_q_ovf_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { ++ auto *speaker = static_cast(user_ctx); ++ if (!speaker->volume_fence_enabled_) return false; ++ portENTER_CRITICAL_ISR(&speaker->volume_mutex_); ++ speaker->volume_fence_.fail(); ++ portEXIT_CRITICAL_ISR(&speaker->volume_mutex_); ++ BaseType_t need_yield = pdFALSE; ++ xEventGroupSetBitsFromISR(speaker->event_group_, ERR_LOCKSTEP_DESYNC | COMMAND_STOP, &need_yield); ++ return need_yield; ++} ++#endif ++ +@@ -358 +464,5 @@ - if (this->q31_volume_factor_ == INT32_MAX) { + apply_volume_factor_(data, bytes_read, q31_volume_factor_); +} + +void I2SAudioSpeakerBase::apply_volume_factor_(uint8_t *data, size_t bytes_read, int32_t factor) { + if (factor == INT32_MAX) { -@@ -365 +450 @@ +@@ -365 +475 @@ - esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); + esp_audio_libs::gain::apply(data, data, factor, len, bytes_per_sample); -@@ -382,0 +468,70 @@ +@@ -382,0 +493,70 @@ +#ifdef USE_OPENATHAN_VOLUME_FENCE +VolumeFenceToken I2SAudioSpeakerBase::request_volume_with_fence(float level) { + if (!volume_fence_enabled_ || !std::isfinite(level) || level < 0 || level > 1) return {}; @@ -216,18 +243,22 @@ + float fenced_volume(); +#endif + -@@ -92,0 +105,10 @@ +@@ -92,0 +105,14 @@ +#ifdef USE_OPENATHAN_VOLUME_FENCE + VolumeGain volume_gain_snapshot_(); + void complete_volume_fence_(VolumeFenceToken token); + void fail_volume_fence_(); + void begin_volume_session_(); + void end_volume_session_(); ++ // ISR completions not yet reclaimed by a committed whole-descriptor write. ++ uint32_t volume_completed_descriptors_{}; ++ uint32_t volume_completion_capacity_{}; ++ static bool i2s_on_send_q_ovf_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + bool volume_fence_enabled_{}; + portMUX_TYPE volume_mutex_ = portMUX_INITIALIZER_UNLOCKED; + VolumeFenceState volume_fence_; +#endif -@@ -134,0 +157 @@ +@@ -134,0 +161 @@ + void apply_volume_factor_(uint8_t *data, size_t bytes_read, int32_t factor); --- a/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -258,24 +289,45 @@ + if (volume_fence_enabled_) record = &fenced_record; +#endif + if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { -@@ -164,3 +184,4 @@ +@@ -163,4 +183,15 @@ +- const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; - i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - - if (i2s_channel_enable(this->tx_handle_) != ESP_OK) { ++ i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) { ++ // Full preload leaves IDF's descriptor queue empty. Its capacity is N-1. ++ portENTER_CRITICAL(&volume_mutex_); ++ volume_completed_descriptors_ = 0; ++ volume_completion_capacity_ = DMA_BUFFERS_COUNT - 1; ++ portEXIT_CRITICAL(&volume_mutex_); ++ callbacks.on_send_q_ovf = i2s_on_send_q_ovf_cb; ++ } ++#endif + if (i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this) != ESP_OK) + successful_setup = false; + + if (!successful_setup || i2s_channel_enable(this->tx_handle_) != ESP_OK) { -@@ -221,0 +243,8 @@ +@@ -172,0 +204,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) fail_volume_fence_(); ++#endif +@@ -196,0 +231,3 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_ && !volume_fence_healthy()) break; ++#endif +@@ -221,0 +259,9 @@ +#ifdef USE_OPENATHAN_VOLUME_FENCE + VolumeFenceToken completed_token{}; + if (volume_fence_enabled_ && uxQueueMessagesWaiting(i2s_event_queue_) > + uxQueueMessagesWaiting(write_records_queue_)) { ++ fail_volume_fence_(); + xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); + break; + } +#endif -@@ -224 +253,6 @@ +@@ -224 +270,6 @@ - if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { + void *record = &real_frames; +#ifdef USE_OPENATHAN_VOLUME_FENCE @@ -283,7 +335,7 @@ + if (volume_fence_enabled_) record = &fenced_record; +#endif + if (xQueueReceive(this->write_records_queue_, record, 0) != pdTRUE) { -@@ -227,0 +262,11 @@ +@@ -227,0 +279,11 @@ + lockstep_broken = true; + break; + } @@ -295,41 +347,79 @@ +#endif + if (real_frames > frames_per_dma_buffer || (real_frames && !pending_real_buffers)) { + xEventGroupSetBits(event_group_, ERR_LOCKSTEP_DESYNC); -@@ -245,0 +291,6 @@ +@@ -243,3 +305,12 @@ +- break; +- } +- ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) fail_volume_fence_(); ++#endif ++ break; ++ } ++ +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Publish only after this entire completion batch has validated. A later + // missing record in the same batch must not briefly authorize unmute. + if (volume_fence_enabled_ && completed_token.generation && volume_fence_healthy()) + complete_volume_fence_(completed_token); +#endif -@@ -260,0 +312,3 @@ +@@ -260,0 +332,5 @@ +#ifdef USE_OPENATHAN_VOLUME_FENCE + const VolumeGain buffer_gain = volume_gain_snapshot_(); ++ // Reuse the existing silence buffer as a complete descriptor staging area. ++ if (volume_fence_enabled_) memset(silence_buffer, 0, dma_buffer_bytes); +#endif -@@ -268 +322,5 @@ +@@ -268 +344,5 @@ - if (bytes_read > 0) { + bool scale_on_fill = true; +#ifdef USE_OPENATHAN_VOLUME_FENCE + scale_on_fill = !volume_fence_enabled_; +#endif + if (bytes_read > 0 && scale_on_fill) { -@@ -289,0 +348,5 @@ +@@ -289,0 +370,5 @@ +#ifdef USE_OPENATHAN_VOLUME_FENCE + // Leave every staged tail raw. A paused silence fence must not leave + // already-scaled louder samples waiting to play after resume. + if (volume_fence_enabled_) apply_volume_factor_(chunk, input_bytes, buffer_gain.factor); +#endif -@@ -300,2 +363,2 @@ +@@ -300,2 +385,9 @@ - i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); - if (bw != output_bytes) { -+ esp_err_t write_error = i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); ++ esp_err_t write_error = ESP_OK; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) { ++ memcpy(silence_buffer + bytes_written_total, chunk, output_bytes); ++ bw = output_bytes; ++ } else ++#endif ++ write_error = i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (write_error != ESP_OK || bw != output_bytes) { -@@ -325,2 +388,2 @@ +@@ -321,0 +414,12 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) { ++ size_t bw = 0; ++ if (!volume_fence_healthy() || ++ i2s_channel_write(tx_handle_, silence_buffer, dma_buffer_bytes, &bw, WRITE_TIMEOUT_TICKS) != ESP_OK || ++ bw != dma_buffer_bytes) { ++ fail_volume_fence_(); ++ xEventGroupSetBits(event_group_, ERR_PARTIAL_WRITE); ++ break; ++ } ++ } ++#endif +@@ -323 +427,5 @@ +- if (silence_bytes > 0) { ++ bool write_silence = silence_bytes > 0; ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ write_silence = write_silence && !volume_fence_enabled_; ++#endif ++ if (write_silence) { +@@ -325,2 +433,2 @@ - i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); - if (bw != silence_bytes) { + esp_err_t write_error = i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (write_error != ESP_OK || bw != silence_bytes) { -@@ -337 +400,6 @@ +@@ -337 +445,9 @@ - if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { + const void *record = &real_frames_total; +#ifdef USE_OPENATHAN_VOLUME_FENCE @@ -337,6 +427,18 @@ + if (volume_fence_enabled_) record = &fenced_record; +#endif + if (xQueueSend(this->write_records_queue_, record, 0) != pdTRUE) { ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) fail_volume_fence_(); ++#endif +@@ -341,0 +458,8 @@ ++#ifdef USE_OPENATHAN_VOLUME_FENCE ++ if (volume_fence_enabled_) { ++ portENTER_CRITICAL(&volume_mutex_); ++ if (!volume_completed_descriptors_) volume_fence_.fail(); ++ else --volume_completed_descriptors_; ++ portEXIT_CRITICAL(&volume_mutex_); ++ } ++#endif --- /dev/null +++ b/components/i2s_audio/speaker/volume_fence.h @@ -0,0 +1,74 @@ diff --git a/tools/check_quran.py b/tools/check_quran.py index 3d15367..ded2142 100644 --- a/tools/check_quran.py +++ b/tools/check_quran.py @@ -20,6 +20,39 @@ def check(arduinojson): str(ROOT / "firmware/esphome/components/openathan_device/quran.cpp"), "-o", str(binary)], check=True) subprocess.run([str(binary)], check=True) + # Separate translation units retain each production adapter's host environment. + worker = Path(directory) / "quran.o" + common = [os.environ.get("CXX", "c++"), "-std=c++20", "-fsanitize=undefined", + "-fno-sanitize-recover=all", "-I" + str(ROOT / "tests/pyramid_quran_stubs"), + "-I" + str(ROOT / "tests/quran_stubs"), "-I" + arduinojson, + "-I" + str(ROOT / "lib/openathan-core/include"), + "-I" + str(ROOT / "firmware/esphome/components/openathan_device")] + subprocess.run(common + ["-I" + str(ROOT / "tests/upgrade_stubs"), + "-I" + str(ROOT / "tests/stubs"), "-c", + str(ROOT / "firmware/esphome/components/openathan_device/quran.cpp"), + "-o", str(worker)], check=True) + worker_controls = Path(directory) / "worker-controls.o" + subprocess.run(common + ["-I" + str(ROOT / "tests/upgrade_stubs"), + "-I" + str(ROOT / "tests/stubs"), "-c", + str(ROOT / "tests/pyramid_quran_worker.cpp"), "-o", str(worker_controls)], check=True) + speaker = ROOT / "third_party/esphome-i2s-audio/components/i2s_audio/speaker" + integration = Path(directory) / "quran-volume-tests" + subprocess.run(common + ["-DUSE_ESP32", "-DUSE_OPENATHAN_VOLUME_FENCE", + "-DOPENATHAN_QURAN_VOLUME_INTEGRATION", "-I" + str(ROOT / "tests/i2s_stubs"), + "-I" + str(ROOT / "tests/pyramid_stubs"), "-I" + str(ROOT / "tests/waveshare_stubs"), + "-I" + str(ROOT / "tests/stubs"), + "-I" + str(ROOT / "third_party/esphome-i2s-audio"), + "-I" + str(ROOT / "firmware"), + "-I" + str(speaker), "-I" + str(ROOT / "third_party/adhan-cpp/include"), + str(ROOT / "tests/pyramid_volume_tests.cpp"), + str(speaker / "i2s_audio_speaker.cpp"), str(speaker / "i2s_audio_speaker_standard.cpp"), + str(ROOT / "firmware/esphome/components/voice_pyramid/voice_pyramid.cpp"), + str(ROOT / "firmware/esphome/components/openathan/nvs_settings_store.cpp"), + *[str(p) for p in sorted((ROOT / "lib/openathan-core/src").glob("*.cpp"))], + *[str(p) for p in sorted((ROOT / "third_party/adhan-cpp/src").glob("*.cpp"))], + str(worker), str(worker_controls), "-o", str(integration)], check=True) + subprocess.run([str(integration)], check=True) + print("Atom volume/Quran continuity, pending admission, zero, rapid changes, Stop and faults passed") if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__)