diff --git a/CMakeLists.txt b/CMakeLists.txt index 859ba91..f34ee70 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,23 @@ 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 + 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/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) + 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/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/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..45cabc1 --- /dev/null +++ b/docs/development/atom-volume-calibration-2026-10-11.md @@ -0,0 +1,172 @@ +# 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 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 +[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. + +## 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 +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 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. + +## 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 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 +runtime memory separately. This build evidence does not qualify a release or +establish the Pyramid's usable maximum loudness. 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..dae770d --- /dev/null +++ b/docs/development/atom-volume-fence-2026-10-11.md @@ -0,0 +1,158 @@ +# 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. + +## 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/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_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/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..86b3344 100644 --- a/firmware/esphome/components/voice_pyramid/README.md +++ b/firmware/esphome/components/voice_pyramid/README.md @@ -5,10 +5,20 @@ 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 +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 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 b60080b..f1eedc8 100644 --- a/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp +++ b/firmware/esphome/components/voice_pyramid/voice_pyramid.cpp @@ -31,10 +31,23 @@ 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; } + 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 5a23db8..87698b7 100644 --- a/firmware/esphome/components/voice_pyramid/voice_pyramid.h +++ b/firmware/esphome/components/voice_pyramid/voice_pyramid.h @@ -11,6 +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 new file mode 100644 index 0000000..7b8ff73 --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/README.md @@ -0,0 +1,30 @@ +# 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 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. + +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/__init__.py b/firmware/esphome/components/voice_pyramid_audio/__init__.py new file mode 100644 index 0000000..32bc6c3 --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/__init__.py @@ -0,0 +1,52 @@ +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", "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") +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("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), + cv.Optional("validation_id"): cv.use_id(Validation), +}).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", "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())) + # 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..4fa8f9a --- /dev/null +++ b/firmware/esphome/components/voice_pyramid_audio/audio.h @@ -0,0 +1,124 @@ +#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/i2s_audio/speaker/i2s_audio_speaker.h" +#include + +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_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 loop() override { + 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; + } + if (!pending_) return; + if (!deadline_) deadline_ = esp_timer_get_time() + 2000000; + if (esp_timer_get_time() >= *deadline_) { fail_(); return; } + if (phase_ == Phase::MUTING) { + uint8_t gain{}, drc{}; + 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) { 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); + } + 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(); } + 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 { + 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; + 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, 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() && speaker_ && speaker_->volume_fence_healthy(); + } + openathan_audio::PartitionAudio *playback_{}; + es8311::ES8311 *dac_{}; + 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/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..5d5780d --- /dev/null +++ b/firmware/esphome/packages/hardware/voice-pyramid-volume.yaml @@ -0,0 +1,37 @@ +# 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] + - source: + type: local + path: ../../../../third_party/esphome-i2s-audio/components + components: [i2s_audio] + +voice_pyramid: + amplifier: + enable_on_startup: false + +speaker: + - id: !extend pyramid_speaker + audio_dac: !remove + volume_fence: true + +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 + 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 77085b6..b8fad0d 100644 --- a/firmware/esphome/packages/hardware/voice-pyramid.yaml +++ b/firmware/esphome/packages/hardware/voice-pyramid.yaml @@ -7,27 +7,13 @@ 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 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/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/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/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..3a38d2d --- /dev/null +++ b/tests/i2s_stubs/driver/i2s_std.h @@ -0,0 +1,61 @@ +#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*){}; + 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;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::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;} + // 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; + } + 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/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..59c5dd4 --- /dev/null +++ b/tests/i2s_stubs/fake_i2s.h @@ -0,0 +1,76 @@ +#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 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{}, 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; 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={}; 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; +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(); + 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;} +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 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_stubs/esphome/components/i2c/i2c.h b/tests/pyramid_stubs/esphome/components/i2c/i2c.h new file mode 100644 index 0000000..a1c02e1 --- /dev/null +++ b/tests/pyramid_stubs/esphome/components/i2c/i2c.h @@ -0,0 +1,32 @@ +#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{}, 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_volume_tests.cpp b/tests/pyramid_volume_tests.cpp new file mode 100644 index 0000000..11fc109 --- /dev/null +++ b/tests/pyramid_volume_tests.cpp @@ -0,0 +1,487 @@ +#include "esphome/components/voice_pyramid_audio/audio.h" +#include "nvs_memory.h" +#include +#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"})); +} +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[fake_i2s::next_transmit].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()); +} +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/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..885bca4 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,23 @@ 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) + 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/test_waveshare.py b/tests/test_waveshare.py index 450f2e3..a2086ee 100644 --- a/tests/test_waveshare.py +++ b/tests/test_waveshare.py @@ -96,10 +96,11 @@ 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'): + '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 +110,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..bae2fe6 100644 --- a/tests/waveshare_stubs/esphome/components/es8311/es8311.h +++ b/tests/waveshare_stubs/esphome/components/es8311/es8311.h @@ -1,14 +1,26 @@ #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{}, mute{}; + bool wrong_mute_readback{}, wrong_unmute_readback{}; std::vector volumes; - bool set_volume(float value) { volumes.push_back(value); return volume_write_ok; } - bool set_mute_off() { ++unmute_writes; return unmute_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; 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 == 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 2e31a2b..aac4351 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,34 @@ #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}; + 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 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/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..d2a791b --- /dev/null +++ b/third_party/esphome-i2s-audio/UPSTREAM.md @@ -0,0 +1,55 @@ +# 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. 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. + +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..d0cebe2 --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -0,0 +1,565 @@ +// 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; + +#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 + // 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; +} + +#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_); +} + +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..3afb1cf --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -0,0 +1,199 @@ +// 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_(); + // 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 + /// @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..93c21cf --- /dev/null +++ b/third_party/esphome-i2s-audio/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -0,0 +1,625 @@ +// 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. + 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) { + ESP_LOGV(TAG, "Failed to enable I2S channel"); + successful_setup = false; + } + } + + 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; + // 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()) { +#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) { + // 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_)) { + fail_volume_fence_(); + 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) { +#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 + // 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_(); + // 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; + 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 = 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. + 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; + } + +#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; + 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) { + // 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) { +#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++; + } + } + } + + 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..8c2955f --- /dev/null +++ b/third_party/esphome-i2s-audio/volume-fence.patch @@ -0,0 +1,518 @@ +--- 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); +@@ -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. ++ 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 +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 ++ 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 +@@ -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 +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 +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 {}; ++ 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,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 +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 +@@ -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) { +@@ -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) { +@@ -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 +270,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 +279,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); +@@ -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 +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 +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 +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 +385,9 @@ +- i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); +- if (bw != output_bytes) { ++ 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) { +@@ -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 +445,9 @@ +- 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) { ++#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 @@ ++// 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/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__) 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