diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dd858be --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,47 @@ + +Version: + + +## What does this implement/fix? + + + +## Types of changes + + +- [ ] Bugfix (fixed change that fixes an issue) +- [ ] New feature (thanks!) +- [ ] Breaking change (repair/feature that breaks existing functionality) +- [ ] Dependency Update - Does not publish +- [ ] Other - Does not publish +- [ ] Website of github readme file update - Does not publish +- [ ] Github workflows - Does not publish + + +## Checklist / Checklijst: + + + - [ ] The code change has been tested and works locally + - [ ] The code change has not yet been tested + +If user-visible functionality or configuration variables are added/modified: + - [ ] Added/updated documentation for the web page + + diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..a7f7176 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,51 @@ +name-template: 'Release v$NEXT_PATCH_VERSION' +tag-template: "$RESOLVED_VERSION" +change-template: "- #$NUMBER $TITLE @$AUTHOR" +sort-direction: ascending + +categories: + - title: "🚨 Breaking changes" + labels: + - "breaking-change" + - title: "✨ New features" + labels: + - "new-feature" + - title: "🐛 Bug fixes" + labels: + - "bugfix" +# - title: "🚀 Enhancements" +# labels: +# - "enhancement" +# - "refactor" +# - "performance" +# - title: "🧰 Maintenance" +# labels: +# - "maintenance" +# - "ci" +# - title: "📚 Documentation" +# labels: +# - "documentation" + - title: "⬆️ Dependency updates" + collapse-after: 5 + labels: + - "dependency-update" +# - title: "🚨🚨 Security Fixes 🚨🚨" +# labels: +# - "security" + + +include-labels: + - "bugfix" + - "new-feature" + - "breaking-change" + +no-changes-template: '- No changes' + +template: | + ## What's Changed + + $CHANGES + + **Full Changelog**: https://github.com/ApolloAutomation/CAST_PRO-1/compare/$PREVIOUS_TAG...$RESOLVED_VERSION.1 + + Be sure to 🌟 this repository for updates! diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml index a70c3da..03b36f5 100644 --- a/.github/workflows/autoassign.yml +++ b/.github/workflows/autoassign.yml @@ -1,9 +1,12 @@ name: Auto Assign +# pull_request_target (not pull_request) so auto-assign works on +# fork-submitted PRs; fork pull_request runs only get a read-only token. +# Safe because this workflow never checks out or executes PR code. on: issues: types: [opened] - pull_request: + pull_request_target: types: [opened] permissions: @@ -12,9 +15,7 @@ permissions: jobs: auto-assign: - # Skip auto-assign for pull requests from forks due to GITHUB_TOKEN permission restrictions - if: github.event_name == 'issues' || github.event.pull_request.head.repo.full_name == github.repository uses: ApolloAutomation/Workflows/.github/workflows/autoassign.yml@main with: - assignees: TrevorSchirmer + assignees: bharvey88 num-of-assignees: 1 diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml new file mode 100644 index 0000000..759795e --- /dev/null +++ b/.github/workflows/build-beta.yml @@ -0,0 +1,108 @@ +name: Build and Publish Beta + +# Builds CAST_PRO-1 firmware from the beta branch and publishes it as assets on a +# rolling "beta" pre-release. The on-device "Firmware Channel" select points OTA +# updates at these assets. Stable firmware is built/published separately by +# build.yml (push to main -> GitHub Pages). +# +# NOTE: the unified firmware's Beta manifest is the manifest.json asset, +# which only exists once this workflow has run after the unified-firmware +# change. Merge to beta (or run this workflow manually) promptly after it +# lands on main — until then, devices switched to the Beta channel get a +# 404 on their update manifest. + +on: + push: + branches: [beta] + paths: + - 'Integrations/ESPHome/**' + workflow_dispatch: + +# Least privilege: read-only by default; only publish-beta is elevated to write. +permissions: + contents: read + +jobs: + version: + name: Read version + runs-on: ubuntu-latest + outputs: + v: ${{ steps.read.outputs.v }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: read + run: | + v=$(awk '/substitutions:/ {f=1} f && /version:/ {print $2; exit}' \ + Integrations/ESPHome/Core.yaml | tr -d '"') + echo "v=$v" >> "$GITHUB_OUTPUT" + echo "Beta version: $v" + + build: + name: Build ${{ matrix.name }} + needs: version + strategy: + matrix: + include: + - { yaml: Integrations/ESPHome/CAST_PRO-1.yaml, name: firmware } + uses: esphome/workflows/.github/workflows/build.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + with: + files: ${{ matrix.yaml }} + esphome-version: stable + combined-name: ${{ matrix.name }} + release-version: ${{ needs.version.outputs.v }} + + publish-beta: + name: Publish beta release assets + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download firmware artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: fw + pattern: firmware* + + - name: Ensure rolling 'beta-fw' pre-release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view beta-fw -R "${{ github.repository }}" >/dev/null 2>&1 \ + || gh release create beta-fw -R "${{ github.repository }}" \ + --prerelease --title "Beta (rolling)" \ + --notes "Latest CAST_PRO-1 beta firmware. Auto-updated on every push to the beta branch." + + - name: Rewrite manifests to absolute URLs and upload assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BASE="https://github.com/${{ github.repository }}/releases/download/beta-fw" + man=$(find "fw/firmware" -name manifest.json | head -1) + if [ -z "$man" ]; then + echo "::error::manifest.json not found" + exit 1 + fi + echo "Rewriting $man -> manifest.json" + # Make ota.path and parts[].path absolute release-asset URLs so the + # device never has to resolve a relative path against a redirected URL. + jq --arg base "$BASE" ' + .builds[0].ota.path = ($base + "/" + (.builds[0].ota.path | sub(".*/"; ""))) + | .builds[0].parts |= map(.path = ($base + "/" + (.path | sub(".*/"; "")))) + ' "$man" > "manifest.json" + cat "manifest.json" + gh release upload beta-fw "manifest.json" -R "${{ github.repository }}" --clobber + find "fw/firmware" -name '*.bin' -print -exec \ + gh release upload beta-fw {} -R "${{ github.repository }}" --clobber \; + echo "Beta assets published." + + - name: Point beta-fw tag at the built commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # gh release create tags default-branch HEAD, and uploads never move + # the tag, so without this the release's source commit drifts away + # from the assets actually published. + gh api -X PATCH "repos/${{ github.repository }}/git/refs/tags/beta-fw" \ + -f sha="${{ github.sha }}" -F force=true + echo "beta-fw -> ${{ github.sha }}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e22fcd..00cea9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,8 @@ on: type: boolean required: false default: false + # release: + # types: [published] jobs: build-and-publish: @@ -21,15 +23,16 @@ jobs: id-token: write pull-requests: write with: - # TODO: Rename to this product's device name and YAML files - device-name: producttemplate + device-name: cast_pro-1 yaml-files: | - Integrations/ESPHome/AIR-1.yaml - firmware-names: ":firmware" + Integrations/ESPHome/CAST_PRO-1.yaml + # Maps CAST_PRO-1.yaml to the firmware/ output dir (the shared workflow's + # pattern key is everything after the last dash of the filename). + firmware-names: "1:firmware" core-yaml-path: Integrations/ESPHome/Core.yaml esphome-version: stable # Bypass check if manually triggered with bypass option check-yaml-changes: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.bypass-yaml-check == 'true') }} publish-to-pages: true has-installer: true - installer-path: static + installer-path: static \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e7bda3..72eed2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,19 +4,18 @@ on: pull_request: permissions: - pull-requests: write - issues: write contents: read +# Label check lives in label-check.yml on pull_request_target so it can +# label fork-submitted PRs; plain pull_request tokens are read-only there. jobs: - label-check: - name: Label Check - uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@main - ci: name: CI uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main with: - # TODO: Rename to this product's YAML files yaml-files: | - Integrations/ESPHome/AIR-1.yaml + Integrations/ESPHome/CAST_PRO-1.yaml + esphome-versions: | + stable + beta + dev diff --git a/.github/workflows/label-check.yml b/.github/workflows/label-check.yml new file mode 100644 index 0000000..96a9751 --- /dev/null +++ b/.github/workflows/label-check.yml @@ -0,0 +1,20 @@ +name: Label Check + +# pull_request_target (not pull_request) so the job gets a write token on +# fork-submitted PRs too; plain pull_request runs from forks are read-only +# and cannot add labels. Safe because the called workflow only reads the PR +# body and never checks out or executes PR code. The "edited" type re-runs +# the check when the template checkboxes are changed. +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + label-check: + name: Label Check + uses: ApolloAutomation/Workflows/.github/workflows/label-check.yml@main diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 0ced14d..c616416 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -12,6 +12,9 @@ jobs: name: Weekly Build uses: ApolloAutomation/Workflows/.github/workflows/esphome-ci.yml@main with: - # TODO: Rename to this product's YAML files yaml-files: | - Integrations/ESPHome/AIR-1.yaml + Integrations/ESPHome/CAST_PRO-1.yaml + # dev excluded until the TemplateSelect .state incompatibility is fixed + esphome-versions: | + stable + beta diff --git a/Integrations/ESPHome/.gitignore b/Integrations/ESPHome/.gitignore new file mode 100644 index 0000000..1bfe7bd --- /dev/null +++ b/Integrations/ESPHome/.gitignore @@ -0,0 +1,6 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml +__pycache__/ diff --git a/Integrations/ESPHome/AIR-1.yaml b/Integrations/ESPHome/AIR-1.yaml deleted file mode 100644 index aa0575e..0000000 --- a/Integrations/ESPHome/AIR-1.yaml +++ /dev/null @@ -1,53 +0,0 @@ -esphome: - name: apollo-air-1 - friendly_name: Apollo AIR-1 # TODO: Rename - comment: Apollo AIR-1 # TODO: Rename - name_add_mac_suffix: true - platformio_options: - board_build.flash_mode: dio - - project: - name: "ApolloAutomation.AIR-1" # TODO: Rename - version: "${version}" - - min_version: 2023.11.1 - -dashboard_import: - package_import_url: github://ApolloAutomation/AIR-1/Integrations/ESPHome/AIR-1.yaml - import_full_config: false # TODO: Rename - -improv_serial: - -esp32_improv: - authorizer: none - -ota: - - platform: esphome - id: ota_esphome - - platform: http_request - id: ota_managed - -http_request: - verify_ssl: true - -safe_mode: - -update: - - platform: http_request - id: firmware_update - name: Firmware Update - source: https://apolloautomation.github.io/PLT-1/artifact/manifest.json # TODO: Rename - -wifi: - on_connect: - - delay: 5s - - ble.disable: - on_disconnect: - - ble.enable: - ap: - ssid: "Apollo PLT1 Hotspot" # TODO: Rename - -logger: - -packages: - core: !include Core.yaml diff --git a/Integrations/ESPHome/CAST_PRO-1.yaml b/Integrations/ESPHome/CAST_PRO-1.yaml new file mode 100644 index 0000000..e243de5 --- /dev/null +++ b/Integrations/ESPHome/CAST_PRO-1.yaml @@ -0,0 +1,14 @@ +# Apollo CAST_PRO-1; device config lives in Core.yaml + +esphome: + name: "apollo-cast-pro-1" + project: + name: "ApolloAutomation.CAST_PRO-1" + version: ${version} + +dashboard_import: + package_import_url: github://ApolloAutomation/CAST_PRO-1/Integrations/ESPHome/CAST_PRO-1.yaml + import_full_config: false + +packages: + core: !include Core.yaml diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 0540c2d..f8960ef 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,14 +1,248 @@ substitutions: - name: apollo-plt-1 # TODO: Rename - version: "24.8.7.2" - device_description: ${name} made by Apollo Automation - version ${version}. + version: "26.9.21.1" + stable_manifest_base: "https://apolloautomation.github.io/CAST_PRO-1" + beta_manifest_base: "https://github.com/ApolloAutomation/CAST_PRO-1/releases/download/beta-fw" + stable_manifest_dir: "firmware" + beta_manifest_file: "manifest.json" + + +packages: + wizmote: !include wizmote.yaml + +# Full-duplex i2s_audio; see components/i2s_audio/README.md +external_components: + - source: + type: local + path: components + components: [i2s_audio] + +esphome: + friendly_name: Apollo CAST_PRO-1 + comment: Apollo CAST_PRO-1 + name_add_mac_suffix: true + min_version: 2026.8.0 + platformio_options: + board_build.flash_mode: dio + # List form so wizmote.yaml's on_boot entries merge + on_boot: + - priority: 800 + then: + - if: + condition: + - lambda: "return id(runTest);" + then: + - lambda: "id(testScript).execute();" + # WiFi stays off while Ethernet is expected; after globals (800), before wifi (245) + - priority: 600 + then: + - if: + condition: + lambda: "return id(ethernet_expected);" + then: + - lambda: |- + id(wifi_1).set_enable_on_boot(false); + id(espnow_component).set_wifi_channel(1); + # Without a W5500 its task polls a floating bus and panics; after ethernet (250) + - priority: 200 + then: + - if: + condition: + lambda: "return id(ethernet_1).is_failed();" + then: + - lambda: |- + TaskHandle_t orphan = xTaskGetHandle("w5500_tsk"); + if (orphan != nullptr) { + vTaskSuspend(orphan); + ESP_LOGW("ethernet", "No Ethernet HAT detected; parked the W5500 helper task"); + } + - priority: 500 + then: + - text_sensor.template.publish: + id: apollo_firmware_version + state: "${version}" + - priority: -100 + then: + - script.execute: apply_ota_source + - priority: -100 + then: + - if: + condition: + lambda: "return id(ethernet_expected);" + then: + - wait_until: + condition: + ethernet.connected: + timeout: 20s + - if: + condition: + not: + ethernet.connected: + then: + - lambda: "id(ethernet_expected) = false;" + - wifi.enable: + # Line In needs the ADC and mixer set up first + - priority: -100 + then: + - lambda: "id(line_in_ready) = true;" + - script.execute: line_in_apply + - priority: -300 + then: + - if: + condition: + switch.is_on: bluetooth_proxy_switch + then: + - esp32_ble_tracker.start_scan: + continuous: true + else: + - esp32_ble_tracker.stop_scan: + +ota: + - platform: esphome + id: ota_esphome + - platform: http_request + id: ota_managed + +safe_mode: + id: safe_mode_1 + +logger: + id: logger_1 + +update: + - platform: http_request + id: update_http_request + name: Firmware Update + source: ${stable_manifest_base}/${stable_manifest_dir}/manifest.json + +network: + id: network_1 + priority: + - ethernet + - wifi + +ethernet: + id: ethernet_1 + type: W5500 + clock_speed: 40MHz + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO5 + reset_pin: GPIO21 + on_connect: + # Keep WiFi alongside Ethernet unless it has no network + - if: + condition: + wifi.enabled: + then: + - if: + condition: + wifi.ap_active: + then: + - script.execute: wifi_release_radio + else: + - wait_until: + condition: + or: + - wifi.connected: + - wifi.ap_active: + timeout: 30s + - if: + condition: + wifi.connected: + then: + - lambda: "id(ethernet_expected) = false;" + else: + - if: + condition: + ethernet.connected: + then: + - script.execute: wifi_release_radio + else: + - lambda: "id(ethernet_expected) = true;" + - component.update: update_http_request + - component.update: network_connection + on_disconnect: + - lambda: "id(ethernet_expected) = false;" + - wifi.enable: + - component.update: network_connection + +wifi: + id: wifi_1 + on_connect: + - lambda: "id(wifi_provisioned) = true;" + - component.update: update_http_request + on_disconnect: + # Release WiFi before its 90s fallback hotspot can start + - if: + condition: + ethernet.connected: + then: + - wait_until: + condition: + wifi.connected: + timeout: 60s + - if: + condition: + and: + - wifi.enabled: + - not: + wifi.connected: + - ethernet.connected: + then: + - script.execute: wifi_release_radio + # Workaround: roam scans drop active audio + post_connect_roaming: false + ap: + ssid: "Apollo CAST_PRO-1 Hotspot" + power_save_mode: none + +captive_portal: + id: captive_portal_1 + +improv_serial: + id: improv_serial_1 + +esp32_improv: + id: esp32_improv_1 + authorizer: none esp32: - board: esp32-c3-devkitm-1 + variant: ESP32S3 + flash_size: 8MB + cpu_frequency: 240MHz framework: type: esp-idf + version: recommended + sdkconfig_options: + CONFIG_ESP32S3_DATA_CACHE_32KB: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y" + + CONFIG_SPIRAM_RODATA: "y" + CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y" + + # Allocate BT in PSRAM so internal RAM stays free for audio + CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y" + CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y" + + CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y" + CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y" + +esp32_ble_tracker: + id: ble_tracker + scan_parameters: + continuous: true + +bluetooth_proxy: + id: bluetooth_proxy_1 api: + id: api_1 + # Sendspin works without an API client, so don't reboot for lack of one + reboot_timeout: 0s + encryption: globals: - id: cycleCounter @@ -19,8 +253,622 @@ globals: restore_value: no type: uint32_t initial_value: '0' + - id: runTest + restore_value: yes + type: bool + initial_value: "true" + - id: testCycleCount + type: int + restore_value: no + initial_value: "0" + - id: announcement_triggered + type: bool + initial_value: 'false' + # WiFi was released for Ethernet; next boot starts WiFi-silent + - id: ethernet_expected + type: bool + restore_value: yes + initial_value: 'false' + - id: line_in_ready + type: bool + restore_value: no + initial_value: 'false' + # WiFi has connected before; the recovery probe never runs otherwise + - id: wifi_provisioned + type: bool + restore_value: yes + initial_value: 'false' -captive_portal: +psram: + id: psram_1 + mode: octal + speed: 80MHz + # Guarantee PSRAM; unlocks fast network tier + ignore_not_found: false web_server: - port: 80 \ No newline at end of file + id: web_server_1 + port: 80 + version: 3 + +http_request: + id: http_request_1 + verify_ssl: true + # Fits GitHub's ~3.6KB CSP redirect header + buffer_size_rx: 5120 + # Fits GitHub's long signed redirect URL + buffer_size_tx: 2048 + +i2c: + sda: GPIO47 + scl: GPIO48 + id: bus_a + +button: + - platform: restart + id: restart_button + icon: mdi:power-cycle + name: "ESP Reboot" + - platform: template + name: "Firmware Update" + id: update_firmware + icon: mdi:cloud-download + entity_category: config + on_press: + - logger.log: "Applying firmware update for the selected channel" + - delay: 3s + - script.execute: apply_ota_source + - script.wait: apply_ota_source + # Give async manifest fetch time to land + - delay: 5s + - lambda: id(update_http_request).perform(true); + +binary_sensor: + - platform: status + name: Online + id: ink_ha_connected + + - platform: gpio + pin: + number: GPIO0 + inverted: true + ignore_strapping_warning: true + mode: + input: true + pullup: true + id: reset_button + on_press: + then: + - lambda: |- + id(button_press_timestamp) = millis(); + on_release: + then: + - lambda: |- + if (millis() - id(button_press_timestamp) >= 8000) { + id(factory_reset_switch).turn_on(); + } + else if (millis() - id(button_press_timestamp) >= 1000) { + // StatusCheck + id(statusCheck).execute(); + delay(3000); + id(testCycleCount) = 0; + id(runTest) = true; + id(testScript).execute(); + } + +sensor: + - platform: internal_temperature + name: "ESP Temperature" + id: sys_esp_temperature + disabled_by_default: true + + - platform: uptime + name: Uptime + id: sys_uptime + update_interval: 60s + +switch: + - platform: factory_reset + disabled_by_default: True + name: "Factory Reset ESP" + id: factory_reset_all + + - platform: factory_reset + id: factory_reset_switch + internal: true + + - platform: template + name: "Line In" + id: line_in_switch + icon: mdi:audio-input-rca + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + on_state: + - if: + condition: + lambda: "return id(line_in_ready);" + then: + - script.execute: line_in_apply + + - platform: template + name: "Bluetooth Proxy" + id: bluetooth_proxy_switch + icon: mdi:bluetooth + entity_category: "config" + restore_mode: RESTORE_DEFAULT_OFF + optimistic: true + on_turn_on: + - esp32_ble_tracker.start_scan: + continuous: true + on_turn_off: + - esp32_ble_tracker.stop_scan: + +text_sensor: + - platform: template + name: "Network Connection" + id: network_connection + icon: "mdi:network" + entity_category: "diagnostic" + update_interval: 10s + lambda: |- + const bool eth = id(ethernet_1).is_connected(); + const bool sta = !id(wifi_1).is_disabled() && id(wifi_1).is_connected(); + const bool ap = !id(wifi_1).is_disabled() && id(wifi_1).is_ap_active(); + if (eth && sta) return std::string("Ethernet + WiFi"); + if (eth) return std::string("Ethernet"); + if (sta) return std::string("WiFi"); + if (ap) return std::string("Hotspot"); + return std::string("Connecting"); + + - platform: ethernet_info + ip_address: + name: "Ethernet IP Address" + id: eth_ip + entity_category: "diagnostic" + + - platform: wifi_info + ip_address: + name: "WiFi IP Address" + id: wifi_ip + entity_category: "diagnostic" + + - platform: sendspin + id: sendspin_track_title + type: title + name: Track Title + + - platform: sendspin + id: sendspin_artist + type: artist + name: Artist + + - platform: sendspin + id: sendspin_album + type: album + name: Album + + - platform: version + id: esphome_version_sensor + name: "ESPHome Version" + hide_timestamp: true + entity_category: "diagnostic" + + - platform: template + name: "Apollo Firmware Version" + id: apollo_firmware_version + update_interval: never + entity_category: "diagnostic" + +light: + - platform: esp32_rmt_led_strip + id: rgb_light + name: "RGB Light" + pin: GPIO4 + default_transition_length: 0s + chipset: WS2812 + num_leds: 4 + channel_colors: GRB + effects: + - pulse: + name: "Slow Pulse" + transition_length: 1000ms + update_interval: 1000ms + min_brightness: 50% + max_brightness: 100% + - pulse: + name: "Fast Pulse" + transition_length: 100ms + update_interval: 100ms + min_brightness: 50% + max_brightness: 100% + +select: + - platform: template + id: firmware_channel + name: "Firmware Channel" + icon: "mdi:source-branch" + entity_category: config + optimistic: true + restore_value: true + options: + - "Stable" + - "Beta" + initial_option: "Stable" + on_value: + then: + - script.execute: apply_ota_source + + - platform: template + id: audio_output + name: "Audio Output" + icon: "mdi:speaker" + entity_category: config + optimistic: true + restore_value: true + options: + - "Analog" + - "Optical" + initial_option: "Analog" + on_value: + then: + - if: + condition: + lambda: 'return x == "Optical";' + then: + - router.speaker.switch_output: + id: output_router + target_speaker: spdif_speaker + else: + - router.speaker.switch_output: + id: output_router + target_speaker: dac_speaker + +audio_dac: + - platform: pcm5122 + id: external_dac + i2c_id: bus_a + address: 0x4D + bits_per_sample: 32bit + +# DAC and ADC share one port in full duplex (SPDIF has the other): 48 kHz stereo, 32-bit slots +i2s_audio: + - id: i2s_main + i2s_lrclk_pin: GPIO7 + i2s_bclk_pin: GPIO14 + i2s_mclk_pin: GPIO17 + full_duplex: true + - id: i2s_spdif + +speaker: + - platform: i2s_audio + id: dac_speaker + i2s_audio_id: i2s_main + sample_rate: 48000 + i2s_dout_pin: GPIO9 + bits_per_sample: 32bit + dac_type: external + channel: stereo + timeout: never + buffer_duration: 100ms + + - platform: i2s_audio + id: spdif_speaker + i2s_audio_id: i2s_spdif + i2s_dout_pin: GPIO16 + dac_type: external + spdif_mode: true + # Required by validation; the S3 has no APLL + use_apll: true + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo + timeout: never + buffer_duration: 100ms + + - platform: router + id: output_router + output_speakers: + - dac_speaker + - spdif_speaker + sample_rate: 48000 + bits_per_sample: 32 + num_channels: 2 + + - platform: mixer + id: mixer_speaker_id + output_speaker: output_router + num_channels: 2 + task_stack_in_psram: true + source_speakers: + - id: announcement_mixer_input + timeout: never + - id: media_mixer_input + timeout: never + - id: line_in_mixer_input + timeout: never + + # These stacks must stay in internal RAM, so leave task_stack_in_psram unset + - platform: resampler + id: announcement_resampling_speaker + output_speaker: announcement_mixer_input + sample_rate: 48000 + bits_per_sample: 32 + + - platform: resampler + id: media_resampling_speaker + output_speaker: media_mixer_input + sample_rate: 48000 + bits_per_sample: 32 + + +# PCM1808: MD0/MD1/FMT tied low (slave, I2S) +microphone: + - platform: i2s_audio + id: line_in_adc + i2s_audio_id: i2s_main + adc_type: external + i2s_din_pin: GPIO15 + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo + on_data: + - lambda: |- + if (id(line_in_switch).state) { + id(line_in_mixer_input)->play(x.data(), x.size(), 0); + } + +sendspin: + id: sendspin_hub + # PSRAM stack skews time sync; keep internal + task_stack_in_psram: false + +media_source: + - platform: sendspin + id: sendspin_source + + - platform: audio_http + id: http_media_source + buffer_size: 500000 + + - platform: audio_http + id: http_announcement_source + buffer_size: 250000 + +media_player: + - platform: sendspin + id: sendspin_group_media_player + name: "Sendspin Player" + + - platform: speaker_source + id: external_media_player + name: "Player" + volume_increment: 0.05 + + announcement_pipeline: + format: FLAC + num_channels: 1 + sample_rate: 48000 + speaker: announcement_resampling_speaker + sources: + - http_announcement_source + + media_pipeline: + speaker: media_resampling_speaker + format: FLAC + num_channels: 2 + sample_rate: 48000 + sources: + - sendspin_source + - http_media_source + + on_announcement: + - if: + condition: + - lambda: return (!id(announcement_triggered)); + then: + - lambda: id(announcement_triggered) = true; + - light.turn_on: + id: rgb_light + blue: 100% + red: 0% + green: 100% + brightness: 50% + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 40 + duration: 0.0s + - mixer_speaker.apply_ducking: + id: line_in_mixer_input + decibel_reduction: 40 + duration: 0.0s + - wait_until: + media_player.is_announcing: external_media_player + - wait_until: + - not: + media_player.is_announcing: external_media_player + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 0 + duration: 1.0s + - mixer_speaker.apply_ducking: + id: line_in_mixer_input + decibel_reduction: 0 + duration: 1.0s + - light.turn_off: + id: rgb_light + - lambda: id(announcement_triggered) = false; + + on_state: + # pcm5122 boots muted; unmute (idempotent) + - audio_dac.mute_off: external_dac + - if: + condition: + and: + - lambda: return (!id(announcement_triggered)); + - not: + media_player.is_announcing: external_media_player + then: + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 0 + duration: 1.0s + - mixer_speaker.apply_ducking: + id: line_in_mixer_input + decibel_reduction: 0 + duration: 1.0s + + + + + +# Retry a released WiFi while on Ethernet +interval: + - id: wifi_recovery_interval + interval: 10min + then: + - if: + condition: + and: + - ethernet.connected: + - not: + wifi.enabled: + - lambda: "return id(wifi_provisioned);" + then: + - script.execute: wifi_recovery_probe + +script: + - id: line_in_apply + then: + - if: + condition: + switch.is_on: line_in_switch + then: + - lambda: |- + id(line_in_mixer_input)->set_audio_stream_info(audio::AudioStreamInfo(32, 2, 48000)); + - microphone.capture: line_in_adc + else: + - microphone.stop_capture: line_in_adc + - lambda: |- + if (!id(line_in_mixer_input)->is_stopped()) { + id(line_in_mixer_input)->stop(); + } + + - id: wifi_recovery_probe + then: + - wifi.enable: + - wait_until: + condition: + wifi.connected: + timeout: 60s + - if: + condition: + wifi.connected: + then: + - lambda: "id(ethernet_expected) = false;" + else: + - if: + condition: + ethernet.connected: + then: + - script.execute: wifi_release_radio + + - id: wifi_release_radio + # Also bounces ESP-NOW so it re-inits standalone for the WizMote + then: + - lambda: "id(ethernet_expected) = true;" + - if: + condition: + wifi.ap_active: + then: + # A started AP can't be relaunched in the same boot, so reboot + - lambda: |- + ESP_LOGW("network", "Rebooting to fully release the active hotspot (one-time)"); + - delay: 500ms + - lambda: "App.safe_reboot();" + else: + - wifi.disable: + - lambda: |- + if (id(captive_portal_1).is_active()) { + id(captive_portal_1).end(); + } + id(espnow_component).disable(); + id(espnow_component).enable(); + + - id: apply_ota_source + then: + - lambda: |- + const bool beta = id(firmware_channel).current_option() == "Beta"; + const char *url = beta + ? "${beta_manifest_base}/${beta_manifest_file}" + : "${stable_manifest_base}/${stable_manifest_dir}/manifest.json"; + ESP_LOGI("firmware", "OTA manifest set to: %s", url); + id(update_http_request).set_source_url(url); + - component.update: update_http_request + - id: statusCheck + then: + - if: + condition: + - lambda: 'return id(ink_ha_connected).state;' + then: + - logger.log: "Apollo Automation: Connected To HA" + - light.turn_on: + id: rgb_light + brightness: 100% + red: 0% + green: 0% + blue: 100% + - delay: 5s + - light.turn_off: rgb_light + + - id: testScript + then: + if: + condition: + - lambda: "return id(runTest) == true;" + then: + - lambda: "id(runTest) = false;" + - lambda: "id(testCycleCount) = 0;" + - delay: 2s + - while: + condition: + - lambda: "return id(testCycleCount) < 5;" + then: + - lambda: "id(runTest) = false;" + - delay: 1s + - lambda: "id(testCycleCount) += 1;" + + ## TODO: Update + - if: + condition: + - lambda: "return true;" + then: + - lambda: "id(runTest) = false;" + - light.turn_on: + id: rgb_light + red: 0% + green: 100% + blue: 0% + - delay: 5s + - light.turn_on: + id: rgb_light + red: 0% + green: 0% + blue: 0% + - light.turn_off: + id: rgb_light + + else: + - lambda: "id(runTest) = false;" + - light.turn_on: + id: rgb_light + red: 100% + green: 0% + blue: 0% + - delay: 5s + - light.turn_on: + id: rgb_light + red: 0% + green: 0% + blue: 0% + - light.turn_off: + id: rgb_light diff --git a/Integrations/ESPHome/components/i2s_audio/README.md b/Integrations/ESPHome/components/i2s_audio/README.md new file mode 100644 index 0000000..0d3bb68 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/README.md @@ -0,0 +1,19 @@ +# i2s_audio (full duplex) + +ESPHome 2026.9.0's `i2s_audio` with full duplex support, so the PCM5122 DAC and +PCM1808 ADC can share one I2S port (common WS/BCLK/MCLK, separate DOUT/DIN). + +Based on esphome/esphome#16882 (head `3db4594`), with these changes: + +- The speaker joins a TX channel the microphone already started without + skewing its playback timestamps (Sendspin sync), and realigns its write + position each session since TX is never reset. +- The DMA ring is fixed at the speaker's 5 x 10 ms, whichever side allocates + first, so stall tolerance doesn't depend on start order. +- TX always auto-clears, so the DAC plays silence rather than looping the last + buffer when the microphone allocated the channels first. +- Both channels get both data pins, whichever side initializes first. +- Mismatched speaker/microphone formats are rejected at config time and at + runtime, instead of playing at the wrong speed. + +Drop this override once full duplex lands upstream. diff --git a/Integrations/ESPHome/components/i2s_audio/__init__.py b/Integrations/ESPHome/components/i2s_audio/__init__.py new file mode 100644 index 0000000..2bea84d --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/__init__.py @@ -0,0 +1,352 @@ +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_FULL_DUPLEX = "full_duplex" +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, + cv.Optional(CONF_FULL_DUPLEX, default=False): cv.boolean, + }, +) + + +@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 _validate_full_duplex(i2s_audio_configs: list[ConfigType]) -> None: + """Full duplex runs TX and RX from one clock and slot layout, so every user must agree on the format.""" + full_config = fv.full_config.get() + for bus in i2s_audio_configs: + if not bus[CONF_FULL_DUPLEX]: + continue + bus_id = str(bus[CONF_ID]) + users = [ + (domain, conf) + for domain in ("speaker", "microphone") + for conf in full_config.get(domain, []) + if conf.get("platform") == CONF_I2S_AUDIO + and str(conf.get(CONF_I2S_AUDIO_ID)) == bus_id + ] + speakers = [conf for domain, conf in users if domain == "speaker"] + microphones = [conf for domain, conf in users if domain == "microphone"] + if len(speakers) > 1 or len(microphones) > 1: + raise cv.Invalid( + f"Full duplex bus '{bus_id}' supports one speaker and one microphone" + ) + if any(conf.get("spdif_mode") for conf in speakers): + raise cv.Invalid(f"Full duplex bus '{bus_id}' does not support SPDIF mode") + if any(conf.get(CONF_PDM) for conf in microphones): + raise cv.Invalid(f"Full duplex bus '{bus_id}' does not support PDM") + # The microphone is always Philips I2S, and both channels share one slot config + if any(conf.get("i2s_comm_fmt", "stand_i2s") != "stand_i2s" for conf in speakers): + raise cv.Invalid( + f"Full duplex bus '{bus_id}' requires 'i2s_comm_fmt: stand_i2s'" + ) + + def _format(conf: ConfigType) -> tuple: + slot_mode = CONF_STEREO if conf[CONF_CHANNEL] == CONF_STEREO else CONF_MONO + return ( + conf[CONF_SAMPLE_RATE], + conf[CONF_BITS_PER_SAMPLE], + slot_mode, + conf[CONF_MCLK_MULTIPLE], + conf[CONF_I2S_MODE], + ) + + formats = {_format(conf) for _, conf in users} + if len(formats) > 1: + raise cv.Invalid( + f"Full duplex bus '{bus_id}' needs the speaker and microphone to share sample_rate, " + f"bits_per_sample, stereo/mono channel layout, mclk_multiple and i2s_mode" + ) + + +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}" + ) + _validate_full_duplex(i2s_audio_configs) + _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])) + cg.add(var.set_full_duplex(config[CONF_FULL_DUPLEX])) diff --git a/Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp b/Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp new file mode 100644 index 0000000..2303bff --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/i2s_audio.cpp @@ -0,0 +1,339 @@ +#include "i2s_audio.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include +#include +#include + +#include +#include "esp_rom_sys.h" +#include "esp_timer.h" + +namespace esphome::i2s_audio { + +static const char *const TAG = "i2s_audio"; + +// The shared DMA ring is fixed rather than taken from whichever side allocates first, so the speaker keeps its +// usual stall tolerance even when the microphone starts first. Matches the standard speaker's 5 x 10 ms ring. +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFER_DURATION_MS = 10; +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFERS_COUNT = 5; +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr uint32_t FULL_DUPLEX_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif +// Long enough for an ISR that fired on_sent before a queue reset to also release its buffer +static constexpr uint32_t FULL_DUPLEX_ISR_SETTLE_US = 50; + +static uint32_t effective_slot_bit_width(const i2s_std_slot_config_t &slot_cfg) { + if (slot_cfg.slot_bit_width == I2S_SLOT_BIT_WIDTH_AUTO) { + return static_cast(slot_cfg.data_bit_width); + } + return static_cast(slot_cfg.slot_bit_width); +} + +esp_err_t I2SAudioComponent::allocate_full_duplex_channels_(const i2s_chan_config_t &chan_cfg, + const i2s_std_config_t &std_cfg) { + if (this->rx_handle_ != nullptr && this->tx_handle_ != nullptr) { + return ESP_OK; + } + if (!this->full_duplex_) { + return ESP_ERR_INVALID_STATE; + } + if (this->din_pin_ == I2S_GPIO_UNUSED || this->dout_pin_ == I2S_GPIO_UNUSED) { + ESP_LOGE(TAG, "Full duplex requires both DIN and DOUT pins on the same I2S bus"); + return ESP_ERR_INVALID_ARG; + } + + // Both channels share this config. If the microphone allocates first, its config has auto clear off, and + // the DAC would then loop the last buffer whenever the speaker stops writing + i2s_chan_config_t duplex_cfg = chan_cfg; + duplex_cfg.auto_clear = true; + const uint32_t frame_bytes = (static_cast(std_cfg.slot_cfg.data_bit_width) / 8) * + (std_cfg.slot_cfg.slot_mode == I2S_SLOT_MODE_STEREO ? 2 : 1); + duplex_cfg.dma_desc_num = FULL_DUPLEX_DMA_BUFFERS_COUNT; + duplex_cfg.dma_frame_num = std::min(std_cfg.clk_cfg.sample_rate_hz * FULL_DUPLEX_DMA_BUFFER_DURATION_MS / 1000, + FULL_DUPLEX_DMA_BUFFER_MAX_SIZE / frame_bytes); + + esp_err_t err = i2s_new_channel(&duplex_cfg, &this->tx_handle_, &this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex channel allocation failed: %s", esp_err_to_name(err)); + this->tx_handle_ = nullptr; + this->rx_handle_ = nullptr; + this->full_duplex_dma_desc_num_ = 0; + } else { + this->full_duplex_dma_desc_num_ = duplex_cfg.dma_desc_num; + } + return err; +} + +esp_err_t I2SAudioComponent::initialize_full_duplex_channels_(const i2s_std_config_t &std_cfg) { + if (this->tx_handle_ == nullptr || this->rx_handle_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } + + if (this->tx_channel_initialized_) { + // Channels keep the first user's clock and slot layout; a different format would play at the wrong speed + if (std_cfg.clk_cfg.sample_rate_hz != this->full_duplex_sample_rate_ || + static_cast(std_cfg.slot_cfg.data_bit_width) != this->full_duplex_data_bit_width_ || + effective_slot_bit_width(std_cfg.slot_cfg) != this->full_duplex_slot_bit_width_ || + std_cfg.slot_cfg.slot_mode != this->full_duplex_slot_mode_) { + ESP_LOGE(TAG, + "Full duplex format mismatch: bus runs %" PRIu32 " Hz, %" PRIu32 "-bit data in %" PRIu32 + "-bit %s slots, requested %" PRIu32 " Hz, %" PRIu32 "-bit data in %" PRIu32 "-bit %s slots", + this->full_duplex_sample_rate_, this->full_duplex_data_bit_width_, this->full_duplex_slot_bit_width_, + this->full_duplex_slot_mode_ == I2S_SLOT_MODE_STEREO ? "stereo" : "mono", + std_cfg.clk_cfg.sample_rate_hz, static_cast(std_cfg.slot_cfg.data_bit_width), + effective_slot_bit_width(std_cfg.slot_cfg), + std_cfg.slot_cfg.slot_mode == I2S_SLOT_MODE_STEREO ? "stereo" : "mono"); + return ESP_ERR_INVALID_ARG; + } + } + + // Each user's pin config only names its own data pin, but both channels are initialized here + i2s_std_config_t duplex_cfg = std_cfg; + duplex_cfg.gpio_cfg = this->get_full_duplex_pin_config(); + + if (!this->tx_channel_initialized_) { + esp_err_t err = i2s_channel_init_std_mode(this->tx_handle_, &duplex_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize full duplex TX channel: %s", esp_err_to_name(err)); + return err; + } + this->tx_channel_initialized_ = true; + this->full_duplex_sample_rate_ = std_cfg.clk_cfg.sample_rate_hz; + this->full_duplex_data_bit_width_ = static_cast(std_cfg.slot_cfg.data_bit_width); + this->full_duplex_slot_bit_width_ = effective_slot_bit_width(std_cfg.slot_cfg); + this->full_duplex_slot_mode_ = std_cfg.slot_cfg.slot_mode; + } + + if (!this->rx_channel_initialized_) { + esp_err_t err = i2s_channel_init_std_mode(this->rx_handle_, &duplex_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize full duplex RX channel: %s", esp_err_to_name(err)); + return err; + } + this->rx_channel_initialized_ = true; + } + + return ESP_OK; +} + +esp_err_t I2SAudioComponent::setup_full_duplex_rx_channel(const i2s_chan_config_t &chan_cfg, + const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *rx_handle) { + LockGuard guard(this->full_duplex_lock_); + esp_err_t err = this->allocate_full_duplex_channels_(chan_cfg, std_cfg); + if (err != ESP_OK) { + return err; + } + err = this->initialize_full_duplex_channels_(std_cfg); + if (err == ESP_OK) { + *rx_handle = this->rx_handle_; + } + return err; +} + +esp_err_t I2SAudioComponent::setup_full_duplex_tx_channel(const i2s_chan_config_t &chan_cfg, + const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *tx_handle) { + LockGuard guard(this->full_duplex_lock_); + esp_err_t err = this->allocate_full_duplex_channels_(chan_cfg, std_cfg); + if (err != ESP_OK) { + return err; + } + err = this->initialize_full_duplex_channels_(std_cfg); + if (err == ESP_OK) { + *tx_handle = this->tx_handle_; + } + return err; +} + +size_t I2SAudioComponent::get_full_duplex_dma_buffer_bytes_() { + i2s_chan_info_t chan_info; + if (this->full_duplex_dma_desc_num_ == 0 || i2s_channel_get_info(this->tx_handle_, &chan_info) != ESP_OK) { + return 0; + } + return chan_info.total_dma_buf_size / this->full_duplex_dma_desc_num_; +} + +// Caller holds full_duplex_lock_ +esp_err_t I2SAudioComponent::start_full_duplex_tx_(const uint8_t *silence, size_t buffer_bytes) { + esp_err_t err; + if (!this->full_duplex_tx_callback_registered_) { + // Must be registered before the channel is enabled + const i2s_event_callbacks_t callbacks = {.on_sent = I2SAudioComponent::full_duplex_on_sent_cb}; + err = i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex TX callback registration failed: %s", esp_err_to_name(err)); + return err; + } + this->full_duplex_tx_callback_registered_ = true; + } + + // Preload every descriptor so the first on_sent events pair with known silence + for (size_t i = 0; i < this->full_duplex_dma_desc_num_; i++) { + size_t bytes_loaded = 0; + err = i2s_channel_preload_data(this->tx_handle_, silence, buffer_bytes, &bytes_loaded); + if (err != ESP_OK || bytes_loaded != buffer_bytes) { + ESP_LOGE(TAG, "Full duplex TX silence preload failed: %s (%u/%u bytes)", esp_err_to_name(err), + (unsigned) bytes_loaded, (unsigned) buffer_bytes); + return err != ESP_OK ? err : ESP_ERR_INVALID_SIZE; + } + } + + err = i2s_channel_enable(this->tx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex TX clock enable failed: %s", esp_err_to_name(err)); + return err; + } + this->full_duplex_tx_enabled_ = true; + ESP_LOGD(TAG, "Full duplex TX clock is running"); + return ESP_OK; +} + +esp_err_t I2SAudioComponent::ensure_full_duplex_tx_running() { + LockGuard guard(this->full_duplex_lock_); + if (!this->full_duplex_ || this->tx_handle_ == nullptr || !this->tx_channel_initialized_) { + return ESP_ERR_INVALID_STATE; + } + if (this->full_duplex_tx_enabled_) { + return ESP_OK; + } + + const size_t buffer_bytes = this->get_full_duplex_dma_buffer_bytes_(); + if (buffer_bytes == 0) { + ESP_LOGE(TAG, "Full duplex TX clock start failed: no DMA buffer size available"); + return ESP_ERR_INVALID_SIZE; + } + + RAMAllocator allocator; + uint8_t *silence = allocator.allocate(buffer_bytes); + if (silence == nullptr) { + return ESP_ERR_NO_MEM; + } + memset(silence, 0, buffer_bytes); + esp_err_t err = this->start_full_duplex_tx_(silence, buffer_bytes); + allocator.deallocate(silence, buffer_bytes); + return err; +} + +esp_err_t I2SAudioComponent::attach_full_duplex_speaker(QueueHandle_t event_queue, QueueHandle_t records_queue, + EventGroupHandle_t event_group, EventBits_t overflow_bits, + const uint8_t *silence, size_t buffer_bytes) { + // Every descriptor finishes once before the speaker's first write reaches the wire, in both paths below + auto seed_records = [&]() -> bool { + const uint32_t zero_real_frames = 0; + for (size_t i = 0; i < this->full_duplex_dma_desc_num_; i++) { + if (xQueueSend(records_queue, &zero_real_frames, 0) != pdTRUE) { + return false; + } + } + return true; + }; + + { + LockGuard guard(this->full_duplex_lock_); + if (!this->full_duplex_ || this->tx_handle_ == nullptr || !this->tx_channel_initialized_) { + return ESP_ERR_INVALID_STATE; + } + + this->full_duplex_tx_event_group_ = event_group; + this->full_duplex_tx_overflow_bits_ = overflow_bits; + + if (!this->full_duplex_tx_enabled_) { + // Fresh start, identical to a simplex speaker: preload silence, then enable + if (!seed_records()) { + return ESP_ERR_NO_MEM; + } + this->full_duplex_tx_event_queue_.store(event_queue, std::memory_order_release); + esp_err_t err = this->start_full_duplex_tx_(silence, buffer_bytes); + if (err != ESP_OK) { + this->full_duplex_tx_event_queue_.store(nullptr, std::memory_order_release); + } + return err; + } + } + + // TX is already clocking the microphone, so its DMA ring holds buffers it has released but nobody refilled. + // A write would land in one of those and play early, skewing timestamps by up to a whole ring. Claim them all + // with silence so the next write waits for the buffer on the wire, the same position as a fresh preload. + // + // First let one full ring play out untouched. That fills the driver's release queue, and a full queue makes + // its next write start a fresh buffer, discarding any partial write position a previous session left behind. + // The lock is released meanwhile so microphone start/stop on the main loop isn't stalled; that's safe because + // TX is never disabled once enabled and only this speaker task writes to it. + vTaskDelay(pdMS_TO_TICKS(this->full_duplex_dma_desc_num_ * FULL_DUPLEX_DMA_BUFFER_DURATION_MS + 1)); + LockGuard guard(this->full_duplex_lock_); + + // Events that arrive meanwhile are discarded; the loop exits once nothing was released since the last reset + this->full_duplex_tx_event_queue_.store(event_queue, std::memory_order_release); + size_t bytes_written = 0; + do { + while (i2s_channel_write(this->tx_handle_, silence, buffer_bytes, &bytes_written, 0) == ESP_OK) { + } + xQueueReset(event_queue); + esp_rom_delay_us(FULL_DUPLEX_ISR_SETTLE_US); + } while (i2s_channel_write(this->tx_handle_, silence, buffer_bytes, &bytes_written, 0) == ESP_OK); + + if (!seed_records()) { + this->full_duplex_tx_event_queue_.store(nullptr, std::memory_order_release); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +void I2SAudioComponent::detach_full_duplex_tx_event_queue(QueueHandle_t queue) { + LockGuard guard(this->full_duplex_lock_); + QueueHandle_t expected = queue; + this->full_duplex_tx_event_queue_.compare_exchange_strong(expected, nullptr, std::memory_order_acq_rel); +} + +bool IRAM_ATTR I2SAudioComponent::full_duplex_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + (void) handle; + (void) event; + auto *component = static_cast(user_ctx); + QueueHandle_t queue = component->full_duplex_tx_event_queue_.load(std::memory_order_acquire); + if (queue == nullptr) { + return false; + } + + int64_t now = esp_timer_get_time(); + BaseType_t need_yield1 = pdFALSE; + BaseType_t need_yield2 = pdFALSE; + BaseType_t need_yield3 = pdFALSE; + + // Mirrors I2SAudioSpeakerBase::i2s_on_sent_cb: a dropped event desyncs the speaker's records, so it restarts + if (xQueueIsQueueFullFromISR(queue)) { + int64_t dummy; + xQueueReceiveFromISR(queue, &dummy, &need_yield1); + if (component->full_duplex_tx_event_group_ != nullptr && component->full_duplex_tx_overflow_bits_ != 0) { + xEventGroupSetBitsFromISR(component->full_duplex_tx_event_group_, component->full_duplex_tx_overflow_bits_, + &need_yield2); + } + } + + xQueueSendToBackFromISR(queue, &now, &need_yield3); + return need_yield1 | need_yield2 | need_yield3; +} + +void I2SAudioComponent::release_full_duplex_rx_channel(i2s_chan_handle_t rx_handle) { + LockGuard guard(this->full_duplex_lock_); + if (rx_handle == this->rx_handle_ && this->full_duplex_rx_enabled_) { + i2s_channel_disable(this->rx_handle_); + this->full_duplex_rx_enabled_ = false; + } +} + +void I2SAudioComponent::release_full_duplex_tx_channel(i2s_chan_handle_t tx_handle) { + (void) tx_handle; + // TX stays enabled: it clocks the external ADC, and stopping it would stall RX until playback resumes +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/i2s_audio.h b/Integrations/ESPHome/components/i2s_audio/i2s_audio.h new file mode 100644 index 0000000..97f6147 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/i2s_audio.h @@ -0,0 +1,148 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include +#include +#include +#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, + }}; + } + i2s_std_gpio_config_t get_full_duplex_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 = (gpio_num_t) this->dout_pin_, + .din = (gpio_num_t) this->din_pin_, + .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_din_pin(int pin) { this->din_pin_ = pin; } + void set_dout_pin(int pin) { this->dout_pin_ = pin; } + void set_port(int port) { this->port_ = port; } + void set_full_duplex(bool full_duplex) { this->full_duplex_ = full_duplex; } + bool is_full_duplex() const { return this->full_duplex_; } +#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(); } + + // Full duplex: TX and RX are allocated together and share one clock/slot config, so the first user's config + // wins and a mismatched second user is rejected. TX stays running once started, since it clocks the ADC. + esp_err_t setup_full_duplex_rx_channel(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *rx_handle); + esp_err_t setup_full_duplex_tx_channel(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, + i2s_chan_handle_t *tx_handle); + /// Starts the TX clock on silence if it isn't running yet (used by the microphone). + esp_err_t ensure_full_duplex_tx_running(); + /// Joins the TX stream for a speaker task. Leaves `records_queue` holding one zero-frame record per DMA buffer + /// that completes before the speaker's first write can play, then routes on_sent events into `event_queue`. + esp_err_t attach_full_duplex_speaker(QueueHandle_t event_queue, QueueHandle_t records_queue, + EventGroupHandle_t event_group, EventBits_t overflow_bits, + const uint8_t *silence, size_t buffer_bytes); + void detach_full_duplex_tx_event_queue(QueueHandle_t queue); + size_t get_full_duplex_dma_desc_num() const { return this->full_duplex_dma_desc_num_; } + void mark_full_duplex_rx_running() { this->full_duplex_rx_enabled_ = true; } + void release_full_duplex_rx_channel(i2s_chan_handle_t rx_handle); + void release_full_duplex_tx_channel(i2s_chan_handle_t tx_handle); + + protected: + esp_err_t allocate_full_duplex_channels_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg); + esp_err_t initialize_full_duplex_channels_(const i2s_std_config_t &std_cfg); + esp_err_t start_full_duplex_tx_(const uint8_t *silence, size_t buffer_bytes); + size_t get_full_duplex_dma_buffer_bytes_(); + static bool full_duplex_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + Mutex lock_; + // Serializes full duplex setup between the speaker and microphone tasks; lock_ is unused in full duplex mode + Mutex full_duplex_lock_; + + I2SAudioIn *audio_in_{nullptr}; + I2SAudioOut *audio_out_{nullptr}; + int mclk_pin_{I2S_GPIO_UNUSED}; + int bclk_pin_{I2S_GPIO_UNUSED}; + int din_pin_{I2S_GPIO_UNUSED}; + int dout_pin_{I2S_GPIO_UNUSED}; + int lrclk_pin_; + int port_{}; + bool full_duplex_{false}; + + i2s_chan_handle_t rx_handle_{nullptr}; + i2s_chan_handle_t tx_handle_{nullptr}; + bool rx_channel_initialized_{false}; + bool tx_channel_initialized_{false}; + bool full_duplex_tx_enabled_{false}; + bool full_duplex_rx_enabled_{false}; + bool full_duplex_tx_callback_registered_{false}; + size_t full_duplex_dma_desc_num_{0}; + // Format of the first user, which both channels were initialized with + uint32_t full_duplex_sample_rate_{0}; + uint32_t full_duplex_data_bit_width_{0}; + uint32_t full_duplex_slot_bit_width_{0}; + i2s_slot_mode_t full_duplex_slot_mode_{}; + // Read by the TX ISR; event group and bits are published before the queue + std::atomic full_duplex_tx_event_queue_{nullptr}; + EventGroupHandle_t full_duplex_tx_event_group_{nullptr}; + EventBits_t full_duplex_tx_overflow_bits_{0}; +}; + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/media_player/__init__.py b/Integrations/ESPHome/components/i2s_audio/media_player/__init__.py new file mode 100644 index 0000000..b366d4f --- /dev/null +++ b/Integrations/ESPHome/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/Integrations/ESPHome/components/i2s_audio/microphone/__init__.py b/Integrations/ESPHome/components/i2s_audio/microphone/__init__.py new file mode 100644 index 0000000..89e9991 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/microphone/__init__.py @@ -0,0 +1,162 @@ +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_AUDIO_ID, + 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])) + parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID]) + cg.add(parent.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/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp new file mode 100644 index 0000000..1ff4494 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -0,0 +1,503 @@ +#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_->is_full_duplex()) { + if (this->pdm_) { + ESP_LOGE(TAG, "Full duplex I2S microphone does not support PDM mode"); + return false; + } + + 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, + .intr_priority = 3, + }; + + 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 + + i2s_std_gpio_config_t pin_config = this->parent_->get_full_duplex_pin_config(); + pin_config.din = this->din_pin_; + + 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_; + + i2s_std_config_t std_cfg = { + .clk_cfg = clk_cfg, + .slot_cfg = std_slot_cfg, + .gpio_cfg = pin_config, + }; + + err = this->parent_->setup_full_duplex_rx_channel(chan_cfg, std_cfg, &this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error setting up full duplex RX channel: %s", esp_err_to_name(err)); + return false; + } + + err = this->parent_->ensure_full_duplex_tx_running(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error starting full duplex TX clock: %s", esp_err_to_name(err)); + return false; + } + + err = i2s_channel_enable(this->rx_handle_); + if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) { + this->parent_->mark_full_duplex_rx_running(); + } else { + ESP_LOGE(TAG, "Enabling full duplex RX failed: %s", esp_err_to_name(err)); + return false; + } + + this->configure_stream_settings_(); + return true; + } + + 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->parent_->is_full_duplex()) { + if (this->rx_handle_ != nullptr) { + this->parent_->release_full_duplex_rx_channel(this->rx_handle_); + this->rx_handle_ = nullptr; + } + return; + } + + 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/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.h b/Integrations/ESPHome/components/i2s_audio/microphone/i2s_audio_microphone.h new file mode 100644 index 0000000..5cdb526 --- /dev/null +++ b/Integrations/ESPHome/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_{nullptr}; + 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/Integrations/ESPHome/components/i2s_audio/speaker/__init__.py b/Integrations/ESPHome/components/i2s_audio/speaker/__init__.py new file mode 100644 index 0000000..7ba7a5e --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/__init__.py @@ -0,0 +1,277 @@ +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_AUDIO_ID, + 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" + +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, + } + ), + }, + 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[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])) + parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID]) + cg.add(parent.set_dout_pin(config[CONF_I2S_DOUT_PIN])) + + 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/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.cpp new file mode 100644 index 0000000..ed5145d --- /dev/null +++ b/Integrations/ESPHome/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/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.h b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_spdif.h new file mode 100644 index 0000000..ca77741 --- /dev/null +++ b/Integrations/ESPHome/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/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp new file mode 100644 index 0000000..62d4538 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -0,0 +1,405 @@ +#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; + } + + // 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_); + + // 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); + } + + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { + 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_(); + // 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: + 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"); + 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"); + 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) { + 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) { + 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) { + 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; + + 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; + + // 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 = ESP_OK; + if (this->parent_->is_full_duplex()) { + err = this->parent_->setup_full_duplex_tx_channel(chan_cfg, std_cfg, &this->tx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex TX setup failed: %s", esp_err_to_name(err)); + this->tx_handle_ = nullptr; + return err; + } + } else { + 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) { + this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); + } 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)"); + if (this->parent_->is_full_duplex()) { + this->parent_->release_full_duplex_tx_channel(this->tx_handle_); + } else { + i2s_del_channel(this->tx_handle_); + this->parent_->unlock(); + } + this->tx_handle_ = nullptr; + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +void I2SAudioSpeakerBase::stop_i2s_driver_() { + if (this->tx_handle_ != nullptr) { + if (this->parent_->is_full_duplex()) { + this->parent_->release_full_duplex_tx_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + return; + } + + i2s_channel_disable(this->tx_handle_); + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); + } + this->parent_->unlock(); +} + +bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { + int64_t now = esp_timer_get_time(); + + BaseType_t need_yield1 = pdFALSE; + BaseType_t need_yield2 = pdFALSE; + BaseType_t need_yield3 = pdFALSE; + + I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; + + if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { + // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` + // and any per-buffer record queue maintained by the task are permanently desynced, so the task + // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP + // (so the task bails immediately, closing the race where loop() could clear the error bit + // before the task observes it). + 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); + } + + xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); + + return need_yield1 | need_yield2 | need_yield3; +} + +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { + if (this->q31_volume_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, this->q31_volume_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 +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 diff --git a/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h new file mode 100644 index 0000000..adb6ca5 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -0,0 +1,172 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "../i2s_audio.h" + +#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; + + protected: + /// @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); + + /// @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/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp new file mode 100644 index 0000000..a7993e6 --- /dev/null +++ b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -0,0 +1,525 @@ +#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 bool full_duplex = this->parent_->is_full_duplex(); + 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 = 0; + 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 descriptors and is an exact multiple of their count. In full duplex the + // microphone may have allocated the shared channel, so its descriptor count applies instead. + const size_t dma_desc_num = + full_duplex ? this->parent_->get_full_duplex_dma_desc_num() : static_cast(DMA_BUFFERS_COUNT); + if (dma_desc_num > 0) { + dma_buffer_bytes = chan_info.total_dma_buf_size / dma_desc_num; + } + } + if (dma_buffer_bytes == 0) { + // 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_)); + } + // 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 && full_duplex) { + // The bus owns the shared TX channel and its on_sent callback, since TX may already be clocking the + // microphone. It seeds the write records to match wherever the DMA ring stands. + if (this->parent_->attach_full_duplex_speaker(this->i2s_event_queue_, this->write_records_queue_, + this->event_group_, + SpeakerEventGroupBits::ERR_DROPPED_EVENT | + SpeakerEventGroupBits::COMMAND_STOP, + silence_buffer, dma_buffer_bytes) != ESP_OK) { + ESP_LOGV(TAG, "Failed to join full duplex TX"); + successful_setup = false; + } + } else 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; + if (xQueueSend(this->write_records_queue_, &zero_real_frames, 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 && !full_duplex) { + // Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer + // generates a queued event that pairs with the first preloaded silence record. + const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; + i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); + + if (i2s_channel_enable(this->tx_handle_) != ESP_OK) { + ESP_LOGV(TAG, "Failed to enable I2S channel"); + successful_setup = false; + } + } + + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { + bool stop_gracefully = false; + // Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful + // stop to wait until every real-audio buffer has been confirmed played by an ISR event. + uint32_t pending_real_buffers = 0; + uint32_t last_data_received_time = millis(); + + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); + + // Main speaker task loop. Continues while: + // - Paused, OR + // - No timeout configured, OR + // - Timeout hasn't elapsed since last data + // + // Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio + // and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA + // consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``. + // + // While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence; + // the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS), + // so the lockstep invariant is preserved without burning CPU. + while (this->pause_state_ || !this->timeout_.has_value() || + (millis() - last_data_received_time) <= this->timeout_.value()) { + uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { + // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion + // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); + ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); + break; + } + if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) { + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); + stop_gracefully = true; + } + + if (this->audio_stream_info_ != this->current_stream_info_) { + // Audio stream info changed, stop the speaker task so it will restart with the proper settings. + ESP_LOGV(TAG, "Exiting: stream info changed"); + break; + } + + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ + // entry by construction (preloaded records at startup, plus exactly one record pushed per + // iteration alongside exactly one DMA-buffer-sized write). + int64_t write_timestamp; + bool lockstep_broken = false; + while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) { + uint32_t real_frames = 0; + if (xQueueReceive(this->write_records_queue_, &real_frames, 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; + } + if (real_frames > 0) { + pending_real_buffers--; + // Real audio is packed at the start of each DMA buffer with any silence padding on the + // tail, so the real audio finished playing earlier than the buffer-completion timestamp + // by the duration of the trailing zeros. + const uint32_t silence_frames = frames_per_dma_buffer - real_frames; + const int64_t adjusted_ts = + write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames); + this->audio_output_callback_(real_frames, adjusted_ts); + } + } + if (lockstep_broken) { + break; + } + + // 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. + 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); + if (bytes_read > 0) { + // 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(); + 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; + i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != output_bytes) { + // A short real-audio write breaks DMA descriptor alignment for every subsequent event; + // the only safe recovery is to restart the task. + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + partial_write_failure = true; + break; + } + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; + } + if (real_frames_total > 0) { + last_data_received_time = millis(); + } + } + + if (partial_write_failure) { + break; + } + + const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; + if (silence_bytes > 0) { + size_t bw = 0; + i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (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. + if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { + ESP_LOGV(TAG, "Exiting: write records queue full"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + break; + } + if (real_frames_total > 0) { + pending_real_buffers++; + } + } + } + + if (full_duplex) { + // TX keeps running for the microphone; stop routing its events to this task + this->parent_->detach_full_duplex_tx_event_queue(this->i2s_event_queue_); + } + + 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 + + // Full duplex shares the bus with the microphone instead of taking it exclusively + if (!this->parent_->is_full_duplex() && !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/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/Integrations/ESPHome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h new file mode 100644 index 0000000..4b52dcd --- /dev/null +++ b/Integrations/ESPHome/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/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.cpp b/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.cpp new file mode 100644 index 0000000..30146e0 --- /dev/null +++ b/Integrations/ESPHome/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/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.h b/Integrations/ESPHome/components/i2s_audio/speaker/spdif_encoder.h new file mode 100644 index 0000000..9e23a85 --- /dev/null +++ b/Integrations/ESPHome/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/Integrations/ESPHome/wizmote.yaml b/Integrations/ESPHome/wizmote.yaml new file mode 100644 index 0000000..37baa50 --- /dev/null +++ b/Integrations/ESPHome/wizmote.yaml @@ -0,0 +1,491 @@ +# WizMote remote (ESP-NOW); included by Core.yaml + +substitutions: + WIZMOTE_BUTTON_ON: "1" + WIZMOTE_BUTTON_OFF: "2" + WIZMOTE_BUTTON_NIGHT: "3" + WIZMOTE_BUTTON_BRIGHT_UP: "9" + WIZMOTE_BUTTON_BRIGHT_DOWN: "8" + WIZMOTE_BUTTON_1: "16" + WIZMOTE_BUTTON_2: "17" + WIZMOTE_BUTTON_3: "18" + WIZMOTE_BUTTON_4: "19" + WIZMOTE_MAC_ADDRESS: "00:00:00:00:00:00" + +globals: + - id: wizmote_last_sequence + type: uint32_t + restore_value: no + initial_value: '0' + - id: wizmote_previous_mac + type: std::string + restore_value: no + initial_value: '""' + - id: wizmote_last_packet_ms + type: uint32_t + restore_value: no + initial_value: '0' + - id: wizmote_scan_channel + type: uint8_t + restore_value: no + initial_value: '1' + +button: + - platform: template + name: "Clear WizMote Pairing" + id: wizmote_clear_button + icon: "mdi:close-circle" + entity_category: config + on_press: + - lambda: |- + std::string mac = id(wizmote_mac_address).state; + if (mac != "00:00:00:00:00:00" && mac != "" && mac.length() == 17) { + std::array mac_bytes; + int parsed = sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + if (parsed == 6) { + id(espnow_component).del_peer(mac_bytes.data()); + } + } + id(wizmote_mac_address).publish_state("00:00:00:00:00:00"); + - lambda: 'id(wizmote_status).update();' + +switch: + - platform: template + name: "WizMote Auto-Discovery" + id: wizmote_discovery_mode + icon: "mdi:radar" + entity_category: config + optimistic: true + restore_mode: ALWAYS_OFF + on_turn_on: + - lambda: 'id(wizmote_status).update();' + on_turn_off: + - lambda: 'id(wizmote_status).update();' + +# Remap buttons from HA; no YAML edits +select: + - platform: template + name: "WizMote On" + id: wizmote_action_on + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Play" + options: &wizmote_actions + - "Nothing" + - "Play" + - "Pause" + - "Play / Pause" + - "Next Track" + - "Previous Track" + - "Volume Up" + - "Volume Down" + - "Toggle Light" + - "Send HA Event" + - platform: template + name: "WizMote Off" + id: wizmote_action_off + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Pause" + options: *wizmote_actions + - platform: template + name: "WizMote Night" + id: wizmote_action_night + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Toggle Light" + options: *wizmote_actions + - platform: template + name: "WizMote Brightness Up" + id: wizmote_action_bright_up + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Volume Up" + options: *wizmote_actions + - platform: template + name: "WizMote Brightness Down" + id: wizmote_action_bright_down + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Volume Down" + options: *wizmote_actions + - platform: template + name: "WizMote Button 1" + id: wizmote_action_btn1 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Previous Track" + options: *wizmote_actions + - platform: template + name: "WizMote Button 2" + id: wizmote_action_btn2 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Next Track" + options: *wizmote_actions + - platform: template + name: "WizMote Button 3" + id: wizmote_action_btn3 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Send HA Event" + options: *wizmote_actions + - platform: template + name: "WizMote Button 4" + id: wizmote_action_btn4 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Send HA Event" + options: *wizmote_actions + +text_sensor: + - platform: template + name: "WizMote Status" + id: wizmote_status + icon: "mdi:information" + entity_category: diagnostic + update_interval: 300s + lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool discovery = id(wizmote_discovery_mode).state; + if (discovery) { + return std::string("Discovery mode active"); + } else if (mac == "00:00:00:00:00:00" || mac == "") { + return std::string("No WizMote paired"); + } else { + return std::string("Paired: " + mac); + } + +text: + - platform: template + name: "WizMote MAC Address" + id: wizmote_mac_address + icon: "mdi:remote" + entity_category: config + initial_value: "${WIZMOTE_MAC_ADDRESS}" + optimistic: true + restore_value: true + mode: text + min_length: 17 + max_length: 17 + on_value: + - lambda: |- + std::string mac = x; + bool valid = true; + for (int i = 0; i < 17; i++) { + if (i % 3 == 2) { if (mac[i] != ':') { valid = false; break; } } + else { if (!std::isxdigit(mac[i])) { valid = false; break; } } + } + if (!valid) { + ESP_LOGW("wizmote", "Invalid MAC: %s", mac.c_str()); + return; + } + - if: + condition: + lambda: 'return (id(wizmote_previous_mac) != "" && id(wizmote_previous_mac) != "00:00:00:00:00:00");' + then: + - espnow.peer.delete: + address: !lambda |- + std::string mac = id(wizmote_previous_mac); + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; + - if: + condition: + lambda: 'return (x != "00:00:00:00:00:00" && x != "");' + then: + - espnow.peer.add: + address: !lambda |- + std::string mac = x; + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; + - lambda: 'id(wizmote_previous_mac) = x;' + - lambda: 'id(wizmote_status).update();' + +script: + - id: run_wizmote_action + parameters: + action: string + button: string + then: + - logger.log: + tag: wizmote + format: "button %s -> %s" + args: ['button.c_str()', 'action.c_str()'] + - if: + condition: + lambda: 'return action == "Play";' + then: + - media_player.play: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Pause";' + then: + - media_player.pause: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Play / Pause";' + then: + - media_player.toggle: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Next Track";' + then: + - media_player.next: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Previous Track";' + then: + - media_player.previous: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Volume Up";' + then: + - media_player.volume_up: external_media_player + - logger.log: + tag: wizmote + format: " volume now %.0f%%" + args: ['id(external_media_player).volume * 100.0f'] + - if: + condition: + lambda: 'return action == "Volume Down";' + then: + - media_player.volume_down: external_media_player + - logger.log: + tag: wizmote + format: " volume now %.0f%%" + args: ['id(external_media_player).volume * 100.0f'] + - if: + condition: + lambda: 'return action == "Toggle Light";' + then: + - light.toggle: rgb_light + - if: + condition: + lambda: 'return action == "Send HA Event";' + then: + - homeassistant.event: + event: esphome.cast1_wizmote_event + data: + button: !lambda 'return button;' + + - id: process_wizmote_button + parameters: + button: int + then: + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_ON};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_on).current_option();' + button: "on" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_OFF};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_off).current_option();' + button: "off" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_NIGHT};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_night).current_option();' + button: "night" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_BRIGHT_UP};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_bright_up).current_option();' + button: "brightness_up" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_BRIGHT_DOWN};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_bright_down).current_option();' + button: "brightness_down" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_1};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn1).current_option();' + button: "1" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_2};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn2).current_option();' + button: "2" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_3};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn3).current_option();' + button: "3" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_4};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn4).current_option();' + button: "4" + - if: + condition: + lambda: |- + return button != ${WIZMOTE_BUTTON_ON} && button != ${WIZMOTE_BUTTON_OFF} && + button != ${WIZMOTE_BUTTON_NIGHT} && button != ${WIZMOTE_BUTTON_BRIGHT_UP} && + button != ${WIZMOTE_BUTTON_BRIGHT_DOWN} && button != ${WIZMOTE_BUTTON_1} && + button != ${WIZMOTE_BUTTON_2} && button != ${WIZMOTE_BUTTON_3} && + button != ${WIZMOTE_BUTTON_4}; + then: + - logger.log: + tag: wizmote + level: WARN + format: "unmapped button %d" + args: ['button'] + +# Channel scan; skipped while WiFi pins the channel +interval: + - id: wizmote_channel_scan + interval: 2s + then: + - if: + condition: + and: + - not: + wifi.enabled: + - lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool want = id(wizmote_discovery_mode).state || + (mac != "00:00:00:00:00:00" && mac != ""); + return want && (millis() - id(wizmote_last_packet_ms) > 30000); + then: + - lambda: |- + id(wizmote_scan_channel)++; + if (id(wizmote_scan_channel) > 13) id(wizmote_scan_channel) = 1; + - espnow.set_channel: + channel: !lambda 'return id(wizmote_scan_channel);' + - logger.log: + format: "WizMote channel scan: trying channel %d" + args: ['id(wizmote_scan_channel)'] + +espnow: + id: espnow_component + auto_add_peer: false + enable_on_boot: true + on_unknown_peer: + then: + - lambda: |- + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", + info.src_addr[0], info.src_addr[1], info.src_addr[2], + info.src_addr[3], info.src_addr[4], info.src_addr[5]); + std::string sender_mac(mac_str); + + if (size != 13) return; + + uint8_t button = data[12]; + bool discovery_mode = id(wizmote_discovery_mode).state; + + if (discovery_mode) { + ESP_LOGI("wizmote", "Discovery: WizMote MAC: %s, Button: %d", sender_mac.c_str(), button); + std::string current_mac = id(wizmote_mac_address).state; + if (current_mac == "00:00:00:00:00:00" || current_mac == "" || current_mac != sender_mac) { + auto call = id(wizmote_mac_address).make_call(); + call.set_value(sender_mac); + call.perform(); + id(wizmote_discovery_mode).turn_off(); + } + return; + } + + ESP_LOGI("wizmote", "Unknown WizMote: %s (enable discovery mode to pair)", sender_mac.c_str()); + on_broadcast: + then: + - lambda: |- + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", + info.src_addr[0], info.src_addr[1], info.src_addr[2], + info.src_addr[3], info.src_addr[4], info.src_addr[5]); + std::string sender_mac(mac_str); + + if (size != 13) return; + + std::string configured_mac = id(wizmote_mac_address).state; + std::transform(configured_mac.begin(), configured_mac.end(), configured_mac.begin(), ::tolower); + + if (configured_mac == "00:00:00:00:00:00" || configured_mac == "" || sender_mac != configured_mac) return; + + // Lets ETH channel-scan lock on + id(wizmote_last_packet_ms) = millis(); + + uint32_t sequence = (data[4] << 24) | (data[3] << 16) | (data[2] << 8) | data[1]; + uint8_t button = data[6]; + + if (sequence == id(wizmote_last_sequence)) return; + id(wizmote_last_sequence) = sequence; + + ESP_LOGI("wizmote", "Button: %d seq=%d from %s", button, sequence, sender_mac.c_str()); + id(process_wizmote_button).execute(button); + +esphome: + on_boot: + - priority: -100 + then: + - delay: 2s + - if: + condition: + lambda: |- + std::string mac = id(wizmote_mac_address).state; + return (mac != "00:00:00:00:00:00" && mac != ""); + then: + - logger.log: "Restoring WizMote pairing on boot..." + - espnow.peer.add: + address: !lambda |- + std::string mac = id(wizmote_mac_address).state; + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes;