diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30c0c67..bcc108cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -600,6 +600,12 @@ jobs: cmake --build .codex-build/menu-widgets --parallel 2 ctest --test-dir .codex-build/menu-widgets --output-on-failure + - name: Verify music, audio focus and device control ownership + run: | + cmake -S tests/music -B .codex-build/music -G Ninja -DCMAKE_BUILD_TYPE=Debug -DLVGL_DIR="$PWD/.codex-build/p4-keyboard-deps/lvgl" + cmake --build .codex-build/music --parallel 2 + ctest --test-dir .codex-build/music --output-on-failure + p4_rendered_ui: name: T-Display P4 Rendered UI runs-on: ubuntu-latest @@ -626,6 +632,8 @@ jobs: cmake --build .codex-build/p4-preview/build --target trailmate_p4_tft trailmate_p4_amoled trailmate_pager trailmate_tdeck trailmate_wio --parallel 2 P4_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/p4-native-ui.test.mjs WIDGET_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/menu-widgets-native.test.mjs + MUSIC_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/music-native-ui.test.mjs + WIDGET_UI_ARTIFACT_DIR=.codex-build/p4-preview/artifacts node tests/site/quick-controls-native.test.mjs - name: Retain actual LVGL frames and layout coordinates if: always() uses: actions/upload-artifact@v4 diff --git a/boards/t_display_p4/include/boards/t_display_p4/heading_runtime.h b/boards/t_display_p4/include/boards/t_display_p4/heading_runtime.h index 6c94d2bd..1a47e531 100644 --- a/boards/t_display_p4/include/boards/t_display_p4/heading_runtime.h +++ b/boards/t_display_p4/include/boards/t_display_p4/heading_runtime.h @@ -8,6 +8,8 @@ namespace boards::t_display_p4::heading_runtime using HeadingState = ::platform::ui::orientation::HeadingState; void ensure_started(); +bool acquire(); +void release(); HeadingState get_data(); } // namespace boards::t_display_p4::heading_runtime diff --git a/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h b/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h index 02bb5250..1b335114 100644 --- a/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h +++ b/boards/t_display_p4/include/boards/t_display_p4/t_display_p4_board.h @@ -201,6 +201,7 @@ class TDisplayP4Board final : public BoardBase, public LoraBoard bool isRadioOnline() const override; int transmitRadio(const uint8_t* data, size_t len) override; int startRadioReceive() override; + bool quiesceForExternalStorage() override; uint32_t getRadioIrqFlags() override; int getRadioPacketLength(bool update) override; int readRadioData(uint8_t* buf, size_t len) override; diff --git a/boards/t_display_p4/src/heading_runtime.cpp b/boards/t_display_p4/src/heading_runtime.cpp index 4dd5a31d..e23cc4be 100644 --- a/boards/t_display_p4/src/heading_runtime.cpp +++ b/boards/t_display_p4/src/heading_runtime.cpp @@ -51,6 +51,8 @@ struct RuntimeState std::mutex mutex; HeadingState data{}; bool started = false; + bool pinned = false; + uint32_t consumers = 0; bool sensor_ready = false; uint32_t retry_backoff_ms = 1000; uint32_t next_retry_ms = 0; @@ -269,10 +271,56 @@ void mark_stale_if_needed() } } +void stop_sensor() +{ + // These device handles belong exclusively to the heading driver. Leave + // the external 3V3 rail available to GNSS, radio, keyboard and expansion. + if (g_runtime.magnetometer) + { + (void)write_register(g_runtime.magnetometer, kAkControl2, 0); + (void)i2c_master_bus_rm_device(g_runtime.magnetometer); + g_runtime.magnetometer = nullptr; + } + if (g_runtime.icm) + { + (void)select_bank(0); + (void)write_register(g_runtime.icm, kPowerManagement1, 0x40); + (void)i2c_master_bus_rm_device(g_runtime.icm); + g_runtime.icm = nullptr; + } + std::lock_guard lock(g_runtime.mutex); + g_runtime.sensor_ready = false; + g_runtime.data = {}; + g_runtime.next_retry_ms = 0; + g_runtime.retry_backoff_ms = 1000; +} + void task_main(void*) { while (true) { + bool wanted = false; + { + std::lock_guard lock(g_runtime.mutex); + wanted = g_runtime.pinned || g_runtime.consumers; + } + if (!wanted) + { + stop_sensor(); + bool exiting = false; + { + std::lock_guard lock(g_runtime.mutex); + // Re-entering during cleanup reuses this task and reprobes. + exiting = !g_runtime.pinned && !g_runtime.consumers; + if (exiting) + { + g_runtime.task = nullptr; + g_runtime.started = false; + } + } + if (exiting) break; + continue; + } if (ensure_sensor_ready()) { if (!sample_once()) @@ -291,24 +339,48 @@ void task_main(void*) } vTaskDelay(pdMS_TO_TICKS(kSampleIntervalMs)); } + vTaskDelete(nullptr); } } // namespace void ensure_started() +{ + // Compatibility for a permanent non-widget consumer. Widget users must + // use acquire/release so leaving a page does not retain this worker. + { + std::lock_guard lock(g_runtime.mutex); + if (g_runtime.pinned) return; + g_runtime.pinned = true; + } + (void)acquire(); +} + +bool acquire() { std::lock_guard lock(g_runtime.mutex); + ++g_runtime.consumers; if (g_runtime.started) { - return; + return true; } g_runtime.started = true; if (xTaskCreate(task_main, "p4_heading", 4096, nullptr, 3, &g_runtime.task) != pdPASS) { g_runtime.started = false; g_runtime.task = nullptr; + --g_runtime.consumers; + g_runtime.pinned = false; ESP_LOGE(kTag, "Failed to create heading task"); + return false; } + return true; +} + +void release() +{ + std::lock_guard lock(g_runtime.mutex); + if (g_runtime.consumers) --g_runtime.consumers; } HeadingState get_data() @@ -325,6 +397,8 @@ namespace boards::t_display_p4::heading_runtime { void ensure_started() {} +bool acquire() { return false; } +void release() {} HeadingState get_data() { diff --git a/boards/t_display_p4/src/t_display_p4_board.cpp b/boards/t_display_p4/src/t_display_p4_board.cpp index e7dc8a06..4d7f62bf 100644 --- a/boards/t_display_p4/src/t_display_p4_board.cpp +++ b/boards/t_display_p4/src/t_display_p4_board.cpp @@ -1473,6 +1473,11 @@ bool TDisplayP4Board::isRadioOnline() const return ensureRadioReady() && radio().isOnline(); } +bool TDisplayP4Board::quiesceForExternalStorage() +{ + return !radio().isOnline() || radio().standby(); +} + int TDisplayP4Board::transmitRadio(const uint8_t* data, size_t len) { if (!ensureRadioReady()) diff --git a/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h b/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h index 7bf07c5f..33ebef85 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h @@ -26,6 +26,9 @@ class TDeckAudioRuntime std::size_t encoded_media_len, uint8_t volume_percent); bool isReady() const; + bool beginMusic(uint32_t sample_rate); + bool writeMusic(const int16_t* mono, std::size_t samples); + void endMusic(); private: static constexpr std::size_t kPcmSampleCount = 128U * 2U; @@ -39,6 +42,7 @@ class TDeckAudioRuntime std::atomic faulted_{false}; std::atomic volume_percent_{45}; std::atomic_flag audio_busy_ = ATOMIC_FLAG_INIT; + bool music_active_ = false; // Only the single music worker accesses its session. std::array pcm_{}; }; diff --git a/boards/tdeck/include/boards/tdeck/tdeck_board.h b/boards/tdeck/include/boards/tdeck/tdeck_board.h index aa19bb8b..75444f20 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_board.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_board.h @@ -77,6 +77,7 @@ class TDeckBoard : public BoardBase, /** @brief True after the board's I2S speaker runtime is available. */ bool isVoicePlaybackReady() const; + TDeckAudioRuntime& audioRuntime() { return audio_runtime_; } /** @brief Plays one Codec2-1300 voice-message object through the speaker. */ bool playCodec2Voice(const uint8_t* encoded_media, diff --git a/boards/tdeck/src/tdeck_audio_runtime.cpp b/boards/tdeck/src/tdeck_audio_runtime.cpp index 4064fe2b..07fb629f 100644 --- a/boards/tdeck/src/tdeck_audio_runtime.cpp +++ b/boards/tdeck/src/tdeck_audio_runtime.cpp @@ -8,6 +8,7 @@ #include #include "pins_arduino.h" +#include "platform/audio/audio_focus.h" #include "platform/esp/common/memory_budget.h" #include "platform/ui/audio/pager_notification_tone.h" @@ -266,6 +267,8 @@ bool TDeckAudioRuntime::playCodec2Voice(const uint8_t* encoded_media, std::printf("[TDeck][Audio] voice rejected reason=memory_budget\n"); return false; } + platform::audio::FocusScope focus(platform::audio::FocusOwner::VoiceMessage); + if (!focus) return false; if (audio_busy_.test_and_set(std::memory_order_acquire)) { std::printf("[TDeck][Audio] voice rejected reason=audio_busy\n"); @@ -391,6 +394,39 @@ bool TDeckAudioRuntime::isReady() const return ready_.load(std::memory_order_acquire) && !faulted_.load(std::memory_order_acquire); } +bool TDeckAudioRuntime::beginMusic(uint32_t sample_rate) +{ + if (!isReady() || platform::audio::music_blocked() || music_active_ || audio_busy_.test_and_set(std::memory_order_acquire)) return false; + if (i2s_set_sample_rates(kSpeakerI2sPort, sample_rate) != ESP_OK) + { + audio_busy_.clear(std::memory_order_release); + return false; + } + music_active_ = true; + return true; +} +bool TDeckAudioRuntime::writeMusic(const int16_t* mono, std::size_t samples) +{ + if (!music_active_ || !mono) return false; + for (std::size_t offset = 0; offset < samples;) + { + const auto count = std::min(kFramesPerChunk, samples - offset); + for (std::size_t i = 0; i < count; i++) pcm_[2 * i] = pcm_[2 * i + 1] = mono[offset + i]; + std::size_t written = 0; + const auto bytes = count * 2 * sizeof(int16_t); + if (i2s_write(kSpeakerI2sPort, pcm_.data(), bytes, &written, pdMS_TO_TICKS(100)) != ESP_OK || written != bytes) return false; + offset += count; + } + return true; +} +void TDeckAudioRuntime::endMusic() +{ + if (!music_active_) return; + i2s_zero_dma_buffer(kSpeakerI2sPort); + (void)i2s_set_sample_rates(kSpeakerI2sPort, ::platform::ui::audio::pager_notification::kPlaybackSampleRateHz); + music_active_ = false; + audio_busy_.clear(std::memory_order_release); +} void TDeckAudioRuntime::taskEntry(void* context) { @@ -411,6 +447,8 @@ void TDeckAudioRuntime::taskLoop() { continue; } + platform::audio::FocusScope focus(platform::audio::FocusOwner::Notification); + if (!focus) continue; if (audio_busy_.test_and_set(std::memory_order_acquire)) { std::printf("[TDeck][Audio] tone dropped voice_playback_active\n"); diff --git a/boards/tdeck/src/tdeck_board.cpp b/boards/tdeck/src/tdeck_board.cpp index 0b8cba04..f1e9c915 100644 --- a/boards/tdeck/src/tdeck_board.cpp +++ b/boards/tdeck/src/tdeck_board.cpp @@ -6,6 +6,7 @@ #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/esp/boards/board_runtime.h" #include "platform/esp/common/shared_spi_coordinator.h" +#include "platform/radio/radio_power.h" #include "sys/bus_access_scope.h" #include #include @@ -1102,6 +1103,7 @@ bool TDeckBoard::syncTimeFromGPS(uint32_t gps_task_interval_ms) int TDeckBoard::transmitRadio(const uint8_t* data, size_t len) { + if (!platform::radio::power_policy().enabled()) return RADIOLIB_ERR_SPI_WRITE_FAILED; const uint32_t timeout_ms = radio_tx_timeout_ms(radio_, len); int rc = RADIOLIB_ERR_SPI_WRITE_FAILED; if (withSharedSpiRadioAccess("radio_tx", pdMS_TO_TICKS(50), [&]() @@ -1141,6 +1143,7 @@ int TDeckBoard::transmitRadio(const uint8_t* data, size_t len) int TDeckBoard::startRadioReceive() { + if (!platform::radio::power_policy().enabled()) return RADIOLIB_ERR_SPI_WRITE_FAILED; int rc = RADIOLIB_ERR_SPI_WRITE_FAILED; if (withSharedSpiRadioAccess("radio_rx", pdMS_TO_TICKS(50), [&]() { rc = radio_.startReceive(); })) diff --git a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h index 2669b3e1..08ef80b6 100644 --- a/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h +++ b/boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h @@ -79,6 +79,7 @@ enum class PagerAudioOwner : uint8_t VoiceMessage, Walkie, Sstv, + Music, }; /** diff --git a/boards/tlora_pager/src/tlora_pager_board.cpp b/boards/tlora_pager/src/tlora_pager_board.cpp index 1af738b0..d785f149 100644 --- a/boards/tlora_pager/src/tlora_pager_board.cpp +++ b/boards/tlora_pager/src/tlora_pager_board.cpp @@ -1,6 +1,7 @@ #if defined(ARDUINO_T_LORA_PAGER) #include "boards/tlora_pager/tlora_pager_board.h" #include "board/sd_utils.h" +#include "platform/radio/radio_power.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" @@ -18,6 +19,7 @@ #include "display/drivers/ST7796.h" #include "pins_arduino.h" +#include "platform/audio/audio_focus.h" #include "platform/esp/arduino_common/power/battery_adc.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/esp/boards/board_runtime.h" @@ -85,6 +87,7 @@ static uint32_t s_last_radio_spi_lock_timeout_log_ms = 0; static uint32_t s_suppressed_radio_spi_lock_timeout_logs = 0; static portMUX_TYPE s_audio_owner_mux = portMUX_INITIALIZER_UNLOCKED; static PagerAudioOwner s_audio_owner = PagerAudioOwner::None; +static platform::audio::FocusToken s_audio_focus; const char* audioOwnerLabel(PagerAudioOwner owner) { @@ -102,6 +105,8 @@ const char* audioOwnerLabel(PagerAudioOwner owner) return "walkie"; case PagerAudioOwner::Sstv: return "sstv"; + case PagerAudioOwner::Music: + return "music"; case PagerAudioOwner::None: default: return "none"; @@ -123,13 +128,30 @@ bool claimAudioOwner(PagerAudioOwner owner) return false; } + platform::audio::FocusToken focus; + if (owner == PagerAudioOwner::Music) + { + if (platform::audio::music_blocked()) return false; + } + else + { + using platform::audio::FocusOwner; + const auto priority = owner == PagerAudioOwner::VoiceMessage ? FocusOwner::VoiceMessage : owner == PagerAudioOwner::Walkie ? FocusOwner::Walkie + : owner == PagerAudioOwner::ReticulumCall || owner == PagerAudioOwner::IncomingCallTone ? FocusOwner::Call + : owner == PagerAudioOwner::Sstv ? FocusOwner::Sstv + : FocusOwner::Notification; + focus = platform::audio::request_focus(priority); + if (!focus) return false; + } portENTER_CRITICAL(&s_audio_owner_mux); const bool claimed = s_audio_owner == PagerAudioOwner::None; if (claimed) { s_audio_owner = owner; + s_audio_focus = focus; } portEXIT_CRITICAL(&s_audio_owner_mux); + if (!claimed) platform::audio::release_focus(focus); return claimed; } @@ -140,12 +162,16 @@ bool audioOwnerMatches(PagerAudioOwner owner) void releaseAudioOwner(PagerAudioOwner owner) { + platform::audio::FocusToken focus; portENTER_CRITICAL(&s_audio_owner_mux); if (s_audio_owner == owner) { s_audio_owner = PagerAudioOwner::None; + focus = s_audio_focus; + s_audio_focus = {}; } portEXIT_CRITICAL(&s_audio_owner_mux); + platform::audio::release_focus(focus); } // Temperature state (from BQ27220), used for safety / UI hints / brightness caps. @@ -1761,6 +1787,7 @@ bool TLoRaPagerBoard::pushColorsResult(uint16_t x1, int TLoRaPagerBoard::transmitRadio(const uint8_t* data, size_t len) { + if (!platform::radio::power_policy().enabled()) return RADIOLIB_ERR_SPI_WRITE_FAILED; const uint32_t timeout_ms = radio_tx_timeout_ms(radio_, len); int rc = RADIOLIB_ERR_SPI_WRITE_FAILED; if (withSharedSpiRadioAccess("radio_tx", pdMS_TO_TICKS(50), [&]() @@ -1816,6 +1843,7 @@ bool TLoRaPagerBoard::quiesceForExternalStorage() int TLoRaPagerBoard::startRadioReceive() { + if (!platform::radio::power_policy().enabled()) return RADIOLIB_ERR_SPI_WRITE_FAILED; int rc = RADIOLIB_ERR_SPI_WRITE_FAILED; if (withSharedSpiRadioAccess("radio_rx", pdMS_TO_TICKS(50), [&]() { rc = radio_.startReceive(); })) diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index dc3b6da5..8fef4b53 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -33,6 +33,10 @@ set(TRAILMATE_ESP_IDF_CORE_HOSTLINK_SOURCES "${TRAILMATE_ROOT}/modules/core_hostlink/src/c6_frame_codec.cpp") set(TRAILMATE_ESP_IDF_CORE_SYS_SOURCES + "${TRAILMATE_ROOT}/platform/esp/common/src/resource_lock.cpp" + "${TRAILMATE_ROOT}/modules/core_sys/src/platform/audio/media_volume.cpp" + "${TRAILMATE_ROOT}/modules/core_sys/src/platform/radio/radio_power.cpp" + "${TRAILMATE_ROOT}/modules/core_sys/src/platform/audio/audio_focus.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/app/app_facade_access.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/app/tms_config_codec.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/platform/ui/screen_power_state_machine.cpp" @@ -40,6 +44,29 @@ set(TRAILMATE_ESP_IDF_CORE_SYS_SOURCES "${TRAILMATE_ROOT}/modules/core_sys/src/sys/clock.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/platform/ui/timezone_profile.cpp") +set(TRAILMATE_ESP_IDF_AGENDA_WIDGET_SOURCES + "${TRAILMATE_ROOT}/platform/esp/common/src/agenda_widget_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/common/src/storage/agenda_file_store.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/agenda_clock.cpp" + "${TRAILMATE_ROOT}/modules/core_agenda/src/agenda_query.cpp" + "${TRAILMATE_ROOT}/modules/core_agenda/src/agenda_service.cpp" + "${TRAILMATE_ROOT}/modules/core_agenda/src/event_codec.cpp" + "${TRAILMATE_ROOT}/modules/core_agenda/src/recurrence.cpp") + +set(TRAILMATE_ESP_IDF_CORE_MUSIC_SOURCES + "${TRAILMATE_ROOT}/platform/esp/common/src/quick_controls_runtime.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/catalog.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/decoder.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/id3.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/lyrics.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/player.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/playlists.cpp" + "${TRAILMATE_ROOT}/modules/core_music/src/storage.cpp" + "${TRAILMATE_ROOT}/modules/music_image/src/cover_decoder.cpp" + "${TRAILMATE_ROOT}/platform/esp/common/src/music/music_audio.cpp" + "${TRAILMATE_ROOT}/platform/esp/common/src/music/music_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/common/src/music/music_storage.cpp") + set(TRAILMATE_ESP_IDF_CORE_DEVICE_SOURCES "${TRAILMATE_ROOT}/modules/core_device/src/device/capability_types.cpp") @@ -163,6 +190,13 @@ set(TRAILMATE_ESP_IDF_CORE_PHONE_SOURCES "${TRAILMATE_ROOT}/modules/core_phone/src/meshtastic/meshtastic_phone_session.cpp") set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/assets/music.c" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/music/music_state.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/music/music_page_input.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/assets/Chat.c" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/assets/alert.c" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/assets/AreaCleared.c" @@ -641,6 +675,8 @@ set(TRAILMATE_ESP_IDF_FINAL_INCLUDE_DIRS "${TRAILMATE_ROOT}/modules/core_track_sharing/include" "${TRAILMATE_ROOT}/modules/core_sys/include" "${TRAILMATE_ROOT}/modules/core_agenda/include" + "${TRAILMATE_ROOT}/modules/core_music/include" + "${TRAILMATE_ROOT}/modules/music_image/include" "${TRAILMATE_ROOT}/modules/core_chat/include" "${TRAILMATE_ROOT}/modules/core_chat/generated" "${TRAILMATE_ROOT}/modules/core_chat/third_party/nanopb" diff --git a/builds/esp_idf/main/CMakeLists.txt b/builds/esp_idf/main/CMakeLists.txt index 7d389d21..14f5b72b 100644 --- a/builds/esp_idf/main/CMakeLists.txt +++ b/builds/esp_idf/main/CMakeLists.txt @@ -44,6 +44,8 @@ idf_component_register( ${TRAILMATE_ESP_IDF_PRODUCT_COMPOSITION_SOURCES} ${TRAILMATE_ESP_IDF_CORE_HOSTLINK_SOURCES} ${TRAILMATE_ESP_IDF_CORE_SYS_SOURCES} + ${TRAILMATE_ESP_IDF_CORE_MUSIC_SOURCES} + ${TRAILMATE_ESP_IDF_AGENDA_WIDGET_SOURCES} ${TRAILMATE_ESP_IDF_CORE_DEVICE_SOURCES} ${TRAILMATE_ESP_IDF_CORE_GPS_SOURCES} ${TRAILMATE_ESP_IDF_CORE_CHAT_SOURCES} diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 9dd2903b..4b51ba5e 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -163,6 +163,7 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/map_diagnostics.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/hostlink_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/lora_runtime.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/resource_lock.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/orientation_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/pack_repository_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/route_storage.cpp" @@ -560,6 +561,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES # --------------------------------------------------------------------------- set(TRAIL_MATE_LINUX_COMMON_INCLUDES + "${TRAIL_MATE_REPO_ROOT}/modules/core_music/include" "${TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT}" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}" "${TRAIL_MATE_CORE_CHAT_INCLUDE_ROOT}" @@ -588,6 +590,7 @@ set(TRAIL_MATE_LINUX_COMMON_INCLUDES ) set(TRAIL_MATE_LINUX_UI_SHELL_INCLUDES + "${TRAIL_MATE_REPO_ROOT}/modules/core_music/include" "${TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT}" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}" "${TRAIL_MATE_CORE_CHAT_INCLUDE_ROOT}" diff --git a/docs/images/music/p4-music-widget.png b/docs/images/music/p4-music-widget.png new file mode 100644 index 00000000..deb94905 Binary files /dev/null and b/docs/images/music/p4-music-widget.png differ diff --git a/docs/images/music/p4-quick-controls.png b/docs/images/music/p4-quick-controls.png new file mode 100644 index 00000000..f4389fcb Binary files /dev/null and b/docs/images/music/p4-quick-controls.png differ diff --git a/docs/images/music/pager-now-playing.png b/docs/images/music/pager-now-playing.png new file mode 100644 index 00000000..f80985b8 Binary files /dev/null and b/docs/images/music/pager-now-playing.png differ diff --git a/docs/images/music/pager-quick-controls.png b/docs/images/music/pager-quick-controls.png new file mode 100644 index 00000000..384008b9 Binary files /dev/null and b/docs/images/music/pager-quick-controls.png differ diff --git a/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md b/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md index 42d29509..660358e5 100644 --- a/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md +++ b/docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md @@ -2,7 +2,7 @@ 由 scripts/audit_nvs_access.mjs 扫描 ESP、共享 UI 和仓库 third_party 的 C/C++ 文件。只记录 NVS 函数、namespace/key/handle 实参和位置,不读取设备、不记录值/默认值/缓冲/分区名。不把最近的 nvs_open 自动推断为后续调用的 namespace;跨作用域句柄、宏和条件编译仍需人工核对。 -扫描 1536 个源文件,找到 106 个候选调用,分布于 8 个文件。显式打开 15,键访问 45,其余为提交/关闭/清域。调用数不是独立配置数。 +扫描 1553 个源文件,找到 106 个候选调用,分布于 8 个文件。显式打开 15,键访问 45,其余为提交/关闭/清域。调用数不是独立配置数。 这是源码入口清单,不是全部配置或硬件生效证明。系统 SDK、构建环境依赖、其他平台、函数指针/包装器实参以及复杂原始字符串不由此表自动覆盖。依赖库可单独调用 collectNvsAccesses 指定根目录审计;输出应单独记录其版本,不能写进与机器无关的仓库清单。 diff --git a/docs/uiux/components/menu_widgets.md b/docs/uiux/components/menu_widgets.md index 914d3ead..44744f29 100644 --- a/docs/uiux/components/menu_widgets.md +++ b/docs/uiux/components/menu_widgets.md @@ -25,9 +25,9 @@ whole rows and centers them vertically between the top and bottom bars. | P4 right dashboard | Swipe left/right | Next/previous widget page | The negative screen shows exactly one widget: Mesh, GPS, Recent Activity, -Compass or Agenda. Navigation stops at the first/last widget. P4 keeps the +Compass, Agenda, Music or Quick controls. Navigation stops at the first/last widget. P4 keeps the left launcher and displays four status widgets on its first right-hand page, -then Agenda on the second. Footer controls provide a visible alternative to +then Agenda, Music and Quick controls on separate pages. Footer controls provide a visible alternative to gestures. Widget browsing does not activate a background launcher icon. A drag is owned by its original menu gesture until physical release. Leaving @@ -44,11 +44,15 @@ sleep scenes and modal focus groups retain their own input ownership. - `menu_dashboard.cpp` owns selection, arrangement, gestures and one refresh timer. `dashboard_state.*` retains only this container's state in PSRAM. - Widgets read the existing GPS, messaging, mesh, orientation and Agenda ports. - They create no service tasks, persistent stores or duplicate background caches. -- The target composition explicitly binds its `IAgendaSource`. T-Deck, L2 and - Pager bind the existing Agenda model. P4 currently has no Agenda composition; - its page reports that capability as unavailable until one is bound. Preview - Agenda data is supplied only by a test adapter, never by firmware. + They do not duplicate the background services. P4 heading uses a balanced + demand lease; the final visible consumer releases its sampling task/handles. +- T-Deck, L2 and Pager bind the existing `IAgendaSource`. P4 creates a per-view, + read-only adapter over the same Agenda file store and query use cases. It + reads an existing `/agenda/events.dat` through the shared SD runtime, never + mounts, formats, creates or repairs a store, and rejects external USB ownership + or a changed media session. A missing file means an empty Agenda; corruption + and I/O failure remain errors. This does not add a P4 Calendar editor or a + second reminder service. Preview data comes only from a test adapter. The container creates only visible widgets. Closing the negative screen, changing a widget/page, or entering an app deletes those controls. Root DELETE @@ -84,6 +88,12 @@ LVGL, repeated destruction and explicit PSRAM failure. It separately compiles the production IDF allocator and checks that even small allocations and failed reallocations obey the external-memory policy. +The P4 Agenda adapter is tested with records produced by the actual storage +codec/service. Its fake shared-SD port deliberately has no write or mount API. +The test verifies strict PSRAM failure, missing/corrupt files, USB ownership, +removal, a card change during a query, successful retry and no retained handles +or adapter allocation after destruction. + `tests/site/menu-widgets-native.test.mjs` compiles/runs the real production UI for Pager, T-Deck, L2 and both P4 panels. It checks adaptive columns, contextual encoder behavior, touch/key navigation, one visible widget, hidden-query @@ -104,6 +114,9 @@ describing a nonexistent magnetometer as still warming up. Physical touch thresholds, encoder direction, internal/PSRAM heap peaks and rendering latency still require device acceptance under live service load. +Music and quick-controls ownership, supported formats, resource limits and +failure semantics are documented in [Music and device controls](music.md). + ## Actual LVGL captures These frames are rendered by the production LVGL code with deterministic diff --git a/docs/uiux/components/music.md b/docs/uiux/components/music.md new file mode 100644 index 00000000..e0c29e11 --- /dev/null +++ b/docs/uiux/components/music.md @@ -0,0 +1,132 @@ +# Music and device controls + +Music uses the existing warm palette, top bar and bottom-bar navigation. Opening +Music with a playing, paused or interrupted session opens Now Playing. Otherwise +it opens the SD library. Songs, artists, albums and folders are indexed on SD; +playlists are editable M3U8 files under `/.trailmate/music/playlists`. Song files +are never deleted by a playlist operation. + +Now Playing includes embedded artwork, title, artist, album, elapsed/total time, +seek, previous/pause/next and repeat-all/repeat-one/shuffle. The artwork frame and +seek knob have disjoint bounds on every supported screen. The track-information +view pages through six ID3 fields at a time and keeps the cover visible. Lyrics +use three lines with synchronized LRC selection or manual scrolling. + +## Ownership and resource lifetime + +`core_music` owns storage/catalog/playback policy without LVGL or board code. +`music_image` adapts the firmware's existing JPEG/PNG codecs. ESP storage/audio +adapters reuse the shared SD runtime and each board's existing audio owner. +The full page and dashboard widget have independent PSRAM state and use one +serialized music service. Menu widgets leave before the next app attaches. + +An idle music widget attaches with zero library rows: it does not scan SD, +allocate a decoder or start a playback task. Only the visible view requests +artwork/lyrics. Hiding it drops its image reference, cancels further decoding, +closes lyric files and releases its UI state. Exiting library browsing cancels +an unfinished index scan. A paused, hidden session keeps a compact resume record; +its decoder, file handles, published snapshot and worker are released. A media +session change invalidates that record. User-requested background playback +continues independently of widget visibility. + +ESP bulk allocations require `MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT` without an +internal-RAM fallback: decoder/input/PCM workspace, indexes, scan tables, +command slots, service snapshots, lyrics, cover pixels and UI state. Decoder +workspace is 35,864 bytes in the host size check. Catalog scans use bounded +tables for 512 groups per category and a fixed-depth directory stack; complete +indexes are committed by switching CRC-protected SD manifest lanes. Song paging +reads only the requested fixed-size records. Playlist and catalog reads pump +audio between bounded batches instead of holding a second PCM cache. + +Internal RAM still holds the small service proxy, lock/atomic control words, +RTOS task stacks and the existing hardware DMA buffers. ESP cache/flash and DMA +constraints make these distinct from bulk PSRAM storage. The music worker is +created only when work exists and exits while idle. It uses a 4 KiB stack; +individual P4 SDK stack-usage reports are build evidence, not measured runtime +high-water marks or a claim of zero internal-RAM cost. Artwork reference counts +use an internal critical-section lock because Xtensa native CAS cannot operate +on external-RAM objects. + +## Audio focus + +Voice messages, walkie-talkie, calls, alerts and SSTV reserve higher-priority +focus before touching the codec. Music acknowledges only after its hardware +lease has closed; opening and active state are published by the output port. +Nested owners must all release before music may resume. Manual pause/stop clears +the user's play intent. Selecting another song while paused preserves pause. +An interruption during PCM output remains an interruption, rather than turning +into a permanent playback error. Optional index/artwork/lyric I/O waits while a +higher-priority owner is active. Driver teardown and SD closure do not run under +the UI snapshot publication mutex. + +User media/alert gain has one cached notification path shared by settings and +music. Queued stale volume commands cannot overwrite a newer user gain. P4 call +gain is session-scoped: initializing a call at 100% does not overwrite the +user's media/notification preference, and changing that preference does not +silently change a call's independent stream gain. + +## Quick controls + +The widget projects existing runtime state and submits bounded intents. It owns +no radio, GPS, codec, task or persistent timer. Five coalescing control words are +consumed one at a time by the existing application service loop outside LVGL. +Sliders commit on release or keyboard adjustment, so pointer movement does not +write flash. A slider owns its whole drag and cannot page the dashboard. + +| Control | Owner and transition | +| --- | --- | +| Wi-Fi | Preserve saved profiles and priority. Coordinate transport clients before stopping the driver. Reject changes during OTA, calls, non-preemptible operations or external-storage suspension. Roll back a rejected preference; preserve initialization state after failed driver cleanup so retry is possible. | +| LoRa | A shared RF-permission policy is separate from mesh-protocol TX/RX configuration and temporary exclusive sessions. Reject a switch during an exclusive session. Reject new TX while disabled, finish current work and enter checked standby before acknowledging Off. Re-enable through the radio task; an uninitialized disabled radio can still be enabled. Preserve shared power rails. | +| GPS | Use the existing configuration edit lease, GPS runtime enable operation and position projection. The user preference is separate from normal duty-cycle sleep and temporary power leases. | +| Volume | Use the shared music/speaker volume. An idle adjustment updates the cached preference without creating a decoder, opening SD or starting a worker. Active playback defers flash persistence until audio is idle. | +| Brightness | Map the percentage to the target's actual brightness range, apply through its device port and persist that native level. | + +Switching/failed/unavailable states are explicit. A failed switch retains its +actual enabled value for rendering and retry; a desired value is not treated as +a hardware acknowledgment. Hiding this widget never changes user preferences. +The empty generic card footer is removed; an error message appears only when +there is an error. Pager uses 1/2/3 for Wi-Fi/LoRa/GPS, +/- for volume and [/] for +brightness. W/A/S/D and the encoder retain their navigation roles. + +P4 compass visibility owns a balanced heading lease. The final release stops +sampling, powers down the private magnetometer/IMU interface, removes its I2C +handles and ends the worker after its current bounded operation. It does not +disable the external shared 3V3 rail. + +## Supported media and verification + +The decoder streams MPEG Layer III at native sample rates and downmixes to mono. +ID3v1 and ID3v2.2/2.3/2.4 cover common and extended text, comments, attached +pictures and embedded lyrics. UTF-8/Latin-1/UTF-16 and separate WXXX description/ +URL encodings are handled. Unsupported compressed/encrypted/global-unsync tag +content is reported rather than decoded as ordinary text. The audio payload can +still be used when an unsupported tag format can be safely skipped. + +JPEG decoding streams into an aspect-preserving RGB565 thumbnail with bounded +scratch and cooperative cancellation. PNG uses a bounded transient decode with +the strict PSRAM allocation lease. Encoded limits are 2 MiB JPEG and 256 KiB PNG; +PNG also has a 512-pixel edge and 147,456-pixel raster limit. Progressive JPEG is +not supported by the bundled decoder. Only the thumbnail is retained. Lyrics +index at most 64 KiB and 512 lines. Long/unsupported fields report limits instead +of retaining an entire tag or full-size cover. + +`tests/music` runs real decoding, parser, artwork codec, catalog/playlist recovery, +focus and playback policy tests. Its device-controls target compiles the actual +production coordinator against observable hardware/configuration ports. The +native LVGL suites cover all five panels, seek/artwork geometry, mode cycling, +playlist creation, widget teardown, Enter routing and slider gesture ownership. +Preview screenshots use explicit deterministic fixtures; they are not evidence +of physical audio quality, bus timing, PSRAM peaks or real-device latency. + +## Actual LVGL captures + +The following captures use the production page/widget code and deterministic +preview services. Top-bar memory values and media metadata are fixture data. + +![Pager Now Playing with cover, seek and playback modes](../../images/music/pager-now-playing.png) + +![Pager Quick controls without the empty footer](../../images/music/pager-quick-controls.png) + +![P4 Music widget in the right dashboard](../../images/music/p4-music-widget.png) + +![P4 Quick controls in the right dashboard](../../images/music/p4-quick-controls.png) diff --git a/modules/core_music/include/music/artwork.h b/modules/core_music/include/music/artwork.h new file mode 100644 index 00000000..fc60a980 --- /dev/null +++ b/modules/core_music/include/music/artwork.h @@ -0,0 +1,74 @@ +#pragma once +#include "music/memory.h" +#include "platform/sync/resource_lock.h" +#include +namespace music +{ +enum class CoverResult : uint8_t +{ + Ready, + Missing, + Invalid, + Unsupported, + TooLarge, + OutOfMemory, + Cancelled +}; +namespace detail +{ +// Xtensa native CAS cannot address external RAM. Keep the tiny synchronization +// primitive internal and the image/reference metadata in PSRAM. +struct ArtworkLock +{ + platform::sync::Guard guard{platform::sync::Resource::MusicArtwork}; +}; +} // namespace detail +enum class PictureFormat : uint8_t +{ + None, + Jpeg, + Png +}; +// Immutable after publication. A service and each visible image hold separate references. +// The final release may run on either task; no LVGL object is owned by this object. +struct Artwork +{ + unsigned references = 1; + uint64_t track_id = 0; + uint16_t width = 0, height = 0; + uint16_t* pixels = nullptr; + void retain() + { + detail::ArtworkLock lock; + ++references; + } + void drop() + { + bool last = false; + { + detail::ArtworkLock lock; + last = --references == 0; + } + if (!last) return; + release(pixels); + this->~Artwork(); + release(this); + } +}; +inline Artwork* make_artwork(uint64_t id, uint16_t w, uint16_t h) +{ + if (!w || !h || w > 240 || h > 240) return nullptr; + auto* art = make(); + if (!art) return nullptr; + art->track_id = id; + art->width = w; + art->height = h; + art->pixels = static_cast(allocate(static_cast(w) * h * 2)); + if (!art->pixels) + { + art->drop(); + return nullptr; + } + return art; +} +} // namespace music diff --git a/modules/core_music/include/music/catalog.h b/modules/core_music/include/music/catalog.h new file mode 100644 index 00000000..d211ac9b --- /dev/null +++ b/modules/core_music/include/music/catalog.h @@ -0,0 +1,51 @@ +#pragma once +#include "music/id3.h" +#include "music/storage.h" +namespace music +{ +class Catalog +{ + struct Scan; + Storage& storage_; + Scan* scan_ = nullptr; + uint32_t session_ = 0, generation_ = 0, count_ = 0, groups_[3]{}; + uint32_t heads_[256]{}; + uint8_t lane_ = 0; + uint8_t record_[580]{}; + uint8_t manifest_[1088]{}; + Track scratch_{}; + Entry entry_{}; + char path_[320]{}; + bool truncated_ = false; + bool read_record_from(File& file, uint32_t index, Track& out, uint32_t* link = nullptr); + uint32_t group_size(Browse kind, const char* category); + void* yield_context_ = nullptr; + bool (*yield_)(void*) = nullptr; + bool load_lane(uint8_t lane, bool apply); + bool read_record(uint32_t index, Track& out, uint32_t* link = nullptr); + bool write_record(File& output, const Track& track, uint32_t link); + void index_path(unsigned lane, const char* type, char* out, std::size_t capacity) const; + + public: + explicit Catalog(Storage& storage) : storage_(storage) {} + ~Catalog(); + bool load(); + bool begin_scan(); + StorageResult scan_step(); + void abort_scan(); + bool scanning() const { return scan_ != nullptr; } + uint32_t count() const { return count_; } + bool track(uint32_t index, Track& out) { return read_record(index, out); } + bool find(uint64_t id, Track& out, uint32_t* index = nullptr); + bool browse(Snapshot& out, uint8_t rows); + bool queue_track(Browse kind, const char* category, uint32_t index, Track& out); + uint32_t queue_count(Browse kind, const char* category); + bool truncated() const; + bool cooperate() { return !yield_ || yield_(yield_context_); } + void set_yield(bool (*callback)(void*), void* context) + { + yield_ = callback; + yield_context_ = context; + } +}; +} // namespace music diff --git a/modules/core_music/include/music/decoder.h b/modules/core_music/include/music/decoder.h new file mode 100644 index 00000000..e918317c --- /dev/null +++ b/modules/core_music/include/music/decoder.h @@ -0,0 +1,36 @@ +#pragma once +#include "music/reader.h" +#include "music/service.h" +namespace music +{ +enum class DecodeResult : uint8_t +{ + Frame, + End, + Invalid, + IoError +}; +struct PcmFrame +{ + int16_t* samples = nullptr; + uint16_t count = 0; + uint32_t rate = 0; +}; +class Decoder +{ + struct Workspace; + Workspace* state_ = nullptr; + + public: + Decoder(); + ~Decoder(); + bool ready() const { return state_ != nullptr; } + bool open(Reader& input, Track& metadata); + DecodeResult next(PcmFrame& out); + bool seek(uint32_t milliseconds); + uint32_t position_ms() const; + static std::size_t workspace_bytes(); + Decoder(const Decoder&) = delete; + Decoder& operator=(const Decoder&) = delete; +}; +} // namespace music diff --git a/modules/core_music/include/music/id3.h b/modules/core_music/include/music/id3.h new file mode 100644 index 00000000..b12adfba --- /dev/null +++ b/modules/core_music/include/music/id3.h @@ -0,0 +1,36 @@ +#pragma once +#include "music/reader.h" +#include "music/service.h" +#include +namespace music +{ +// Allocate the parser with music::make: its bounded text workspace belongs in PSRAM. +// Scans frame headers and requested fields only; APIC/USLT payloads remain in the file. +class TagReader +{ + public: + bool read(Reader& input, Track& track, MetadataPage* page = nullptr, uint16_t offset = 0); + bool read_text(Reader& input, uint32_t start, uint32_t end, uint8_t encoding, char* out, std::size_t capacity) + { + input_ = &input; + return text(start, std::min(end, input.size()), encoding, out, capacity); + } + + private: + Reader* input_ = nullptr; + MetadataPage* page_ = nullptr; + uint8_t buffer_[512]{}; + uint8_t byte_cache_[128]{}; + uint32_t byte_offset_ = 0; + uint16_t byte_count_ = 0; + char value_[384]{}; + uint16_t fields_ = 0; + bool exact(uint32_t offset, void* output, std::size_t size); + bool byte(uint32_t offset, uint8_t& value); + uint32_t terminated(uint32_t start, uint32_t end, uint8_t encoding); + bool text(uint32_t start, uint32_t end, uint8_t encoding, char* out, std::size_t capacity, bool unsync = false); + void field(const char* id, const char* name, const char* value, bool truncated = false); + void parse_picture(uint32_t start, uint32_t end, bool legacy, Track& track); + void parse_frame(const char* id, uint32_t start, uint32_t end, bool unsync, Track& track); +}; +} // namespace music diff --git a/modules/core_music/include/music/lyrics.h b/modules/core_music/include/music/lyrics.h new file mode 100644 index 00000000..b2334f3c --- /dev/null +++ b/modules/core_music/include/music/lyrics.h @@ -0,0 +1,36 @@ +#pragma once +#include "music/id3.h" +namespace music +{ +// Caller-owned PSRAM. Retains offsets/timestamps and only three visible text lines. +class Lyrics +{ + struct Line + { + uint32_t time = 0, start = 0, end = 0; + }; + Line lines_[512]{}; + Reader* reader_ = nullptr; + TagReader decoder_; + uint8_t cache_[512]{}; + char text_[512]{}; + uint32_t cache_at_ = UINT32_MAX, cache_size_ = 0; + uint16_t count_ = 0; + int current_ = -2, manual_ = 0; + int32_t offset_ms_ = 0; + uint8_t encoding_ = 3; + bool synchronized_ = false, truncated_ = false; + bool byte(uint32_t at, uint8_t& value); + + public: + bool open(Reader& input, bool lrc, uint32_t offset = 0, uint32_t bytes = UINT32_MAX, uint8_t encoding = 3); + void clear(); + void scroll(int delta) + { + manual_ = std::clamp(manual_ + delta, 0, std::max(0, int(count_) - 1)); + current_ = -2; + } + bool update(uint32_t position_ms, Snapshot& out); + bool truncated() const { return truncated_; } +}; +} // namespace music diff --git a/modules/core_music/include/music/memory.h b/modules/core_music/include/music/memory.h new file mode 100644 index 00000000..23af325d --- /dev/null +++ b/modules/core_music/include/music/memory.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include +#include +#if defined(ESP_PLATFORM) +#include "esp_heap_caps.h" +#endif +namespace music +{ +inline void* allocate(std::size_t bytes) +{ +#if defined(ESP_PLATFORM) + return heap_caps_calloc(1, bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + return std::calloc(1, bytes); +#endif +} +inline void release(void* value) +{ +#if defined(ESP_PLATFORM) + heap_caps_free(value); +#else + std::free(value); +#endif +} +template +T* make(Args&&... args) +{ + void* memory = allocate(sizeof(T)); + return memory ? new (memory) T(static_cast(args)...) : nullptr; +} +template +void destroy(T*& value) +{ + if (!value) return; + value->~T(); + release(value); + value = nullptr; +} +} // namespace music diff --git a/modules/core_music/include/music/playback_policy.h b/modules/core_music/include/music/playback_policy.h new file mode 100644 index 00000000..75b8844d --- /dev/null +++ b/modules/core_music/include/music/playback_policy.h @@ -0,0 +1,58 @@ +#pragma once +#include "music/service.h" +namespace music +{ +// Separate user intent from a transient loss of hardware focus. +struct PlaybackIntent +{ + bool wanted = false; + void play() { wanted = true; } + void pause() { wanted = false; } + void stop() { wanted = false; } + Playback state(bool blocked) const { return wanted ? (blocked ? Playback::Interrupted : Playback::Playing) : Playback::Paused; } + bool may_open_audio(bool blocked) const { return wanted && !blocked; } +}; +class QueueCursor +{ + uint32_t current_ = 0, count_ = 0, random_ = 0x6419d2f3; + uint32_t history_[32]{}; + uint8_t history_size_ = 0; + + public: + void reset(uint32_t count, uint32_t index, uint32_t seed) + { + count_ = count; + current_ = count ? index % count : 0; + random_ = seed ? seed : 1; + history_size_ = 0; + } + uint32_t current() const { return current_; } + uint32_t next(PlayMode mode, bool natural_end) + { + if (count_ < 2 || (natural_end && mode == PlayMode::RepeatOne)) return current_; + if (history_size_ == 32) + { + for (unsigned i = 1; i < 32; i++) history_[i - 1] = history_[i]; + --history_size_; + } + history_[history_size_++] = current_; + if (mode == PlayMode::Shuffle) + { + random_ ^= random_ << 13; + random_ ^= random_ >> 17; + random_ ^= random_ << 5; + const auto pick = random_ % (count_ - 1); + current_ = pick >= current_ ? pick + 1 : pick; + } + else current_ = (current_ + 1) % count_; + return current_; + } + uint32_t previous(PlayMode mode) + { + if (!count_) return 0; + if (mode == PlayMode::Shuffle && history_size_) current_ = history_[--history_size_]; + else current_ = current_ ? current_ - 1 : count_ - 1; + return current_; + } +}; +} // namespace music diff --git a/modules/core_music/include/music/player.h b/modules/core_music/include/music/player.h new file mode 100644 index 00000000..c450b97f --- /dev/null +++ b/modules/core_music/include/music/player.h @@ -0,0 +1,90 @@ +#pragma once +#include "music/catalog.h" +#include "music/decoder.h" +#include "music/lyrics.h" +#include "music/playback_policy.h" +#include "music/playlists.h" +namespace music +{ +class AudioOutput +{ + public: + virtual ~AudioOutput() = default; + virtual bool supported() const = 0; + virtual bool blocked() const = 0; + virtual bool needs_frame() const { return true; } + virtual bool begin(uint32_t sample_rate) = 0; + virtual bool write(const int16_t* mono, std::size_t count) = 0; + virtual void end() = 0; +}; +class ImageLoader +{ + public: + virtual ~ImageLoader() = default; + virtual Artwork* decode(Reader& input, const Track& track, uint16_t edge, bool (*pump)(void*), void* context, CoverResult& result) = 0; +}; +struct ResumeState +{ + Track current{}; + QueueCursor queue; + Browse queue_kind = Browse::Songs; + uint64_t playlist = 0; + char category[96]{}; + uint32_t position_ms = 0, queue_count = 0, media_session = 0; + PlayMode mode = PlayMode::RepeatAll; + uint8_t volume = 35; +}; +// Single worker owner. The ESP proxy publishes copies and sends bounded commands; +// it never calls these methods from LVGL or another audio owner. +class Player +{ + Storage& storage_; + AudioOutput& output_; + ImageLoader& images_; + Catalog catalog_; + Playlists playlists_; + TagReader tags_; + Decoder* decoder_ = nullptr; + Lyrics* lyrics_ = nullptr; + File *song_ = nullptr, *lyric_file_ = nullptr; + Artwork* artwork_ = nullptr; + PlaybackIntent intent_; + QueueCursor queue_; + Browse queue_kind_ = Browse::Songs; + uint64_t queue_playlist_ = 0; + char queue_category_[96]{}, path_[320]{}; + uint32_t queue_count_ = 0, media_session_ = 0, audio_rate_ = 0; + uint16_t artwork_edge_ = 0, decoded_edge_ = 0; + uint8_t visible_rows_ = 0; + bool show_lyrics_ = false, audio_open_ = false, loaded_ = false, pumping_ = false; + bool refresh_library_ = true; + uint64_t lyric_track_ = 0, artwork_track_ = 0; + void close_audio(); + void close_decoder(); + void clear_lyrics(); + void clear_artwork(); + bool open_song(); + bool audio_tick(); + bool select_queue(uint32_t index, bool play = true); + void update_library(); + void update_lyrics(); + void update_artwork(); + static bool cooperate(void* context); + static bool cooperate_image(void* context); + + public: + Snapshot state{}; + Player(Storage& storage, AudioOutput& output, ImageLoader& images); + ~Player(); + void attach(uint8_t rows); + void request_artwork(uint16_t edge); + void request_lyrics(bool visible); + bool command(const Request& request); + void tick(); + void save_resume(ResumeState& out) const; + void restore_resume(const ResumeState& saved); + bool active() const { return intent_.wanted || catalog_.scanning(); } + bool audio_open() const { return audio_open_; } + Artwork* artwork() const { return artwork_; } // Worker-owned; proxy retains under its publication lock. +}; +} // namespace music diff --git a/modules/core_music/include/music/playlists.h b/modules/core_music/include/music/playlists.h new file mode 100644 index 00000000..c47a2095 --- /dev/null +++ b/modules/core_music/include/music/playlists.h @@ -0,0 +1,38 @@ +#pragma once +#include "music/catalog.h" +namespace music +{ +class Playlists +{ + Storage& storage_; + Catalog& catalog_; + uint8_t cache_[512]{}; + char line_[320]{}, path_[128]{}, temporary_[144]{}, backup_[144]{}; + Track track_{}; + uint32_t cursor_ = 0, cache_at_ = UINT32_MAX, cache_size_ = 0, sequence_ = 0; + bool read_error_ = false; + Entry entry_{}; + bool read_line(File& file); + void reset_reader() + { + cursor_ = 0; + cache_at_ = UINT32_MAX; + cache_size_ = 0; + read_error_ = false; + } + void paths(uint64_t id); + File* open(uint64_t id); + bool rewrite(uint64_t id, const char* name, uint64_t remove_track, const char* add_path); + + public: + Playlists(Storage& storage, Catalog& catalog) : storage_(storage), catalog_(catalog) {} + bool create(const char* name, uint64_t initial_track, uint64_t& out_id); + bool add(uint64_t playlist, uint64_t song); + bool remove(uint64_t playlist, uint64_t song); + bool rename(uint64_t playlist, const char* name); + bool erase(uint64_t playlist); + bool browse(Snapshot& out, uint8_t rows); + uint32_t count(uint64_t playlist); + bool track(uint64_t playlist, uint32_t index, Track& out); +}; +} // namespace music diff --git a/modules/core_music/include/music/reader.h b/modules/core_music/include/music/reader.h new file mode 100644 index 00000000..ab4cf78c --- /dev/null +++ b/modules/core_music/include/music/reader.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include +namespace music +{ +class Reader +{ + public: + virtual ~Reader() = default; + virtual uint32_t size() const = 0; + virtual std::size_t read_at(uint32_t offset, void* output, std::size_t bytes) = 0; +}; +} // namespace music diff --git a/modules/core_music/include/music/service.h b/modules/core_music/include/music/service.h new file mode 100644 index 00000000..d09e6eec --- /dev/null +++ b/modules/core_music/include/music/service.h @@ -0,0 +1,141 @@ +#pragma once +#include "music/artwork.h" +#include +#include + +namespace music +{ +constexpr std::size_t kPageRows = 8; +enum class Browse : uint8_t +{ + Songs, + Artists, + Albums, + Folders, + Playlists, + PlaylistTracks, + ArtistTracks, + AlbumTracks, + FolderTracks +}; +enum class Playback : uint8_t +{ + Stopped, + Playing, + Paused, + Interrupted, + Error +}; +enum class PlayMode : uint8_t +{ + RepeatAll, + RepeatOne, + Shuffle +}; +enum class Action : uint8_t +{ + Browse, + Play, + TogglePause, + Stop, + Previous, + Next, + Seek, + Volume, + Rescan, + CreatePlaylist, + AddToPlaylist, + RemoveFromPlaylist, + DeletePlaylist, + RenamePlaylist, + MetadataPage, + ScrollLyrics, + SetPlayMode +}; +struct Track +{ + uint64_t id = 0; + char path[256]{}; + char title[96]{}; + char artist[64]{}; + char album[64]{}; + char genre[32]{}; + uint32_t duration_ms = 0; + uint32_t audio_offset = 0; + uint32_t lyric_offset = 0; + uint32_t lyric_size = 0; + uint8_t lyric_encoding = 0; + bool estimated_duration = false; + uint32_t picture_offset = 0, picture_size = 0; + PictureFormat picture_format = PictureFormat::None; + uint8_t picture_type = 0; +}; +constexpr std::size_t kMetadataRows = 6; +struct MetadataField +{ + char id[5]{}, name[48]{}, value[256]{}; + bool truncated = false; +}; +struct MetadataPage +{ + uint64_t track_id = 0; + uint16_t offset = 0, total = 0; + uint8_t count = 0; + bool unsupported_flags = false, truncated = false; + MetadataField fields[kMetadataRows]{}; +}; +struct Row +{ + uint64_t id = 0; + char title[96]{}; + char subtitle[96]{}; + uint32_t count = 0; +}; +struct Snapshot +{ + uint32_t revision = 0, library_revision = 0; + bool storage_ready = false, audio_supported = false, busy = false; + Playback playback = Playback::Stopped; + PlayMode mode = PlayMode::RepeatAll; + Browse browse = Browse::Songs; + uint32_t offset = 0, total = 0, position_ms = 0; + uint8_t row_count = 0, volume = 35; + uint64_t playlist_id = 0; + char category[96]{}; + char notice[96]{}; + Track current{}; + Row rows[kPageRows]{}; + char lyric_previous[192]{}, lyric_current[192]{}, lyric_next[192]{}; + bool has_lyrics = false, synchronized_lyrics = false; + uint32_t artwork_revision = 0, metadata_revision = 0; + MetadataPage metadata{}; +}; +struct Request +{ + Action action = Action::Browse; + Browse browse = Browse::Songs; + uint64_t id = 0, playlist_id = 0; + int32_t value = 0; + char text[96]{}; +}; +class IService +{ + public: + virtual ~IService() = default; + virtual void attach(uint8_t visible_rows) = 0; + virtual void detach() = 0; + // Bounded copy into caller-owned storage. Never performs SD or audio I/O. + virtual bool snapshot(Snapshot& out) = 0; + virtual bool send(const Request& request) = 0; + // Zero disables artwork work while the player is hidden. Decoding runs on the service task. + virtual void request_artwork(uint16_t edge) { (void)edge; } + // Returns one retained, immutable PSRAM image, or nullptr. Caller must drop it. + virtual Artwork* acquire_artwork() { return nullptr; } + virtual void request_lyrics(bool visible) { (void)visible; } + virtual uint8_t volume() const = 0; +}; +inline bool has_session(const Snapshot& value) +{ + return value.current.id != 0 && value.playback != Playback::Stopped && value.playback != Playback::Error; +} +} // namespace music diff --git a/modules/core_music/include/music/storage.h b/modules/core_music/include/music/storage.h new file mode 100644 index 00000000..30367145 --- /dev/null +++ b/modules/core_music/include/music/storage.h @@ -0,0 +1,50 @@ +#pragma once +#include "music/reader.h" +namespace music +{ +enum class FileMode : uint8_t +{ + Read, + Write, + Append +}; +enum class StorageResult : uint8_t +{ + Ok, + End, + Busy, + Missing, + Unavailable, + Error +}; +struct Entry +{ + char name[256]{}; + bool directory = false; + uint64_t size = 0; +}; +class File : public Reader +{ + public: + virtual std::size_t write(const void* data, std::size_t count) = 0; + virtual bool flush() = 0; +}; +class Storage +{ + public: + virtual ~Storage() = default; + virtual bool ready() const = 0; + virtual uint32_t session() const = 0; + virtual File* open(const char* path, FileMode mode) = 0; + virtual void close(File*& file) = 0; + virtual StorageResult last_result() const = 0; + virtual StorageResult next(const char* directory, uint64_t& cursor, Entry& entry) = 0; + virtual bool exists(const char* path) = 0; + virtual bool mkdir(const char* path) = 0; + virtual bool remove(const char* path) = 0; + virtual bool rename(const char* from, const char* to) = 0; +}; +uint64_t path_id(const char* text); +bool valid_music_path(const char* path); +void copy_text(char* output, std::size_t capacity, const char* text); +} // namespace music diff --git a/modules/core_music/library.json b/modules/core_music/library.json new file mode 100644 index 00000000..2273174e --- /dev/null +++ b/modules/core_music/library.json @@ -0,0 +1,6 @@ +{ + "name": "core_music", + "version": "0.1.0", + "build": {"includeDir": "include", "srcDir": "src"}, + "dependencies": [{"name":"core_sys"}] +} diff --git a/modules/core_music/src/catalog.cpp b/modules/core_music/src/catalog.cpp new file mode 100644 index 00000000..0cdb5328 --- /dev/null +++ b/modules/core_music/src/catalog.cpp @@ -0,0 +1,522 @@ +#include "music/catalog.h" +#include +#include +#include +namespace music +{ +namespace +{ +constexpr const char* kRoot = "/.trailmate/music"; +constexpr const char* types[] = {"artists", "albums", "folders"}; +constexpr uint32_t kRecordSize = 580, kManifestSize = 1088, kMaxTracks = 1000000; +uint32_t get32(const uint8_t* p) { return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; } +void put32(uint8_t* p, uint32_t value) +{ + for (unsigned i = 0; i < 4; i++) p[i] = value >> (i * 8); +} +uint64_t get64(const uint8_t* p) { return get32(p) | (uint64_t(get32(p + 4)) << 32); } +void put64(uint8_t* p, uint64_t value) +{ + put32(p, value); + put32(p + 4, value >> 32); +} +uint32_t crc(const uint8_t* p, std::size_t size) +{ + uint32_t value = ~0U; + while (size--) + { + value ^= *p++; + for (unsigned n = 0; n < 8; n++) value = (value >> 1) ^ (0xedb88320U & -(value & 1)); + } + return ~value; +} +const char* group_name(const Track& track, unsigned kind, char* folder, std::size_t capacity) +{ + if (kind == 0) return track.artist[0] ? track.artist : "Unknown artist"; + if (kind == 1) return track.album[0] ? track.album : "Unknown album"; + copy_text(folder, capacity, track.path + 7); + auto* slash = std::strrchr(folder, '/'); + if (slash) *slash = 0; + else copy_text(folder, capacity, "Music"); + return folder; +} +bool is_mp3(const char* name) +{ + const auto size = std::strlen(name); + if (size < 4) return false; + const char* ext = name + size - 4; + return ext[0] == '.' && (ext[1] == 'm' || ext[1] == 'M') && (ext[2] == 'p' || ext[2] == 'P') && ext[3] == '3'; +} +bool same_category(const Track& track, Browse kind, const char* category, char* folder, std::size_t capacity) +{ + return kind == Browse::Songs || path_id(category) == path_id(group_name(track, kind == Browse::ArtistTracks ? 0 : kind == Browse::AlbumTracks ? 1 + : 2, + folder, capacity)); +} +} // namespace +struct Catalog::Scan +{ + struct Directory + { + char path[256]{}; + uint64_t cursor = 0; + }; + struct Group + { + uint64_t id = 0; + uint32_t first = 0, count = 0; + }; + Directory directories[8]{}; + Group groups[3][512]{}; + uint16_t group_count[3]{}; + uint32_t heads[256]{}, count = 0, generation = 0; + uint8_t depth = 0, lane = 0; + File* output = nullptr; + TagReader tags; + bool truncated = false; + char folder[256]{}; +}; +Catalog::~Catalog() { abort_scan(); } +void Catalog::index_path(unsigned lane, const char* type, char* out, std::size_t capacity) const +{ + std::snprintf(out, capacity, "%s/%s-%u.idx", kRoot, type, lane); +} +bool Catalog::load_lane(uint8_t lane, bool apply) +{ + index_path(lane, "manifest", path_, sizeof(path_)); + auto* file = storage_.open(path_, FileMode::Read); + if (!file) return false; + const bool read = file->size() == kManifestSize && file->read_at(0, manifest_, kManifestSize) == kManifestSize; + storage_.close(file); + if (!read || std::memcmp(manifest_, "TMUM", 4) || get32(manifest_ + 4) != 1 || get32(manifest_ + 1084) != crc(manifest_, 1084)) return false; + const auto generation = get32(manifest_ + 8), count = get32(manifest_ + 12); + if (!generation || count > kMaxTracks) return false; + for (unsigned kind = 0; kind < 4; kind++) + { + const uint32_t records = kind ? get32(manifest_ + 12 + kind * 4) : count; + if (kind && records > 512) return false; + index_path(lane, kind ? types[kind - 1] : "songs", path_, sizeof(path_)); + file = storage_.open(path_, FileMode::Read); + if (!file) return false; + const bool valid = file->size() == uint64_t(records) * (kind ? 16 : kRecordSize); + storage_.close(file); + if (!valid) return false; + } + for (unsigned i = 0; i < 256; i++) + if (get32(manifest_ + 32 + i * 4) > count) return false; + if (apply) + { + generation_ = generation; + count_ = count; + lane_ = lane; + session_ = storage_.session(); + truncated_ = manifest_[28] != 0; + for (unsigned i = 0; i < 3; i++) groups_[i] = get32(manifest_ + 16 + i * 4); + for (unsigned i = 0; i < 256; i++) + { + heads_[i] = get32(manifest_ + 32 + i * 4); + } + } + return true; +} +bool Catalog::load() +{ + if (!storage_.ready()) return false; + generation_ = 0; + count_ = 0; + const bool first = load_lane(0, true); + const auto first_generation = generation_; + if (load_lane(1, false) && (!first || static_cast(get32(manifest_ + 8) - first_generation) > 0)) return load_lane(1, true); + if (first) return load_lane(0, true); + return false; +} +bool Catalog::read_record(uint32_t index, Track& out, uint32_t* link) +{ + if (index >= count_ || storage_.session() != session_) return false; + index_path(lane_, "songs", path_, sizeof(path_)); + auto* file = storage_.open(path_, FileMode::Read); + if (!file) return false; + const bool read = read_record_from(*file, index, out, link); + storage_.close(file); + return read; +} +bool Catalog::read_record_from(File& file, uint32_t index, Track& out, uint32_t* link) +{ + if (index >= count_ || storage_.session() != session_) return false; + const bool read = file.read_at(index * kRecordSize, record_, sizeof(record_)) == sizeof(record_); + if (!read || get32(record_ + 576) != crc(record_, 576)) return false; + if (get32(record_ + 36) > index) return false; // Hash links may only point to earlier records. + // Stored strings must be NUL-terminated before any C-string operation. + for (const auto end : {319, 415, 479, 543, 575}) + if (record_[end]) return false; + if (!valid_music_path(reinterpret_cast(record_ + 64))) return false; + out = {}; + out.id = get64(record_); + out.duration_ms = get32(record_ + 8); + out.audio_offset = get32(record_ + 12); + out.lyric_offset = get32(record_ + 16); + out.lyric_size = get32(record_ + 20); + out.lyric_encoding = record_[24]; + out.estimated_duration = record_[25] != 0; + out.picture_format = record_[26] <= 2 ? static_cast(record_[26]) : PictureFormat::None; + out.picture_type = record_[27]; + out.picture_offset = get32(record_ + 28); + out.picture_size = get32(record_ + 32); + if (link) *link = get32(record_ + 36); + std::memcpy(out.path, record_ + 64, 256); + std::memcpy(out.title, record_ + 320, 96); + std::memcpy(out.artist, record_ + 416, 64); + std::memcpy(out.album, record_ + 480, 64); + std::memcpy(out.genre, record_ + 544, 32); + return out.id == path_id(out.path); +} +bool Catalog::write_record(File& file, const Track& track, uint32_t link) +{ + std::memset(record_, 0, sizeof(record_)); + put64(record_, track.id); + put32(record_ + 8, track.duration_ms); + put32(record_ + 12, track.audio_offset); + put32(record_ + 16, track.lyric_offset); + put32(record_ + 20, track.lyric_size); + record_[24] = track.lyric_encoding; + record_[25] = track.estimated_duration; + record_[26] = static_cast(track.picture_format); + record_[27] = track.picture_type; + put32(record_ + 28, track.picture_offset); + put32(record_ + 32, track.picture_size); + put32(record_ + 36, link); + copy_text(reinterpret_cast(record_ + 64), 256, track.path); + copy_text(reinterpret_cast(record_ + 320), 96, track.title); + copy_text(reinterpret_cast(record_ + 416), 64, track.artist); + copy_text(reinterpret_cast(record_ + 480), 64, track.album); + copy_text(reinterpret_cast(record_ + 544), 32, track.genre); + put32(record_ + 576, crc(record_, 576)); + return file.write(record_, sizeof(record_)) == sizeof(record_); +} +bool Catalog::find(uint64_t id, Track& out, uint32_t* index) +{ + uint32_t next = heads_[id & 255]; + for (uint32_t seen = 0; next && seen < count_; seen++) + { + if (seen && (seen % 8 == 0) && !cooperate()) return false; + const uint32_t at = next - 1; + if (!read_record(at, out, &next)) return false; + if (out.id == id) + { + if (index) *index = at; + return true; + } + } + return false; +} +bool Catalog::begin_scan() +{ + if (scan_ || !storage_.ready()) return false; + if (!storage_.mkdir("/.trailmate") || !storage_.mkdir(kRoot)) return false; + scan_ = make(); + if (!scan_) return false; + scan_->lane = generation_ ? 1 - lane_ : 0; + scan_->generation = generation_ + 1; + if (!scan_->generation) scan_->generation = 1; + session_ = storage_.session(); + copy_text(scan_->directories[0].path, 256, "/Music"); + // An old inactive manifest must not reference files that are about to be truncated. + index_path(scan_->lane, "manifest", path_, sizeof(path_)); + if (storage_.exists(path_) && !storage_.remove(path_)) + { + abort_scan(); + return false; + } + index_path(scan_->lane, "songs", path_, sizeof(path_)); + scan_->output = storage_.open(path_, FileMode::Write); + if (!scan_->output) + { + abort_scan(); + return false; + } + return true; +} +void Catalog::abort_scan() +{ + if (scan_) + { + storage_.close(scan_->output); + destroy(scan_); + } +} +bool Catalog::truncated() const { return scan_ ? scan_->truncated : truncated_; } +StorageResult Catalog::scan_step() +{ + if (!scan_) return StorageResult::Error; + if (!storage_.ready() || storage_.session() != session_) + { + abort_scan(); + return StorageResult::Unavailable; + } + auto& scan = *scan_; + auto& dir = scan.directories[scan.depth]; + const uint64_t previous = dir.cursor; + const auto result = storage_.next(dir.path, dir.cursor, entry_); + if (result == StorageResult::Busy) return result; + if (result == StorageResult::End || result == StorageResult::Missing) + { + if (scan.depth) + { + --scan.depth; + return StorageResult::Ok; + } + if (!scan.output->flush()) + { + abort_scan(); + return StorageResult::Error; + } + storage_.close(scan.output); + for (unsigned kind = 0; kind < 3; kind++) + { + index_path(scan.lane, types[kind], path_, sizeof(path_)); + auto* group_file = storage_.open(path_, FileMode::Write); + if (!group_file) + { + abort_scan(); + return StorageResult::Error; + } + bool ok = true; + for (unsigned n = 0; n < scan.group_count[kind]; n++) + { + const auto& group = scan.groups[kind][n]; + put64(record_, group.id); + put32(record_ + 8, group.first); + put32(record_ + 12, group.count); + if (group_file->write(record_, 16) != 16) + { + ok = false; + break; + } + } + ok = group_file->flush() && ok; + storage_.close(group_file); + if (!ok) + { + abort_scan(); + return StorageResult::Error; + } + } + std::memset(manifest_, 0, sizeof(manifest_)); + std::memcpy(manifest_, "TMUM", 4); + put32(manifest_ + 4, 1); + put32(manifest_ + 8, scan.generation); + put32(manifest_ + 12, scan.count); + manifest_[28] = scan.truncated; + for (unsigned i = 0; i < 3; i++) put32(manifest_ + 16 + i * 4, scan.group_count[i]); + for (unsigned i = 0; i < 256; i++) put32(manifest_ + 32 + i * 4, scan.heads[i]); + put32(manifest_ + 1084, crc(manifest_, 1084)); + index_path(scan.lane, "manifest", path_, sizeof(path_)); + auto* manifest = storage_.open(path_, FileMode::Write); + bool ok = manifest && manifest->write(manifest_, sizeof(manifest_)) == sizeof(manifest_) && manifest->flush(); + storage_.close(manifest); + const auto lane = scan.lane; + abort_scan(); + if (!ok || !load_lane(lane, true)) return StorageResult::Error; + return StorageResult::End; + } + if (result != StorageResult::Ok) + { + abort_scan(); + return result; + } + if (!entry_.name[0] || entry_.name[0] == '.') return StorageResult::Ok; + const int length = std::snprintf(path_, sizeof(path_), "%s/%s", dir.path, entry_.name); + if (length < 0 || length >= 256 || !valid_music_path(path_)) + { + scan.truncated = true; + return StorageResult::Ok; + } + if (entry_.directory) + { + if (scan.depth + 1 >= 8) + { + scan.truncated = true; + return StorageResult::Ok; + } + ++scan.depth; + copy_text(scan.directories[scan.depth].path, 256, path_); + scan.directories[scan.depth].cursor = 0; + return StorageResult::Ok; + } + if (!is_mp3(entry_.name) || entry_.size > UINT32_MAX || entry_.size < 4) return StorageResult::Ok; + auto* song = storage_.open(path_, FileMode::Read); + if (!song) + { + if (storage_.last_result() == StorageResult::Busy) + { + dir.cursor = previous; + return StorageResult::Busy; + } + return StorageResult::Ok; + } + scratch_ = {}; + copy_text(scratch_.path, sizeof(scratch_.path), path_); + scratch_.id = path_id(path_); + const bool parsed = scan.tags.read(*song, scratch_); + storage_.close(song); + if (!parsed) + { + if (storage_.last_result() == StorageResult::Busy) + { + dir.cursor = previous; + return StorageResult::Busy; + } + return StorageResult::Ok; + } + if (scan.count == kMaxTracks) + { + scan.truncated = true; + return StorageResult::Ok; + } + const unsigned bucket = scratch_.id & 255; + if (!write_record(*scan.output, scratch_, scan.heads[bucket])) + { + abort_scan(); + return StorageResult::Error; + } + scan.heads[bucket] = scan.count + 1; + for (unsigned kind = 0; kind < 3; kind++) + { + const auto id = path_id(group_name(scratch_, kind, scan.folder, sizeof(scan.folder))); + unsigned n = 0; + for (; n < scan.group_count[kind] && scan.groups[kind][n].id != id; n++) + { + } + if (n == 512) + { + scan.truncated = true; + continue; + } + auto& group = scan.groups[kind][n]; + if (n == scan.group_count[kind]) + { + group.id = id; + group.first = scan.count; + scan.group_count[kind]++; + } + group.count++; + } + ++scan.count; + return StorageResult::Ok; +} +bool Catalog::browse(Snapshot& out, uint8_t rows) +{ + rows = std::min(rows, kPageRows); + out.row_count = 0; + if (out.browse == Browse::Artists || out.browse == Browse::Albums || out.browse == Browse::Folders) + { + const unsigned kind = static_cast(out.browse) - 1; + out.total = groups_[kind]; + index_path(lane_, types[kind], path_, sizeof(path_)); + auto* file = storage_.open(path_, FileMode::Read); + if (!file) return false; + for (uint32_t index = out.offset; index < out.total && out.row_count < rows; index++) + { + if (file->read_at(index * 16, record_, 16) != 16) + { + storage_.close(file); + return false; + } + const auto id = get64(record_); + const auto first = get32(record_ + 8), count = get32(record_ + 12); + if (!read_record(first, scratch_)) + { + storage_.close(file); + return false; + } + auto& row = out.rows[out.row_count++]; + row = {}; + row.id = id; + row.count = count; + copy_text(row.title, sizeof(row.title), group_name(scratch_, kind, path_, sizeof(path_))); + std::snprintf(row.subtitle, sizeof(row.subtitle), "%lu songs", static_cast(count)); + } + storage_.close(file); + return true; + } + if (out.browse != Browse::Songs && out.browse != Browse::ArtistTracks && out.browse != Browse::AlbumTracks && out.browse != Browse::FolderTracks) return false; + out.total = out.browse == Browse::Songs ? count_ : group_size(out.browse, out.category); + if (!rows || out.offset >= out.total) return true; + index_path(lane_, "songs", path_, sizeof(path_)); + auto* file = storage_.open(path_, FileMode::Read); + if (!file) return false; + uint32_t matching = 0; + bool ok = true; + for (uint32_t index = out.browse == Browse::Songs ? out.offset : 0; index < count_ && out.row_count < rows; index++) + { + if (yield_ && index % 8 == 0 && !yield_(yield_context_)) + { + ok = false; + break; + } + if (!read_record_from(*file, index, scratch_)) + { + ok = false; + break; + } + if (!same_category(scratch_, out.browse, out.category, path_, sizeof(path_))) continue; + if (out.browse != Browse::Songs && matching++ < out.offset) continue; + auto& row = out.rows[out.row_count++]; + row = {}; + row.id = scratch_.id; + copy_text(row.title, sizeof(row.title), scratch_.title); + std::snprintf(row.subtitle, sizeof(row.subtitle), "%.47s | %.43s", scratch_.artist, scratch_.album); + } + storage_.close(file); + if (!ok) out.row_count = 0; + return ok; +} +uint32_t Catalog::queue_count(Browse kind, const char* category) +{ + if (kind == Browse::Songs) return count_; + return group_size(kind, category); +} +uint32_t Catalog::group_size(Browse kind, const char* category) +{ + const unsigned group = kind == Browse::ArtistTracks ? 0 : kind == Browse::AlbumTracks ? 1 + : 2; + index_path(lane_, types[group], path_, sizeof(path_)); + auto* file = storage_.open(path_, FileMode::Read); + if (!file) return 0; + uint32_t count = 0; + const auto id = path_id(category); + for (unsigned i = 0; i < groups_[group]; i++) + { + if (file->read_at(i * 16, record_, 16) != 16) break; + if (get64(record_) == id) + { + count = get32(record_ + 12); + break; + } + } + storage_.close(file); + return count; +} +bool Catalog::queue_track(Browse kind, const char* category, uint32_t index, Track& out) +{ + if (kind == Browse::Songs) return track(index, out); + index_path(lane_, "songs", path_, sizeof(path_)); + auto* file = storage_.open(path_, FileMode::Read); + if (!file) return false; + uint32_t matching = 0; + bool found = false; + for (uint32_t i = 0; i < count_; i++) + { + if (yield_ && i % 8 == 0 && !yield_(yield_context_)) break; + if (read_record_from(*file, i, scratch_) && same_category(scratch_, kind, category, path_, sizeof(path_))) + if (matching++ == index) + { + out = scratch_; + found = true; + break; + } + } + storage_.close(file); + return found; +} +} // namespace music diff --git a/modules/core_music/src/decoder.cpp b/modules/core_music/src/decoder.cpp new file mode 100644 index 00000000..84578441 --- /dev/null +++ b/modules/core_music/src/decoder.cpp @@ -0,0 +1,137 @@ +#include "music/decoder.h" +#include "music/memory.h" +#define MINIMP3_IMPLEMENTATION +#define MINIMP3_ONLY_MP3 +#define MINIMP3_NO_SIMD +#define MINIMP3_EXTERNAL_SCRATCH +#include "../../../third_party/minimp3/minimp3.h" +#undef scratch +#include +#include +namespace music +{ +struct Decoder::Workspace +{ + Reader* input = nullptr; + Track* metadata = nullptr; + mp3dec_t decoder{}; + mp3dec_scratch_t scratch{}; + mp3dec_frame_info_t info{}; + uint8_t input_bytes[8192]{}; + int16_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME]{}; + uint32_t offset = 0, used = 0, audio_bytes = 0; + uint64_t position_us = 0; + uint8_t toc[100]{}; + bool has_toc = false, probed = false; +}; +Decoder::Decoder() : state_(make()) {} +Decoder::~Decoder() { destroy(state_); } +std::size_t Decoder::workspace_bytes() { return sizeof(Workspace); } +bool Decoder::open(Reader& input, Track& metadata) +{ + if (!state_ || metadata.audio_offset >= input.size()) return false; + state_->~Workspace(); + new (state_) Workspace{}; + state_->input = &input; + state_->metadata = &metadata; + state_->offset = metadata.audio_offset; + state_->audio_bytes = input.size() - metadata.audio_offset; + mp3dec_init(&state_->decoder); + return true; +} +DecodeResult Decoder::next(PcmFrame& out) +{ + out = {}; + if (!state_ || !state_->input) return DecodeResult::Invalid; + auto& self = *state_; + // Limit garbage scanning per iteration so storage corruption cannot starve focus/stop commands. + for (unsigned attempt = 0; attempt < 4; attempt++) + { + if (self.used < sizeof(self.input_bytes) && self.offset < self.input->size()) + { + const auto wanted = std::min(sizeof(self.input_bytes) - self.used, self.input->size() - self.offset); + const auto count = self.input->read_at(self.offset, self.input_bytes + self.used, wanted); + if (!count) return DecodeResult::IoError; + self.offset += count; + self.used += count; + } + if (!self.used) return DecodeResult::End; + const int count = mp3dec_decode_frame(&self.decoder, self.input_bytes, self.used, self.pcm, &self.info, &self.scratch); + if (count > 0) + { + if (self.info.hz < 8000 || self.info.hz > 48000 || (self.info.channels != 1 && self.info.channels != 2) || self.info.layer != 3) return DecodeResult::Invalid; + if (!self.probed) + { + self.probed = true; + const auto* header = self.input_bytes + self.info.frame_offset; + const bool version1 = ((header[1] >> 3) & 3) == 3; + const unsigned side = version1 ? (self.info.channels == 1 ? 17 : 32) : (self.info.channels == 1 ? 9 : 17); + const unsigned at = self.info.frame_offset + 4 + ((header[1] & 1) ? 0 : 2) + side; + auto big = [](const uint8_t* p) + { return uint32_t(p[0]) << 24 | uint32_t(p[1]) << 16 | uint32_t(p[2]) << 8 | p[3]; }; + if (at + 12 <= static_cast(self.info.frame_bytes) && (!std::memcmp(self.input_bytes + at, "Xing", 4) || !std::memcmp(self.input_bytes + at, "Info", 4))) + { + const auto flags = big(self.input_bytes + at + 4); + unsigned cursor = at + 8; + if (flags & 1) + { + const auto frames = big(self.input_bytes + cursor); + cursor += 4; + const uint64_t duration = uint64_t(frames) * count * 1000 / self.info.hz; + if (duration <= UINT32_MAX) + { + self.metadata->duration_ms = duration; + self.metadata->estimated_duration = false; + } + } + if ((flags & 2) && cursor + 4 <= static_cast(self.info.frame_bytes)) + { + self.audio_bytes = std::min(self.audio_bytes, big(self.input_bytes + cursor)); + cursor += 4; + } + if ((flags & 4) && cursor + 100 <= static_cast(self.info.frame_bytes)) + { + std::memcpy(self.toc, self.input_bytes + cursor, 100); + self.has_toc = true; + } + } + else if (!self.metadata->duration_ms && self.info.bitrate_kbps > 0) + { + self.metadata->duration_ms = std::min(UINT32_MAX, uint64_t(self.audio_bytes) * 8 / self.info.bitrate_kbps); + self.metadata->estimated_duration = true; + } + } + if (self.info.channels == 2) + for (int index = 0; index < count; index++) self.pcm[index] = static_cast((int32_t(self.pcm[index * 2]) + self.pcm[index * 2 + 1]) / 2); + self.position_us += uint64_t(count) * 1000000 / self.info.hz; + out = {self.pcm, static_cast(count), static_cast(self.info.hz)}; + } + const auto consumed = std::clamp(self.info.frame_bytes, 0, static_cast(self.used)); + if (!consumed) return DecodeResult::Invalid; + self.used -= consumed; + std::memmove(self.input_bytes, self.input_bytes + consumed, self.used); + if (count > 0) return DecodeResult::Frame; + } + return DecodeResult::Invalid; +} +bool Decoder::seek(uint32_t milliseconds) +{ + if (!state_ || !state_->metadata || !state_->metadata->duration_ms) return false; + auto& self = *state_; + milliseconds = std::min(milliseconds, self.metadata->duration_ms); + uint64_t offset = uint64_t(self.audio_bytes) * milliseconds / self.metadata->duration_ms; + if (self.has_toc) + { + const uint64_t percent = uint64_t(milliseconds) * 10000 / self.metadata->duration_ms; + const unsigned index = std::min(percent / 100, 99), fraction = percent % 100; + const unsigned a = self.toc[index], b = index == 99 ? 256 : self.toc[index + 1]; + offset = uint64_t(self.audio_bytes) * (a * 100 + (b - a) * fraction) / 25600; + } + self.offset = self.metadata->audio_offset + std::min(offset, self.audio_bytes); + self.used = 0; + self.position_us = uint64_t(milliseconds) * 1000; + mp3dec_init(&self.decoder); + return true; +} +uint32_t Decoder::position_ms() const { return state_ ? state_->position_us / 1000 : 0; } +} // namespace music diff --git a/modules/core_music/src/id3.cpp b/modules/core_music/src/id3.cpp new file mode 100644 index 00000000..f42e3b62 --- /dev/null +++ b/modules/core_music/src/id3.cpp @@ -0,0 +1,437 @@ +#include "music/id3.h" +#include +#include +#include +#include +namespace music +{ +namespace +{ +uint32_t be32(const uint8_t* p) { return uint32_t(p[0]) << 24 | uint32_t(p[1]) << 16 | uint32_t(p[2]) << 8 | p[3]; } +bool syncsafe(const uint8_t* p, uint32_t& value) +{ + if ((p[0] | p[1] | p[2] | p[3]) & 0x80) return false; + value = uint32_t(p[0]) << 21 | uint32_t(p[1]) << 14 | uint32_t(p[2]) << 7 | p[3]; + return true; +} +void copy(char* out, std::size_t capacity, const char* text) +{ + if (!capacity) return; + std::size_t n = std::min(capacity - 1, std::strlen(text)); + while (n && (static_cast(text[n]) & 0xc0) == 0x80) --n; + std::memcpy(out, text, n); + out[n] = 0; +} +const char* frame_name(const char* id) +{ + static constexpr struct + { + const char* id; + const char* name; + } names[] = { + {"TIT2", "Title"}, {"TIT1", "Content group"}, {"TIT3", "Subtitle"}, {"TPE1", "Artist"}, {"TPE2", "Album artist"}, {"TPE3", "Conductor"}, {"TPE4", "Remixed by"}, {"TALB", "Album"}, {"TCOM", "Composer"}, {"TEXT", "Lyricist"}, {"TCON", "Genre"}, {"TYER", "Year"}, {"TDRC", "Recording date"}, {"TDRL", "Release date"}, {"TDOR", "Original release"}, {"TRCK", "Track number"}, {"TPOS", "Disc number"}, {"TBPM", "BPM"}, {"TKEY", "Initial key"}, {"TLAN", "Language"}, {"TCOP", "Copyright"}, {"TPUB", "Publisher"}, {"TENC", "Encoded by"}, {"TSSE", "Encoder settings"}, {"TSRC", "ISRC"}, {"TLEN", "Length (ms)"}, {"TOAL", "Original album"}, {"TOPE", "Original artist"}, {"TOWN", "Owner"}, {"TMED", "Media type"}, {"TSOA", "Album sort"}, {"TSOP", "Artist sort"}, {"TSOT", "Title sort"}, {"COMM", "Comment"}, {"USLT", "Embedded lyrics"}, {"WXXX", "Website"}, {"TXXX", "User text"}, {"IPLS", "Involved people"}, {"TIPL", "Involved people"}, {"TMCL", "Musician credits"}, {"POPM", "Rating"}, {"PCNT", "Play count"}}; + for (const auto& name : names) + if (!std::strcmp(id, name.id)) return name.name; + return id; +} +const char* canonical(const char* id) +{ + static constexpr struct + { + const char* old; + const char* current; + } names[] = { + {"TT2", "TIT2"}, {"TP1", "TPE1"}, {"TP2", "TPE2"}, {"TAL", "TALB"}, {"TCO", "TCON"}, {"TCM", "TCOM"}, {"TYE", "TYER"}, {"TRK", "TRCK"}, {"TPA", "TPOS"}, {"TCR", "TCOP"}, {"TEN", "TENC"}, {"TSS", "TSSE"}, {"COM", "COMM"}, {"ULT", "USLT"}, {"PIC", "APIC"}, {"TXX", "TXXX"}, {"WXX", "WXXX"}, {"TLE", "TLEN"}, {"TXT", "TEXT"}, {"TBP", "TBPM"}}; + for (const auto& name : names) + if (!std::strcmp(id, name.old)) return name.current; + return id; +} +} // namespace +bool TagReader::exact(uint32_t offset, void* output, std::size_t size) +{ + return offset <= input_->size() && size <= input_->size() - offset && input_->read_at(offset, output, size) == size; +} +bool TagReader::byte(uint32_t offset, uint8_t& value) +{ + if (offset >= input_->size()) return false; + if (offset < byte_offset_ || offset - byte_offset_ >= byte_count_) + { + byte_offset_ = offset; + byte_count_ = static_cast(std::min(sizeof(byte_cache_), input_->size() - offset)); + if (!exact(offset, byte_cache_, byte_count_)) + { + byte_count_ = 0; + return false; + } + } + value = byte_cache_[offset - byte_offset_]; + return true; +} +uint32_t TagReader::terminated(uint32_t start, uint32_t end, uint8_t encoding) +{ + const uint32_t step = encoding == 1 || encoding == 2 ? 2 : 1; + uint8_t a = 0, b = 0; + for (uint32_t at = start; at < end && at - start < 4096; at += step) + { + if (!byte(at, a) || (step == 2 && (at + 1 >= end || !byte(at + 1, b)))) return end; + if (a == 0 && (step == 1 || b == 0)) return at + step; + } + return end; +} +bool TagReader::text(uint32_t start, uint32_t end, uint8_t encoding, char* out, std::size_t capacity, bool unsync) +{ + if (!capacity) return true; + out[0] = 0; + if (start >= end || encoding > 3) return false; + std::size_t used = 0, available = 0, cursor = 0; + uint32_t at = start; + bool previous_ff = false, truncated = false; + auto next = [&](uint8_t& value) + { + for (;;) + { + if (cursor == available) + { + if (at >= end) return false; + available = std::min(sizeof(buffer_), end - at); + cursor = 0; + if (!exact(at, buffer_, available)) return false; + at += available; + } + value = buffer_[cursor++]; + if (unsync && previous_ff && value == 0) + { + previous_ff = false; + continue; + } + previous_ff = value == 0xff; + return true; + } + }; + auto emit = [&](uint32_t cp) + { + const unsigned count = cp < 0x80 ? 1 : cp < 0x800 ? 2 + : cp < 0x10000 ? 3 + : 4; + if (used + count >= capacity) + { + truncated = true; + return false; + } + if (count == 1) out[used++] = static_cast(cp); + else + { + out[used++] = static_cast((count == 2 ? 0xc0 : count == 3 ? 0xe0 + : 0xf0) | + (cp >> (6 * (count - 1)))); + for (unsigned n = count - 1; n; --n) out[used++] = static_cast(0x80 | ((cp >> (6 * (n - 1))) & 0x3f)); + } + return true; + }; + uint8_t a = 0, b = 0; + bool little = encoding == 1, first = true, separator = false; + while (next(a)) + { + uint32_t cp = a; + if (encoding == 1 || encoding == 2) + { + if (!next(b)) break; + if ((first || separator) && ((a == 0xff && b == 0xfe) || (a == 0xfe && b == 0xff))) + { + little = a == 0xff; + first = false; + continue; + } + cp = little ? uint32_t(a) | uint32_t(b) << 8 : uint32_t(a) << 8 | b; + if (cp >= 0xd800 && cp <= 0xdbff) + { + if (!next(a) || !next(b)) break; + const uint32_t lo = little ? uint32_t(a) | uint32_t(b) << 8 : uint32_t(a) << 8 | b; + cp = lo >= 0xdc00 && lo <= 0xdfff ? 0x10000 + ((cp - 0xd800) << 10) + lo - 0xdc00 : 0xfffd; + } + else if (cp >= 0xdc00 && cp <= 0xdfff) cp = 0xfffd; + } + else if (encoding == 3 && a >= 0x80) + { + const unsigned count = a >= 0xc2 && a <= 0xdf ? 2 : a >= 0xe0 && a <= 0xef ? 3 + : a >= 0xf0 && a <= 0xf4 ? 4 + : 0; + cp = count ? a & ((1U << (7 - count)) - 1) : 0xfffd; + for (unsigned n = 1; n < count; ++n) + { + if (!next(b) || (b & 0xc0) != 0x80) + { + cp = 0xfffd; + break; + } + cp = cp << 6 | (b & 0x3f); + } + if ((count == 2 && cp < 0x80) || (count == 3 && cp < 0x800) || (count == 4 && cp < 0x10000) || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) cp = 0xfffd; + } + first = false; + if (!cp) + { + separator = used != 0; + continue; + } + if (separator) + { + if (!emit(' ') || !emit('/') || !emit(' ')) break; + separator = false; + } + if (cp < 32 && cp != '\n' && cp != '\t') cp = ' '; + if (!emit(cp)) break; + } + while (used && (out[used - 1] == ' ' || out[used - 1] == '\n' || out[used - 1] == '\r')) --used; + out[used] = 0; + return truncated; +} +void TagReader::field(const char* id, const char* name, const char* value, bool truncated) +{ + if (!value[0]) return; + const auto index = fields_++; + if (!page_ || index < page_->offset || index >= page_->offset + kMetadataRows) return; + auto& dest = page_->fields[page_->count++]; + copy(dest.id, sizeof(dest.id), id); + copy(dest.name, sizeof(dest.name), name); + copy(dest.value, sizeof(dest.value), value); + dest.truncated = truncated || std::strlen(value) >= sizeof(dest.value); + page_->truncated |= dest.truncated; +} +void TagReader::parse_picture(uint32_t start, uint32_t end, bool legacy, Track& track) +{ + uint8_t encoding = 0, type = 0; + if (!byte(start++, encoding) || encoding > 3) return; + PictureFormat format = PictureFormat::None; + if (legacy) + { + uint8_t mime[3]; + if (!exact(start, mime, 3)) return; + start += 3; + format = !std::memcmp(mime, "JPG", 3) ? PictureFormat::Jpeg : !std::memcmp(mime, "PNG", 3) ? PictureFormat::Png + : PictureFormat::None; + } + else + { + const auto next = terminated(start, end, 0); + if (next <= start || next - start > 64) return; + text(start, next, 0, value_, sizeof(value_)); + start = next; + format = !std::strcmp(value_, "image/jpeg") || !std::strcmp(value_, "image/jpg") ? PictureFormat::Jpeg : !std::strcmp(value_, "image/png") ? PictureFormat::Png + : PictureFormat::None; + } + if (start >= end || !byte(start++, type)) return; + start = terminated(start, end, encoding); + if (start >= end) return; + std::snprintf(value_, sizeof(value_), "%s | type %u | %lu bytes", format == PictureFormat::Jpeg ? "JPEG" : format == PictureFormat::Png ? "PNG" + : "Unsupported image", + type, static_cast(end - start)); + field("APIC", type == 3 ? "Front cover" : "Attached picture", value_); + if (format == PictureFormat::None || (track.picture_size && (track.picture_type == 3 || type != 3))) return; + track.picture_offset = start; + track.picture_size = end - start; + track.picture_format = format; + track.picture_type = type; +} +void TagReader::parse_frame(const char* id, uint32_t start, uint32_t end, bool unsync, Track& track) +{ + const bool legacy = std::strlen(id) == 3; + id = canonical(id); + if (!std::strcmp(id, "APIC")) + { + if (!unsync) parse_picture(start, end, legacy, track); + else + { + field("APIC", "Attached picture", "Unsynchronized picture"); + if (page_) page_->unsupported_flags = true; + } + return; + } + uint8_t encoding = 0; + if (id[0] == 'T' || !std::strcmp(id, "COMM") || !std::strcmp(id, "USLT") || !std::strcmp(id, "WXXX") || !std::strcmp(id, "IPLS")) + { + if (!byte(start++, encoding) || encoding > 3) return; + if (!std::strcmp(id, "WXXX")) + { + // Only the description uses the declared encoding; URL is Latin-1. + const auto next = terminated(start, end, encoding); + bool truncated = text(start, next, encoding, value_, sizeof(value_), unsync); + auto used = std::strlen(value_); + if (next < end && used + 4 < sizeof(value_)) + { + if (used) + { + std::memcpy(value_ + used, " / ", 3); + used += 3; + } + truncated |= text(next, end, 0, value_ + used, sizeof(value_) - used, unsync); + } + field(id, frame_name(id), value_, truncated); + return; + } + if (!std::strcmp(id, "COMM") || !std::strcmp(id, "USLT")) + { + if (end - start < 3) return; + uint8_t a = 0, b = 0; + if (encoding == 1 && byte(start + 3, a) && byte(start + 4, b) && a == 0xfe && b == 0xff) encoding = 2; + start = terminated(start + 3, end, encoding); + if (!std::strcmp(id, "USLT")) + { + if (!unsync && start < end) + { + track.lyric_offset = start; + track.lyric_size = end - start; + track.lyric_encoding = encoding; + } + field(id, frame_name(id), unsync ? "Unsynchronized text" : "Available in Lyrics"); + return; + } + } + // User-defined description and value are both retained, separated by '/' by text(). + const bool truncated = text(start, end, encoding, value_, sizeof(value_), unsync); + field(id, frame_name(id), value_, truncated); + if (!std::strcmp(id, "TIT2")) copy(track.title, sizeof(track.title), value_); + else if (!std::strcmp(id, "TPE1")) copy(track.artist, sizeof(track.artist), value_); + else if (!std::strcmp(id, "TALB")) copy(track.album, sizeof(track.album), value_); + else if (!std::strcmp(id, "TCON")) copy(track.genre, sizeof(track.genre), value_); + else if (!std::strcmp(id, "TLEN")) + { + const auto length = std::strtoull(value_, nullptr, 10); + if (length <= UINT32_MAX) track.duration_ms = static_cast(length); + } + } + else if (id[0] == 'W') + { + field(id, "Website", (text(start, end, 0, value_, sizeof(value_)), value_)); + } + else + { + // Binary/private frames are represented without interpreting opaque or encrypted payloads. + std::snprintf(value_, sizeof(value_), "%lu bytes", static_cast(end - start)); + field(id, frame_name(id), value_); + } +} +bool TagReader::read(Reader& input, Track& track, MetadataPage* page, uint16_t offset) +{ + input_ = &input; + byte_count_ = 0; + page_ = page; + fields_ = 0; + if (page) + { + page->~MetadataPage(); + new (page) MetadataPage{}; + page->track_id = track.id; + page->offset = offset; + } + track.picture_offset = track.picture_size = track.lyric_offset = track.lyric_size = track.audio_offset = 0; + track.picture_format = PictureFormat::None; + uint8_t head[10]{}; + bool valid = exact(0, head, sizeof(head)); + if (valid && !std::memcmp(head, "ID3", 3)) + { + const uint8_t version = head[3], flags = head[5]; + uint32_t size = 0; + if (version < 2 || version > 4 || !syncsafe(head + 6, size) || size > input.size() - 10) return false; + uint32_t start = 10, end = 10 + size; + track.audio_offset = end; + // v2.4 size excludes both header and footer (ID3v2.4 structure 3.1). + if (version == 4 && (flags & 0x10)) + { + uint8_t footer[10]; + if (input.size() - end < 10 || !exact(end, footer, 10) || std::memcmp(footer, "3DI", 3) || std::memcmp(footer + 3, head + 3, 7)) return false; + track.audio_offset += 10; + } + const bool unsupported = (version < 4 && (flags & 0x80)) || (version == 2 && (flags & 0x40)); + if (unsupported) + { + if (page) page->unsupported_flags = true; + } + else + { + if (version >= 3 && (flags & 0x40)) + { + uint8_t extended[4]; + uint32_t length = 0; + if (!exact(start, extended, 4)) return false; + if (version == 4) + { + if (!syncsafe(extended, length) || length < 6) return false; + } + else + { + length = be32(extended); + if (length > UINT32_MAX - 4) return false; + length += 4; + } + if (length > end - start) return false; + start += length; + } + for (unsigned frames = 0; frames < 512 && start < end; ++frames) + { + const uint32_t header_size = version == 2 ? 6 : 10; + if (end - start < header_size || !exact(start, head, header_size)) break; + if (!head[0]) break; + char id[5]{}; + const unsigned id_size = version == 2 ? 3 : 4; + for (unsigned n = 0; n < id_size; ++n) + { + if (!((head[n] >= 'A' && head[n] <= 'Z') || (head[n] >= '0' && head[n] <= '9'))) return false; + id[n] = static_cast(head[n]); + } + uint32_t length = version == 2 ? uint32_t(head[3]) << 16 | uint32_t(head[4]) << 8 | head[5] : be32(head + 4); + if (version == 4 && !syncsafe(head + 4, length)) return false; + start += header_size; + if (!length || length > end - start) return false; + uint32_t payload = start; + const auto frame_end = start + length; + const uint8_t format = version == 2 ? 0 : head[9]; + const bool compressed = version == 3 ? format & 0xc0 : format & 0x0c; + if (compressed) + { + if (page) page->unsupported_flags = true; + field(id, frame_name(id), "Compressed or encrypted frame"); + } + else + { + if ((version == 3 && (format & 0x20)) || (version == 4 && (format & 0x40))) ++payload; + if (version == 4 && (format & 0x01)) payload += 4; + if (payload < frame_end) parse_frame(id, payload, frame_end, version == 4 && ((flags & 0x80) || (format & 0x02)), track); + } + start = frame_end; + if (frames == 511 && start < end && page) page->truncated = true; + } + } + } + // ID3v1 supplements missing common fields; it never replaces v2 text. + if (input.size() >= 128 && exact(input.size() - 128, buffer_, 128) && !std::memcmp(buffer_, "TAG", 3)) + { + const uint32_t base = input.size() - 128; + const struct + { + unsigned at, length; + const char* id; + char* dest; + std::size_t capacity; + } tags[] = { + {3, 30, "TIT2", track.title, sizeof(track.title)}, {33, 30, "TPE1", track.artist, sizeof(track.artist)}, {63, 30, "TALB", track.album, sizeof(track.album)}, {93, 4, "TYER", nullptr, 0}, {97, 28, "COMM", nullptr, 0}}; + for (const auto& tag : tags) + { + if (tag.dest && tag.dest[0]) continue; + text(base + tag.at, base + tag.at + tag.length, 0, value_, sizeof(value_)); + if (tag.dest) copy(tag.dest, tag.capacity, value_); + field(tag.id, frame_name(tag.id), value_); + } + } + if (!track.title[0]) + { + const auto* name = std::strrchr(track.path, '/'); + copy(track.title, sizeof(track.title), name ? name + 1 : track.path); + auto* dot = std::strrchr(track.title, '.'); + if (dot) *dot = 0; + } + field("FILE", "File", track.path); + if (page) page->total = fields_; + return valid; +} +} // namespace music diff --git a/modules/core_music/src/lyrics.cpp b/modules/core_music/src/lyrics.cpp new file mode 100644 index 00000000..2af4c25c --- /dev/null +++ b/modules/core_music/src/lyrics.cpp @@ -0,0 +1,157 @@ +#include "music/lyrics.h" +#include +#include +#include +#include +namespace music +{ +void Lyrics::clear() +{ + reader_ = nullptr; + count_ = 0; + current_ = -2; + manual_ = 0; + cache_at_ = UINT32_MAX; + cache_size_ = 0; + offset_ms_ = 0; + synchronized_ = truncated_ = false; +} +bool Lyrics::byte(uint32_t at, uint8_t& value) +{ + if (cache_at_ == UINT32_MAX || at < cache_at_ || at >= cache_at_ + cache_size_) + { + cache_at_ = at; + cache_size_ = reader_->read_at(at, cache_, std::min(sizeof(cache_), reader_->size() - at)); + } + if (at >= cache_at_ + cache_size_) return false; + value = cache_[at - cache_at_]; + return true; +} +bool Lyrics::open(Reader& input, bool lrc, uint32_t offset, uint32_t bytes, uint8_t encoding) +{ + clear(); + reader_ = &input; + encoding_ = encoding; + if (offset > input.size()) return false; + const uint32_t available = std::min(bytes, input.size() - offset), length = std::min(available, 64 * 1024), end = offset + length; + truncated_ = available > length; + // Detect sidecar UTF8/UTF16 BOMs. The scan itself always keeps raw file offsets. + uint8_t a = 0, b = 0, c = 0; + if (length >= 2 && byte(offset, a) && byte(offset + 1, b)) + { + if (a == 0xff && b == 0xfe) + { + encoding_ = 1; + offset += 2; + } + else if (a == 0xfe && b == 0xff) + { + encoding_ = 2; + offset += 2; + } + else if (length >= 3 && a == 0xef && b == 0xbb && byte(offset + 2, c) && c == 0xbf) + { + encoding_ = 3; + offset += 3; + } + } + const unsigned step = encoding_ == 1 || encoding_ == 2 ? 2 : 1; + uint32_t start = offset; + auto append = [&](uint32_t time, uint32_t begin, uint32_t finish) + {if(count_==512){truncated_=true;return;}lines_[count_++]={time,begin,finish}; }; + for (uint32_t at = offset; at <= end; at += step) + { + bool newline = at == end; + if (!newline) + { + if (!byte(at, a)) return false; + if (step == 2) + { + if (at + 1 >= end) break; + if (!byte(at + 1, b)) return false; + } + const unsigned cp = step == 1 ? a : encoding_ == 1 ? unsigned(a) | (unsigned(b) << 8) + : (unsigned(a) << 8) | b; + newline = cp == 10; + } + if (!newline) continue; + if (at > start) + { + decoder_.read_text(input, start, at, encoding_, text_, sizeof(text_)); + if (lrc) + { + if (!std::strncmp(text_, "[offset:", 8)) + { + const auto value = std::strtol(text_ + 8, nullptr, 10); + offset_ms_ = std::clamp(value, -3600000, 3600000); + } + else + { + uint32_t stamps[8]{}; + unsigned n = 0; + const char* cursor = text_; + while (*cursor == '[' && n < 8) + { + unsigned minute = 0, second = 0, millis = 0; + int consumed = 0; + if (std::sscanf(cursor, "[%u:%u%n", &minute, &second, &consumed) != 2 || minute > 999 || second > 59) break; + const char* tail = cursor + consumed; + if (*tail == '.' || *tail == ':') + { + ++tail; + unsigned scale = 100; + while (*tail >= '0' && *tail <= '9') + { + if (scale) + { + millis += (*tail - '0') * scale; + scale /= 10; + } + ++tail; + } + } + if (*tail != ']') break; + stamps[n++] = (minute * 60 + second) * 1000 + millis; + cursor = tail + 1; + } + if (n && *cursor) + { + // Timestamp syntax is ASCII: each consumed code point has fixed encoded width. + const uint32_t begin = start + static_cast(cursor - text_) * step; + for (unsigned i = 0; i < n; i++) append(stamps[i], begin, at); + } + } + } + else if (text_[0]) append(0, start, at); + } + start = at + step; + if (at == end) break; + } + synchronized_ = lrc && count_ != 0; + if (synchronized_) std::sort(lines_, lines_ + count_, [](const Line& left, const Line& right) + { return left.time < right.time; }); + return count_ != 0; +} +bool Lyrics::update(uint32_t position_ms, Snapshot& out) +{ + out.has_lyrics = count_ != 0; + out.synchronized_lyrics = synchronized_; + int selected = manual_; + if (synchronized_) + { + const int64_t now = int64_t(position_ms) - offset_ms_; + selected = -1; + for (unsigned i = 0; i < count_ && int64_t(lines_[i].time) <= now; i++) selected = i; + } + if (selected == current_) return false; + current_ = selected; + char* labels[] = {out.lyric_previous, out.lyric_current, out.lyric_next}; + for (unsigned index = 0; index < 3; index++) + { + labels[index][0] = 0; + const int line = selected + int(index) - 1; + if (line >= 0 && line < count_) decoder_.read_text(*reader_, lines_[line].start, lines_[line].end, encoding_, labels[index], 192); + } + return true; +} +} // namespace music diff --git a/modules/core_music/src/player.cpp b/modules/core_music/src/player.cpp new file mode 100644 index 00000000..f7f1c2d9 --- /dev/null +++ b/modules/core_music/src/player.cpp @@ -0,0 +1,484 @@ +#include "music/player.h" +#include +#include +#include +namespace music +{ +Player::Player(Storage& storage, AudioOutput& output, ImageLoader& images) + : storage_(storage), output_(output), images_(images), catalog_(storage), playlists_(storage, catalog_) +{ + state.audio_supported = output.supported(); + state.storage_ready = storage.ready(); + media_session_ = storage.session(); + catalog_.set_yield(cooperate, this); +} +Player::~Player() +{ + close_decoder(); + clear_lyrics(); + clear_artwork(); +} +void Player::save_resume(ResumeState& out) const +{ + out.current = state.current; + out.media_session = media_session_; + out.queue = queue_; + out.queue_kind = queue_kind_; + out.playlist = queue_playlist_; + copy_text(out.category, sizeof(out.category), queue_category_); + out.position_ms = state.position_ms; + out.queue_count = queue_count_; + out.mode = state.mode; + out.volume = state.volume; +} +void Player::restore_resume(const ResumeState& saved) +{ + if (!storage_.ready() || saved.media_session != storage_.session()) return; + state.current = saved.current; + queue_ = saved.queue; + queue_kind_ = saved.queue_kind; + queue_playlist_ = saved.playlist; + copy_text(queue_category_, sizeof(queue_category_), saved.category); + state.position_ms = saved.position_ms; + queue_count_ = saved.queue_count; + state.mode = saved.mode; + state.volume = saved.volume; + state.playback = state.current.id ? Playback::Paused : Playback::Stopped; + loaded_ = catalog_.load(); +} +void Player::close_audio() +{ + if (audio_open_) + { + output_.end(); + audio_open_ = false; + audio_rate_ = 0; + } +} +void Player::close_decoder() +{ + close_audio(); + destroy(decoder_); + storage_.close(song_); +} +void Player::clear_lyrics() +{ + destroy(lyrics_); + storage_.close(lyric_file_); + lyric_track_ = 0; + state.has_lyrics = state.synchronized_lyrics = false; + state.lyric_previous[0] = state.lyric_current[0] = state.lyric_next[0] = 0; +} +void Player::clear_artwork() +{ + const bool changed = artwork_ || decoded_edge_ || artwork_track_; + if (artwork_) + { + artwork_->drop(); + artwork_ = nullptr; + } + decoded_edge_ = 0; + artwork_track_ = 0; + if (changed) ++state.artwork_revision; +} +void Player::attach(uint8_t rows) +{ + rows = std::min(rows, kPageRows); + if (rows != visible_rows_) + { + visible_rows_ = rows; + if (rows) refresh_library_ = true; + else if (catalog_.scanning()) + { + catalog_.abort_scan(); + state.busy = false; + } + } +} +void Player::request_artwork(uint16_t edge) +{ + const auto next = edge ? std::clamp(edge, 32, 240) : 0; + if (next == artwork_edge_) return; + artwork_edge_ = next; + if (!artwork_edge_) clear_artwork(); +} +void Player::request_lyrics(bool visible) +{ + if (show_lyrics_ == visible) return; + show_lyrics_ = visible; + if (!visible) clear_lyrics(); +} +bool Player::open_song() +{ + if (song_ && decoder_) return true; + song_ = storage_.open(state.current.path, FileMode::Read); + if (!song_) return false; + if (!tags_.read(*song_, state.current)) + { + storage_.close(song_); + return false; + } + decoder_ = make(); + if (!decoder_ || !decoder_->ready() || !decoder_->open(*song_, state.current)) + { + close_decoder(); + return false; + } + if (state.position_ms && !decoder_->seek(state.position_ms)) + { + close_decoder(); + return false; + } + return true; +} +bool Player::select_queue(uint32_t index, bool play) +{ + intent_.pause(); // Queue lookup may cooperate; it must never reopen the old decoder. + close_decoder(); + clear_lyrics(); + clear_artwork(); + bool found = queue_kind_ == Browse::PlaylistTracks ? playlists_.track(queue_playlist_, index, state.current) : catalog_.queue_track(queue_kind_, queue_category_, index, state.current); + if (!found) + { + state.playback = Playback::Error; + copy_text(state.notice, sizeof(state.notice), "Song missing from library"); + intent_.stop(); + return false; + } + state.position_ms = 0; + state.metadata.~MetadataPage(); + new (&state.metadata) MetadataPage{}; + ++state.metadata_revision; + if (play) intent_.play(); + state.playback = play ? (output_.blocked() ? Playback::Interrupted : Playback::Playing) : Playback::Paused; + return true; +} +bool Player::audio_tick() +{ + if (!intent_.wanted || !state.current.id) + { + close_audio(); + return true; + } + if (output_.blocked()) + { + close_audio(); + state.playback = Playback::Interrupted; + return true; + } + if (!open_song()) + { + if (storage_.last_result() == StorageResult::Busy) return true; + intent_.stop(); + close_decoder(); + state.playback = Playback::Error; + copy_text(state.notice, sizeof(state.notice), "Unable to open song"); + return false; + } + PcmFrame frame; + const auto result = decoder_->next(frame); + if (result == DecodeResult::End) + { + // A cooperative thumbnail/catalog pump must not reenter those objects to select a song. + if (pumping_) return false; + return select_queue(queue_.next(state.mode, true)); + } + if (result != DecodeResult::Frame) + { + if (result == DecodeResult::IoError && storage_.last_result() == StorageResult::Busy) return true; + intent_.stop(); + close_decoder(); + state.playback = Playback::Error; + copy_text(state.notice, sizeof(state.notice), "Audio or SD read failed"); + return false; + } + if (audio_open_ && audio_rate_ != frame.rate) close_audio(); + if (!audio_open_) + { + // The output port publishes opening/active state around the hardware lease. + if (output_.blocked()) return true; + if (!output_.begin(frame.rate)) + { + if (output_.blocked()) + { + state.playback = Playback::Interrupted; + return true; + } + intent_.pause(); + state.playback = Playback::Error; + copy_text(state.notice, sizeof(state.notice), "Audio device is unavailable"); + return false; + } + audio_open_ = true; + audio_rate_ = frame.rate; + } + for (unsigned i = 0; i < frame.count; i++) frame.samples[i] = static_cast(frame.samples[i]) * state.volume / 100; + if (!output_.write(frame.samples, frame.count)) + { + if (output_.blocked()) + { + close_audio(); + state.playback = Playback::Interrupted; + return true; + } + intent_.stop(); + close_decoder(); + state.playback = Playback::Error; + copy_text(state.notice, sizeof(state.notice), "Audio output failed"); + return false; + } + state.position_ms = decoder_->position_ms(); + state.playback = Playback::Playing; + return true; +} +bool Player::cooperate(void* context) +{ + auto& self = *static_cast(context); + if (self.pumping_) return true; + if (!self.output_.blocked() && !self.output_.needs_frame()) return true; + self.pumping_ = true; + const bool ok = self.audio_tick(); + self.pumping_ = false; + return ok; +} +bool Player::cooperate_image(void* context) +{ + auto& self = *static_cast(context); + return cooperate(context) && !self.output_.blocked(); +} +void Player::update_library() +{ + if (!visible_rows_ || !refresh_library_ || catalog_.scanning()) return; + const bool ok = state.browse == Browse::Playlists || state.browse == Browse::PlaylistTracks ? playlists_.browse(state, visible_rows_) : catalog_.browse(state, visible_rows_); + if (ok) + { + refresh_library_ = false; + ++state.library_revision; + } +} +void Player::update_lyrics() +{ + if (!show_lyrics_ || !state.current.id) return; + if (lyric_track_ != state.current.id) + { + clear_lyrics(); + lyric_track_ = state.current.id; + lyrics_ = make(); + if (!lyrics_) return; + copy_text(path_, sizeof(path_), state.current.path); + auto* dot = std::strrchr(path_, '.'); + if (dot) std::strcpy(dot, ".lrc"); + lyric_file_ = storage_.open(path_, FileMode::Read); + if (lyric_file_ && !lyrics_->open(*lyric_file_, true)) + { + storage_.close(lyric_file_); + } + if (!lyric_file_ && state.current.lyric_size) + { + lyric_file_ = storage_.open(state.current.path, FileMode::Read); + if (lyric_file_ && !lyrics_->open(*lyric_file_, false, state.current.lyric_offset, state.current.lyric_size, state.current.lyric_encoding)) storage_.close(lyric_file_); + } + if (!lyric_file_) destroy(lyrics_); + } + if (lyrics_) lyrics_->update(state.position_ms, state); +} +void Player::update_artwork() +{ + if (!artwork_edge_ || !state.current.id || (artwork_track_ == state.current.id && decoded_edge_ == artwork_edge_)) return; + auto* file = storage_.open(state.current.path, FileMode::Read); + if (!file) return; + CoverResult result; + auto* art = images_.decode(*file, state.current, artwork_edge_, cooperate_image, this, result); + storage_.close(file); + if (result == CoverResult::Cancelled) + { + if (art) art->drop(); + return; + } + clear_artwork(); + artwork_ = art; + artwork_track_ = state.current.id; + decoded_edge_ = artwork_edge_; + if (result == CoverResult::TooLarge) copy_text(state.notice, sizeof(state.notice), "Cover exceeds the image budget"); + else if (result == CoverResult::Unsupported) copy_text(state.notice, sizeof(state.notice), "Unsupported cover encoding"); + else if (result == CoverResult::OutOfMemory) copy_text(state.notice, sizeof(state.notice), "Not enough PSRAM for cover"); + ++state.artwork_revision; +} +void Player::tick() +{ + state.storage_ready = storage_.ready(); + if (!state.storage_ready || media_session_ != storage_.session()) + { + intent_.stop(); + close_decoder(); + clear_lyrics(); + clear_artwork(); + catalog_.abort_scan(); + state.busy = false; + state.playback = state.current.id ? Playback::Error : Playback::Stopped; + loaded_ = false; + media_session_ = storage_.session(); + state.row_count = 0; + state.total = 0; + copy_text(state.notice, sizeof(state.notice), "SD card unavailable or changed"); + ++state.revision; + return; + } + if (output_.blocked()) + { + // Yield the codec first. Optional index/thumbnail/lyric I/O waits for + // the higher-priority owner instead of delaying a short voice session. + audio_tick(); + ++state.revision; + return; + } + if (!loaded_ && visible_rows_) + { + loaded_ = catalog_.load(); + if (!loaded_ && !catalog_.scanning()) catalog_.begin_scan(); + } + if (catalog_.scanning()) + { + state.busy = true; + const auto result = catalog_.scan_step(); + if (result == StorageResult::End) + { + loaded_ = true; + state.busy = false; + refresh_library_ = true; + copy_text(state.notice, sizeof(state.notice), catalog_.truncated() ? "Library indexed with limits" : "Library up to date"); + } + else if (result != StorageResult::Ok && result != StorageResult::Busy) + { + state.busy = false; + copy_text(state.notice, sizeof(state.notice), "Library scan failed"); + } + } + audio_tick(); + update_library(); + update_lyrics(); + update_artwork(); + ++state.revision; +} +bool Player::command(const Request& request) +{ + if (request.action != Action::Browse && request.action != Action::MetadataPage && request.action != Action::ScrollLyrics) + state.notice[0] = 0; + switch (request.action) + { + case Action::Browse: + state.browse = request.browse; + state.offset = std::max(0, request.value); + state.playlist_id = request.id; + copy_text(state.category, sizeof(state.category), request.text); + refresh_library_ = true; + break; + case Action::Play: + queue_kind_ = state.browse >= Browse::PlaylistTracks ? state.browse : Browse::Songs; + queue_playlist_ = state.playlist_id; + copy_text(queue_category_, sizeof(queue_category_), state.category); + queue_count_ = queue_kind_ == Browse::PlaylistTracks ? playlists_.count(queue_playlist_) : catalog_.queue_count(queue_kind_, queue_category_); + queue_.reset(queue_count_, std::max(0, request.value), static_cast(request.id)); + if (!queue_count_ || !select_queue(queue_.current())) return false; + // Confirm the clicked id, rather than trusting a stale library row offset after a rescan. + if (state.current.id != request.id) + { + intent_.stop(); + close_decoder(); + state.playback = Playback::Error; + copy_text(state.notice, sizeof(state.notice), "Library changed. Select the song again."); + return false; + } + break; + case Action::TogglePause: + if (!state.current.id) return false; + if (intent_.wanted) + { + intent_.pause(); + state.playback = Playback::Paused; + close_decoder(); + } + else + { + intent_.play(); + state.playback = output_.blocked() ? Playback::Interrupted : Playback::Playing; + } + break; + case Action::Stop: + intent_.stop(); + close_decoder(); + clear_lyrics(); + clear_artwork(); + state.current = {}; + state.position_ms = 0; + state.playback = Playback::Stopped; + break; + case Action::Previous: + if (!queue_count_) return false; + return select_queue(queue_.previous(state.mode), intent_.wanted); + case Action::Next: + if (!queue_count_) return false; + return select_queue(queue_.next(state.mode, false), intent_.wanted); + case Action::Seek: + state.position_ms = std::clamp(std::max(0, request.value), 0, state.current.duration_ms); + close_audio(); + if (decoder_) decoder_->seek(state.position_ms); + break; + case Action::Volume: + state.volume = std::clamp(request.value, 0, 100); + break; + case Action::SetPlayMode: + state.mode = static_cast(std::clamp(request.value, 0, 2)); + break; + case Action::Rescan: + if (intent_.wanted) + { + copy_text(state.notice, sizeof(state.notice), "Pause music before scanning"); + return false; + } + close_decoder(); + if (!catalog_.begin_scan()) return false; + state.busy = true; + break; + case Action::CreatePlaylist: + { + uint64_t id = 0; + if (!playlists_.create(request.text, request.id, id)) return false; + refresh_library_ = true; + break; + } + case Action::AddToPlaylist: + if (!playlists_.add(request.playlist_id, request.id)) return false; + refresh_library_ = true; + break; + case Action::RemoveFromPlaylist: + if (!playlists_.remove(request.playlist_id, request.id)) return false; + refresh_library_ = true; + break; + case Action::DeletePlaylist: + if (!playlists_.erase(request.id)) return false; + refresh_library_ = true; + break; + case Action::RenamePlaylist: + if (!playlists_.rename(request.id, request.text)) return false; + refresh_library_ = true; + break; + case Action::MetadataPage: + { + if (request.id != state.current.id) return false; + auto* file = storage_.open(state.current.path, FileMode::Read); + if (!file) return false; + const bool ok = tags_.read(*file, state.current, &state.metadata, std::clamp(request.value, 0, 512)); + storage_.close(file); + if (!ok) return false; + ++state.metadata_revision; + break; + } + case Action::ScrollLyrics: + if (lyrics_) lyrics_->scroll(request.value); + break; + } + ++state.revision; + return true; +} +} // namespace music diff --git a/modules/core_music/src/playlists.cpp b/modules/core_music/src/playlists.cpp new file mode 100644 index 00000000..8e5ad85a --- /dev/null +++ b/modules/core_music/src/playlists.cpp @@ -0,0 +1,281 @@ +#include "music/playlists.h" +#include +#include +#include +#include +namespace music +{ +namespace +{ +constexpr const char* root = "/.trailmate/music/playlists"; +bool valid_name(const char* name) +{ + const auto length = std::strlen(name); + if (!length || length > 48) return false; + bool visible = false; + for (const uint8_t* p = reinterpret_cast(name); *p; p++) + { + if (*p < 32 || *p == 127) return false; + if (*p != ' ') visible = true; + } + return visible; +} +bool write_text(File& file, const char* text) +{ + const auto n = std::strlen(text); + return file.write(text, n) == n; +} +bool playlist_id(const char* name, uint64_t& id) +{ + if (std::strlen(name) != 20 || std::strcmp(name + 16, ".m3u")) return false; + for (unsigned i = 0; i < 16; i++) + if (!((name[i] >= '0' && name[i] <= '9') || (name[i] >= 'a' && name[i] <= 'f'))) return false; + id = std::strtoull(name, nullptr, 16); + return id != 0; +} +} // namespace +void Playlists::paths(uint64_t id) +{ + std::snprintf(path_, sizeof(path_), "%s/%016llx.m3u", root, static_cast(id)); + std::snprintf(temporary_, sizeof(temporary_), "%s.tmp", path_); + std::snprintf(backup_, sizeof(backup_), "%s.bak", path_); +} +File* Playlists::open(uint64_t id) +{ + paths(id); + if (!storage_.exists(path_) && storage_.exists(backup_)) storage_.rename(backup_, path_); + auto* file = storage_.open(path_, FileMode::Read); + reset_reader(); + if (file && file->size() > 4U * 1024U * 1024U) + { + storage_.close(file); + return nullptr; + } + return file; +} +bool Playlists::read_line(File& file) +{ + std::size_t used = 0; + bool too_long = false; + while (cursor_ < file.size()) + { + if (cache_at_ == UINT32_MAX || cursor_ < cache_at_ || cursor_ >= cache_at_ + cache_size_) + { + if (!catalog_.cooperate()) + { + read_error_ = true; + line_[0] = 0; + return false; + } + cache_at_ = cursor_; + cache_size_ = file.read_at(cursor_, cache_, std::min(sizeof(cache_), file.size() - cursor_)); + if (!cache_size_) + { + read_error_ = true; + line_[0] = 0; + return false; + } + } + const uint8_t c = cache_[cursor_++ - cache_at_]; + if (c == '\n') break; + if (c == '\r') continue; + if (used + 1 < sizeof(line_)) line_[used++] = c; + else too_long = true; + } + line_[used] = 0; + if (too_long) line_[0] = 0; + return used != 0 || cursor_ < file.size(); +} +bool Playlists::create(const char* name, uint64_t initial, uint64_t& out_id) +{ + out_id = 0; + if (!storage_.ready() || !valid_name(name)) return false; + if (!storage_.mkdir("/.trailmate") || !storage_.mkdir("/.trailmate/music") || !storage_.mkdir(root)) return false; + for (unsigned n = 0; n < 32; n++) + { + const uint64_t id = path_id(name) ^ (uint64_t(storage_.session()) << 32) ^ (++sequence_ * 0x9e3779b97f4a7c15ULL); + if (!id) continue; + paths(id); + if (storage_.exists(path_)) continue; + auto* file = storage_.open(temporary_, FileMode::Write); + if (!file) return false; + bool ok = write_text(*file, "#EXTM3U\n#PLAYLIST:") && write_text(*file, name) && write_text(*file, "\n"); + if (initial) + { + ok = ok && catalog_.find(initial, track_) && write_text(*file, track_.path) && write_text(*file, "\n"); + } + ok = file->flush() && ok; + storage_.close(file); + if (!ok || !storage_.rename(temporary_, path_)) + { + storage_.remove(temporary_); + return false; + } + out_id = id; + return true; + } + return false; +} +bool Playlists::rewrite(uint64_t id, const char* name, uint64_t remove_track, const char* add_path) +{ + if (name && !valid_name(name)) return false; + auto* original = open(id); + if (!original) return false; + auto* updated = storage_.open(temporary_, FileMode::Write); + if (!updated) + { + storage_.close(original); + return false; + } + bool ok = write_text(*updated, "#EXTM3U\n"), has_name = false, duplicate = false; + unsigned tracks = 0; + while (ok && read_line(*original)) + { + if (!std::strncmp(line_, "#PLAYLIST:", 10)) + { + ok = write_text(*updated, "#PLAYLIST:") && write_text(*updated, name ? name : line_ + 10) && write_text(*updated, "\n"); + has_name = true; + } + else if (valid_music_path(line_)) + { + if (++tracks > 10000) + { + ok = false; + break; + } + if (add_path && path_id(line_) == path_id(add_path)) duplicate = true; + if (path_id(line_) != remove_track) ok = write_text(*updated, line_) && write_text(*updated, "\n"); + } + } + ok = ok && !read_error_; + if (ok && !has_name) ok = write_text(*updated, "#PLAYLIST:") && write_text(*updated, name ? name : "Playlist") && write_text(*updated, "\n"); + if (ok && add_path && !duplicate) ok = tracks < 10000 && write_text(*updated, add_path) && write_text(*updated, "\n"); + ok = updated->flush() && ok; + storage_.close(updated); + storage_.close(original); + if (!ok) + { + storage_.remove(temporary_); + return false; + } + if (storage_.exists(backup_) && !storage_.remove(backup_)) + { + storage_.remove(temporary_); + return false; + } + if (!storage_.rename(path_, backup_)) + { + storage_.remove(temporary_); + return false; + } + if (!storage_.rename(temporary_, path_)) + { + storage_.rename(backup_, path_); + return false; + } + storage_.remove(backup_); + return true; +} +bool Playlists::add(uint64_t playlist, uint64_t song) +{ + if (!catalog_.find(song, track_)) return false; + // track_.path belongs to this object and remains stable while streaming the playlist rewrite. + return rewrite(playlist, nullptr, 0, track_.path); +} +bool Playlists::remove(uint64_t playlist, uint64_t song) { return rewrite(playlist, nullptr, song, nullptr); } +bool Playlists::rename(uint64_t playlist, const char* name) { return rewrite(playlist, name, 0, nullptr); } +bool Playlists::erase(uint64_t playlist) +{ + paths(playlist); + if (storage_.exists(backup_) && !storage_.remove(backup_)) return false; + if (storage_.exists(temporary_)) storage_.remove(temporary_); + return storage_.remove(path_); +} +uint32_t Playlists::count(uint64_t playlist) +{ + auto* file = open(playlist); + if (!file) return 0; + uint32_t count = 0; + while (read_line(*file)) + if (valid_music_path(line_) && ++count == 10000) break; + storage_.close(file); + return read_error_ ? 0 : count; +} +bool Playlists::track(uint64_t playlist, uint32_t index, Track& out) +{ + if (index >= 10000) return false; + auto* file = open(playlist); + if (!file) return false; + bool found = false; + while (read_line(*file)) + if (valid_music_path(line_) && index-- == 0) + { + found = catalog_.find(path_id(line_), out); + break; + } + storage_.close(file); + return found; +} +bool Playlists::browse(Snapshot& out, uint8_t rows) +{ + out.row_count = 0; + out.total = 0; + rows = std::min(rows, kPageRows); + if (out.browse == Browse::PlaylistTracks) + { + auto* file = open(out.playlist_id); + if (!file) return false; + while (read_line(*file)) + if (valid_music_path(line_)) + { + if (out.total == 10000) break; + const auto at = out.total++; + if (at < out.offset || out.row_count >= rows) continue; + auto& row = out.rows[out.row_count++]; + row = {}; + row.id = path_id(line_); + if (catalog_.find(row.id, track_)) + { + copy_text(row.title, sizeof(row.title), track_.title); + copy_text(row.subtitle, sizeof(row.subtitle), track_.artist); + } + else + { + const auto* name = std::strrchr(line_, '/'); + copy_text(row.title, sizeof(row.title), name ? name + 1 : line_); + copy_text(row.subtitle, sizeof(row.subtitle), "Missing from library"); + } + if (out.total == 10000) break; + } + storage_.close(file); + return !read_error_; + } + uint64_t cursor = 0; + for (;;) + { + if (!catalog_.cooperate()) return false; + const auto result = storage_.next(root, cursor, entry_); + if (result == StorageResult::End || result == StorageResult::Missing) return true; + if (result != StorageResult::Ok) return false; + uint64_t id = 0; + if (entry_.directory || !playlist_id(entry_.name, id)) continue; + const uint32_t at = out.total++; + if (at < out.offset || out.row_count >= rows) continue; + auto& row = out.rows[out.row_count++]; + row = {}; + row.id = id; + copy_text(row.title, sizeof(row.title), "Playlist"); + auto* file = open(id); + if (!file) return false; + while (read_line(*file)) + { + if (!std::strncmp(line_, "#PLAYLIST:", 10)) copy_text(row.title, sizeof(row.title), line_ + 10); + else if (valid_music_path(line_)) ++row.count; + if (row.count == 10000) break; + } + storage_.close(file); + if (read_error_) return false; + std::snprintf(row.subtitle, sizeof(row.subtitle), "%lu songs", static_cast(row.count)); + } +} +} // namespace music diff --git a/modules/core_music/src/storage.cpp b/modules/core_music/src/storage.cpp new file mode 100644 index 00000000..b51fe3a0 --- /dev/null +++ b/modules/core_music/src/storage.cpp @@ -0,0 +1,45 @@ +#include "music/storage.h" +#include +#include +namespace music +{ +uint64_t path_id(const char* text) +{ + uint64_t hash = 14695981039346656037ULL; + while (*text) + { + uint8_t c = *text++; + if (c >= 'A' && c <= 'Z') c += 32; + hash = (hash ^ c) * 1099511628211ULL; + } + return hash ? hash : 1; +} +bool valid_music_path(const char* path) +{ + if (std::strncmp(path, "/Music/", 7)) return false; + unsigned component = 0; + const char* start = path + 7; + for (const char* p = start;; ++p) + { + const uint8_t c = *p; + if (c && c < 32) return false; + if (c == '\\' || c == ':') return false; + if (!c || c == '/') + { + if (!component || (component == 1 && *start == '.') || (component == 2 && start[0] == '.' && start[1] == '.')) return false; + if (!c) return true; + component = 0; + start = p + 1; + } + else ++component; + } +} +void copy_text(char* output, std::size_t capacity, const char* text) +{ + if (!capacity) return; + std::size_t n = std::min(capacity - 1, std::strlen(text)); + while (n && (static_cast(text[n]) & 0xc0) == 0x80) --n; + std::memcpy(output, text, n); + output[n] = 0; +} +} // namespace music diff --git a/modules/core_sys/include/platform/audio/audio_focus.h b/modules/core_sys/include/platform/audio/audio_focus.h new file mode 100644 index 00000000..6290f8c8 --- /dev/null +++ b/modules/core_sys/include/platform/audio/audio_focus.h @@ -0,0 +1,42 @@ +#pragma once +#include +namespace platform::audio +{ +enum class FocusOwner : uint8_t +{ + Notification, + VoiceMessage, + Walkie, + Call, + Sstv +}; +struct FocusToken +{ + uint32_t generation = 0; + uint8_t slot = 0; + explicit operator bool() const { return generation != 0; } +}; +struct MusicFocusClient +{ + void* context = nullptr; + // Must acknowledge after closing the physical codec/I2S, with a bounded wait. + bool (*suspend)(void*) = nullptr; + // Wake only. The worker rechecks music_blocked() and the user's play intent before opening audio. + void (*resume)(void*) = nullptr; +}; +void register_music_focus_client(MusicFocusClient client); +FocusToken request_focus(FocusOwner owner); +void release_focus(FocusToken token); +bool music_blocked(); +class FocusScope +{ + FocusToken token_; + + public: + explicit FocusScope(FocusOwner owner) : token_(request_focus(owner)) {} + ~FocusScope() { release_focus(token_); } + explicit operator bool() const { return bool(token_); } + FocusScope(const FocusScope&) = delete; + FocusScope& operator=(const FocusScope&) = delete; +}; +} // namespace platform::audio diff --git a/modules/core_sys/include/platform/audio/media_volume.h b/modules/core_sys/include/platform/audio/media_volume.h new file mode 100644 index 00000000..aafcf0ac --- /dev/null +++ b/modules/core_sys/include/platform/audio/media_volume.h @@ -0,0 +1,11 @@ +#pragma once +#include +namespace platform::audio +{ +// User media/alert gain is distinct from temporary call/codec session gain. +// Registration belongs to one process-lifetime playback adapter, never a view. +using MediaVolumeObserver = void (*)(void*, uint8_t); +void observe_media_volume(MediaVolumeObserver observer, void* context); +void publish_media_volume(uint8_t percent); +uint8_t media_volume(uint8_t fallback); +} // namespace platform::audio diff --git a/modules/core_sys/include/platform/radio/radio_power.h b/modules/core_sys/include/platform/radio/radio_power.h new file mode 100644 index 00000000..1d00973c --- /dev/null +++ b/modules/core_sys/include/platform/radio/radio_power.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +namespace platform::radio +{ +// The user's RF permission and temporary exclusive ownership are separate. +// Only the radio task acknowledges a physical transition; UI never owns hardware. +class PowerPolicy +{ + static constexpr uint32_t kEnabled = 1, kApplied = 2, kExclusive = 4, kFailed = 8; + std::atomic state_{kEnabled | kApplied}; + + public: + struct Status + { + bool enabled, applied, exclusive, failed; + }; + Status status() const + { + const auto state = state_.load(); + return {bool(state & kEnabled), bool(state & kApplied), bool(state & kExclusive), bool(state & kFailed)}; + } + bool enabled() const { return state_.load() & kEnabled; } + bool request(bool enabled) + { + auto before = state_.load(); + for (;;) + { + if (before & kExclusive) return false; + const auto after = (before & ~(kEnabled | kFailed)) | (enabled ? kEnabled : 0U); + if (state_.compare_exchange_weak(before, after)) return true; + } + } + // Also permits USB storage suspension when the user already turned RF off. + bool begin_exclusive() + { + return !(state_.fetch_or(kExclusive) & kExclusive); + } + void end_exclusive() { state_.fetch_and(~kExclusive); } + void acknowledge(bool enabled) + { + auto before = state_.load(); + while (bool(before & kEnabled) == enabled) + { + const auto after = (before & ~(kApplied | kFailed)) | (enabled ? kApplied : 0U); + if (state_.compare_exchange_weak(before, after)) return; + } + } + void fail() { state_.fetch_or(kFailed); } +}; +PowerPolicy& power_policy(); +} // namespace platform::radio diff --git a/modules/core_sys/include/platform/sync/resource_lock.h b/modules/core_sys/include/platform/sync/resource_lock.h new file mode 100644 index 00000000..58819bbc --- /dev/null +++ b/modules/core_sys/include/platform/sync/resource_lock.h @@ -0,0 +1,24 @@ +#pragma once +#include +namespace platform::sync +{ +// Fixed process-lifetime locks for tiny metadata updates. Implementations must +// keep their primitive internal on ESP. Never perform I/O or callbacks inside. +enum class Resource : uint8_t +{ + AudioFocus, + MusicArtwork +}; +void enter(Resource resource); +void leave(Resource resource); +class Guard +{ + Resource resource_; + + public: + explicit Guard(Resource resource) : resource_(resource) { enter(resource_); } + ~Guard() { leave(resource_); } + Guard(const Guard&) = delete; + Guard& operator=(const Guard&) = delete; +}; +} // namespace platform::sync diff --git a/modules/core_sys/include/platform/ui/agenda_widget_runtime.h b/modules/core_sys/include/platform/ui/agenda_widget_runtime.h new file mode 100644 index 00000000..58ae3eb6 --- /dev/null +++ b/modules/core_sys/include/platform/ui/agenda_widget_runtime.h @@ -0,0 +1,12 @@ +#pragma once +namespace ui::agenda +{ +class IAgendaSource; +} +namespace platform::ui +{ +// Optional per-view adapter for targets without a resident Calendar composition. +// The creator owns the result and must release it when the widget leaves. +::ui::agenda::IAgendaSource* create_agenda_widget_source(); +void destroy_agenda_widget_source(::ui::agenda::IAgendaSource* source); +} // namespace platform::ui diff --git a/modules/core_sys/include/platform/ui/music_runtime.h b/modules/core_sys/include/platform/ui/music_runtime.h new file mode 100644 index 00000000..c89bab67 --- /dev/null +++ b/modules/core_sys/include/platform/ui/music_runtime.h @@ -0,0 +1,10 @@ +#pragma once +namespace music +{ +class IService; +} +namespace platform::ui +{ +// Resolving the stable proxy does not create a task, touch SD or initialize audio. +::music::IService* music_service(); +} // namespace platform::ui diff --git a/modules/core_sys/include/platform/ui/orientation_runtime.h b/modules/core_sys/include/platform/ui/orientation_runtime.h index 3e9fbfe4..dc520097 100644 --- a/modules/core_sys/include/platform/ui/orientation_runtime.h +++ b/modules/core_sys/include/platform/ui/orientation_runtime.h @@ -41,6 +41,10 @@ struct ScreenOrientationState }; HeadingState get_heading(); +// A view owns one balanced lease. Acquisition only schedules work; release +// stops the P4 sampling worker after the last consumer leaves. +bool acquire_heading(); +void release_heading(); void ensure_heading_runtime(); ScreenOrientationState get_screen_orientation(); void ensure_screen_orientation_runtime(); diff --git a/modules/core_sys/include/platform/ui/quick_controls_runtime.h b/modules/core_sys/include/platform/ui/quick_controls_runtime.h new file mode 100644 index 00000000..9063703e --- /dev/null +++ b/modules/core_sys/include/platform/ui/quick_controls_runtime.h @@ -0,0 +1,12 @@ +#pragma once +namespace ui::controls +{ +class IQuickControls; +} +namespace platform::ui +{ +::ui::controls::IQuickControls* quick_controls_source(); +// Pump bounded user requests from the existing application service loop, outside +// LVGL. Reading a widget never starts this work or creates another RTOS task. +void service_quick_controls(); +} // namespace platform::ui diff --git a/modules/core_sys/include/platform/ui/wifi_runtime.h b/modules/core_sys/include/platform/ui/wifi_runtime.h index 177e015f..8d2b3c6b 100644 --- a/modules/core_sys/include/platform/ui/wifi_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_runtime.h @@ -82,6 +82,9 @@ bool read_saved_profiles_into(bool* enabled, std::size_t* count, Config* profiles, std::size_t capacity); bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t count); bool apply_enabled(bool enabled); +// User switch: preserve profile order/credentials, coordinate runtime clients, +// persist the preference, and roll it back if the transition cannot start. +bool set_user_enabled(bool enabled); bool suspend_for_external_storage(ExternalStorageSuspension* out_suspension); void resume_after_external_storage(ExternalStorageSuspension* suspension); bool connect(const Config* override_config = nullptr); diff --git a/modules/core_sys/src/platform/audio/audio_focus.cpp b/modules/core_sys/src/platform/audio/audio_focus.cpp new file mode 100644 index 00000000..976d246e --- /dev/null +++ b/modules/core_sys/src/platform/audio/audio_focus.cpp @@ -0,0 +1,77 @@ +#include "platform/audio/audio_focus.h" +#include "platform/sync/resource_lock.h" +namespace platform::audio +{ +namespace +{ +struct Slot +{ + uint32_t generation = 0; + FocusOwner owner = FocusOwner::Notification; +}; +Slot slots[12]; +uint32_t sequence = 0; +MusicFocusClient music; +struct Lock +{ + platform::sync::Guard guard{platform::sync::Resource::AudioFocus}; +}; +bool blocked_locked() +{ + for (const auto& slot : slots) + if (slot.generation) return true; + return false; +} +} // namespace +void register_music_focus_client(MusicFocusClient client) +{ + Lock lock; + music = client; +} +FocusToken request_focus(FocusOwner owner) +{ + FocusToken token; + MusicFocusClient client; + { + Lock lock; + for (unsigned index = 0; index < 12; index++) + if (!slots[index].generation) + { + if (++sequence == 0) ++sequence; + slots[index] = {sequence, owner}; + token = {sequence, static_cast(index)}; + break; + } + client = music; + } + if (!token) return {}; + // Reservation is visible before waiting, so a simultaneous music open must yield. + // Callbacks are always outside the critical section, including failed-acquire rollback. + if (client.suspend && !client.suspend(client.context)) + { + release_focus(token); + return {}; + } + return token; +} +void release_focus(FocusToken token) +{ + if (!token || token.slot >= 12) return; + MusicFocusClient client; + bool resume = false; + { + Lock lock; + auto& slot = slots[token.slot]; + if (slot.generation != token.generation) return; + slot.generation = 0; + resume = !blocked_locked(); + client = music; + } + if (resume && client.resume) client.resume(client.context); +} +bool music_blocked() +{ + Lock lock; + return blocked_locked(); +} +} // namespace platform::audio diff --git a/modules/core_sys/src/platform/audio/media_volume.cpp b/modules/core_sys/src/platform/audio/media_volume.cpp new file mode 100644 index 00000000..00be1773 --- /dev/null +++ b/modules/core_sys/src/platform/audio/media_volume.cpp @@ -0,0 +1,30 @@ +#include "platform/audio/media_volume.h" +#include +namespace platform::audio +{ +namespace +{ +std::atomic value{255}; +std::atomic observer_context{nullptr}; +std::atomic observer_function{nullptr}; +} // namespace +void observe_media_volume(MediaVolumeObserver observer, void* context) +{ + observer_context.store(context); + observer_function.store(observer); + const auto current = value.load(); + if (observer && current <= 100) observer(context, current); +} +void publish_media_volume(uint8_t percent) +{ + percent = percent > 100 ? 100 : percent; + value.store(percent); + const auto observer = observer_function.load(); + if (observer) observer(observer_context.load(), percent); +} +uint8_t media_volume(uint8_t fallback) +{ + const auto current = value.load(); + return current <= 100 ? current : fallback; +} +} // namespace platform::audio diff --git a/modules/core_sys/src/platform/radio/radio_power.cpp b/modules/core_sys/src/platform/radio/radio_power.cpp new file mode 100644 index 00000000..cdfe95fb --- /dev/null +++ b/modules/core_sys/src/platform/radio/radio_power.cpp @@ -0,0 +1,10 @@ +#include "platform/radio/radio_power.h" +namespace platform::radio +{ +PowerPolicy& power_policy() +{ + // Lock-free control word stays internal on ESP; bulk widget state stays in PSRAM. + static PowerPolicy policy; + return policy; +} +} // namespace platform::radio diff --git a/modules/music_image/include/music/cover_decoder.h b/modules/music_image/include/music/cover_decoder.h new file mode 100644 index 00000000..0f1b03ac --- /dev/null +++ b/modules/music_image/include/music/cover_decoder.h @@ -0,0 +1,9 @@ +#pragma once +#include "music/reader.h" +#include "music/service.h" +namespace music +{ +// Worker-only: decode the selected APIC region, never a whole MP3 or a full-size JPEG raster. +// Returns one owned immutable reference. It can be shared with a visible player/widget. +Artwork* decode_cover(Reader& input, const Track& track, uint16_t edge, CoverResult& result, bool (*pump)(void*) = nullptr, void* context = nullptr); +} // namespace music diff --git a/modules/music_image/library.json b/modules/music_image/library.json new file mode 100644 index 00000000..82b48ea4 --- /dev/null +++ b/modules/music_image/library.json @@ -0,0 +1,6 @@ +{ + "name": "music_image", + "version": "0.1.0", + "build": {"includeDir": "include", "srcDir": "src"}, + "dependencies": [{"name":"core_music"},{"name":"core_sys"},{"name":"lvgl"}] +} diff --git a/modules/music_image/src/cover_decoder.cpp b/modules/music_image/src/cover_decoder.cpp new file mode 100644 index 00000000..47dfbdfe --- /dev/null +++ b/modules/music_image/src/cover_decoder.cpp @@ -0,0 +1,227 @@ +#include "music/cover_decoder.h" +#include "lvgl.h" +#include "platform/ui/psram_ui_lease.h" +#include "src/draw/lv_draw_buf_private.h" +#include "src/libs/tjpgd/tjpgd.h" +#define LODEPNG_NO_COMPILE_CPP +#include "src/libs/lodepng/lodepng.h" +#include +#include +namespace music +{ +namespace +{ +constexpr uint32_t kMaxPictureBytes = 2U * 1024U * 1024U; +struct JpegWorkspace +{ + Reader* reader = nullptr; + uint32_t start = 0, length = 0, cursor = 0; + Artwork* art = nullptr; + bool (*pump)(void*) = nullptr; + void* context = nullptr; + unsigned blocks = 0; + JDEC decoder{}; + alignas(8) uint8_t pool[8192]{}; +}; +std::size_t jpeg_read(JDEC* decoder, uint8_t* output, std::size_t requested) +{ + auto& self = *static_cast(decoder->device); + const auto size = std::min(requested, self.length - self.cursor); + const auto got = output ? self.reader->read_at(self.start + self.cursor, output, size) : size; + self.cursor += got; + return got; +} +uint16_t rgb565(unsigned r, unsigned g, unsigned b) { return static_cast((r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)); } +int jpeg_output(JDEC* decoder, void* bitmap, JRECT* rect) +{ + auto& self = *static_cast(decoder->device); + auto& art = *self.art; + if (self.pump && ++self.blocks % 16 == 0 && !self.pump(self.context)) return 0; + const auto* rgb = static_cast(bitmap); + const uint32_t block_w = rect->right - rect->left + 1; + // Map destination centres into this MCU. Original aspect ratio is retained. + const uint32_t first_y = static_cast(rect->top) * art.height / decoder->height; + const uint32_t last_y = std::min(art.height, static_cast(rect->bottom + 1) * art.height / decoder->height + 1); + const uint32_t first_x = static_cast(rect->left) * art.width / decoder->width; + const uint32_t last_x = std::min(art.width, static_cast(rect->right + 1) * art.width / decoder->width + 1); + for (uint32_t y = first_y; y < last_y; y++) + { + const uint32_t sy = (2 * y + 1) * decoder->height / (2 * art.height); + if (sy < rect->top || sy > rect->bottom) continue; + for (uint32_t x = first_x; x < last_x; x++) + { + const uint32_t sx = (2 * x + 1) * decoder->width / (2 * art.width); + if (sx < rect->left || sx > rect->right) continue; + const auto at = ((sy - rect->top) * block_w + sx - rect->left) * 3; + art.pixels[y * art.width + x] = rgb565(rgb[at], rgb[at + 1], rgb[at + 2]); + } + } + return 1; +} +Artwork* jpeg(Reader& input, const Track& track, uint16_t edge, CoverResult& result, bool (*pump)(void*), void* context) +{ + auto* workspace = make(); + if (!workspace) + { + result = CoverResult::OutOfMemory; + return nullptr; + } + workspace->reader = &input; + workspace->pump = pump; + workspace->context = context; + workspace->start = track.picture_offset; + workspace->length = track.picture_size; + const auto prepared = jd_prepare(&workspace->decoder, jpeg_read, workspace->pool, sizeof(workspace->pool), workspace); + if (prepared != JDR_OK) + { + result = prepared == JDR_FMT3 ? CoverResult::Unsupported : CoverResult::Invalid; + destroy(workspace); + return nullptr; + } + const auto width = workspace->decoder.width, height = workspace->decoder.height; + if (!width || !height || width > 8192 || height > 8192) + { + result = CoverResult::TooLarge; + destroy(workspace); + return nullptr; + } + const auto longest = std::max(width, height); + const auto w = std::max(1, static_cast(width) * edge / longest), h = std::max(1, static_cast(height) * edge / longest); + auto* art = make_artwork(track.id, w, h); + workspace->art = art; + if (!art) + { + result = CoverResult::OutOfMemory; + destroy(workspace); + return nullptr; + } + const auto decoded = jd_decomp(&workspace->decoder, jpeg_output, 0); + destroy(workspace); + if (decoded != JDR_OK) + { + art->drop(); + result = decoded == JDR_INTR ? CoverResult::Cancelled : CoverResult::Invalid; + return nullptr; + } + result = CoverResult::Ready; + return art; +} +Artwork* png(Reader& input, const Track& track, uint16_t edge, CoverResult& result) +{ +#if LV_USE_LODEPNG + // Existing LVGL decoder allocations use the strict PSRAM allocator while this lease is held, + // even if the user closes the player before decoding finishes. + platform::ui::PsramUiLease memory; + uint8_t header[33]; + if (track.picture_size < sizeof(header) || input.read_at(track.picture_offset, header, sizeof(header)) != sizeof(header)) + { + result = CoverResult::Invalid; + return nullptr; + } + auto* state = make(); + if (!state) + { + result = CoverResult::OutOfMemory; + return nullptr; + } + lodepng_state_init(state); + unsigned width = 0, height = 0; + const unsigned inspected = lodepng_inspect(&width, &height, state, header, sizeof(header)); + // LodePNG expands full scanlines: cap that transient raster independently of JPEG dimensions. + if (inspected || !width || !height || width > 512 || height > 512 || uint64_t(width) * height > 147456 || track.picture_size > 256U * 1024U) + { + lodepng_state_cleanup(state); + destroy(state); + result = inspected ? CoverResult::Invalid : CoverResult::TooLarge; + return nullptr; + } + auto* encoded = static_cast(allocate(track.picture_size)); + if (!encoded) + { + lodepng_state_cleanup(state); + destroy(state); + result = CoverResult::OutOfMemory; + return nullptr; + } + if (input.read_at(track.picture_offset, encoded, track.picture_size) != track.picture_size) + { + release(encoded); + lodepng_state_cleanup(state); + destroy(state); + result = CoverResult::Invalid; + return nullptr; + } + state->info_raw.colortype = LCT_RGBA; + state->info_raw.bitdepth = 8; + state->decoder.zlibsettings.max_output_size = (width * 4 + 8) * height + 1024; +#ifdef LODEPNG_COMPILE_ANCILLARY_CHUNKS + state->decoder.read_text_chunks = 0; + state->decoder.remember_unknown_chunks = 0; + state->decoder.max_icc_size = 4096; + state->decoder.max_text_size = 4096; +#endif + unsigned char* rgba = nullptr; + const auto decoded = lodepng_decode(&rgba, &width, &height, state, encoded, track.picture_size); + release(encoded); + lodepng_state_cleanup(state); + destroy(state); + // LVGL's fork of LodePNG returns a draw-buffer object, unlike upstream's byte array. + auto* decoded_buffer = reinterpret_cast(rgba); + if (decoded) + { + if (decoded_buffer) lv_draw_buf_destroy(decoded_buffer); + result = decoded == 83 ? CoverResult::OutOfMemory : CoverResult::Invalid; + return nullptr; + } + const auto* pixels = decoded_buffer->data; + const auto longest = std::max(width, height); + auto* art = make_artwork(track.id, std::max(1U, width * edge / longest), std::max(1U, height * edge / longest)); + if (art) + for (unsigned y = 0; y < art->height; y++) + for (unsigned x = 0; x < art->width; x++) + { + const auto at = (2 * y + 1) * height / (2 * art->height) * decoded_buffer->header.stride + (2 * x + 1) * width / (2 * art->width) * 4; + const unsigned a = pixels[at + 3]; + art->pixels[y * art->width + x] = rgb565((pixels[at] * a + 250 * (255 - a)) / 255, (pixels[at + 1] * a + 240 * (255 - a)) / 255, (pixels[at + 2] * a + 216 * (255 - a)) / 255); + } + lv_draw_buf_destroy(decoded_buffer); + result = art ? CoverResult::Ready : CoverResult::OutOfMemory; + return art; +#else + (void)input; + (void)track; + (void)edge; + result = CoverResult::Unsupported; + return nullptr; +#endif +} +} // namespace +Artwork* decode_cover(Reader& input, const Track& track, uint16_t edge, CoverResult& result, bool (*pump)(void*), void* context) +{ + if (!track.picture_size) + { + result = CoverResult::Missing; + return nullptr; + } + if (track.picture_size > kMaxPictureBytes) + { + result = CoverResult::TooLarge; + return nullptr; + } + if (track.picture_offset > input.size() || track.picture_size > input.size() - track.picture_offset) + { + result = CoverResult::Invalid; + return nullptr; + } + edge = std::clamp(edge, 32, 240); + if (pump && !pump(context)) + { + result = CoverResult::Cancelled; + return nullptr; + } + if (track.picture_format == PictureFormat::Jpeg) return jpeg(input, track, edge, result, pump, context); + if (track.picture_format == PictureFormat::Png) return png(input, track, edge, result); + result = CoverResult::Unsupported; + return nullptr; +} +} // namespace music diff --git a/modules/ui_presentation/include/ui_presentation/controls/quick_controls.h b/modules/ui_presentation/include/ui_presentation/controls/quick_controls.h new file mode 100644 index 00000000..b72d5f3d --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/controls/quick_controls.h @@ -0,0 +1,38 @@ +#pragma once +#include +namespace ui::controls +{ +enum class Control : uint8_t +{ + Wifi, + Lora, + Gps, + Volume, + Brightness +}; +enum class SwitchState : uint8_t +{ + Off, + On, + Changing, + Unavailable, + Failed +}; +struct Snapshot +{ + SwitchState wifi = SwitchState::Unavailable, lora = SwitchState::Unavailable, gps = SwitchState::Unavailable; + bool wifi_enabled = false, lora_enabled = false, gps_enabled = false; + bool volume_supported = false, brightness_supported = false; + uint8_t volume = 0, brightness = 50; + char message[96]{}; +}; +class IQuickControls +{ + public: + virtual ~IQuickControls() = default; + // Read a bounded runtime projection; never starts a service or scans hardware. + virtual bool snapshot(Snapshot& out) = 0; + // Values: switches 0/1, volume 0..100, brightness 10..100. Implementations coalesce writes. + virtual bool set(Control control, uint8_t value) = 0; +}; +} // namespace ui::controls diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h index 37689cbe..d661fa8d 100644 --- a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widget.h @@ -16,6 +16,8 @@ enum class WidgetKind : uint8_t Recent, Compass, Agenda, + Music, + Controls, Count }; struct WidgetHandle @@ -25,6 +27,7 @@ struct WidgetHandle void (*refresh)(void*) = nullptr; uint32_t period_ms = 1000; uint32_t next_refresh_ms = 0; + bool (*key)(void*, uint32_t) = nullptr; }; inline void* widget_allocate(std::size_t bytes) { diff --git a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h index 3b870c3b..1657211b 100644 --- a/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h +++ b/modules/ui_shared/include/ui/menu/dashboard/dashboard_widgets.h @@ -5,6 +5,14 @@ namespace ui::agenda { class IAgendaSource; } +namespace ui::controls +{ +class IQuickControls; +} +namespace music +{ +class IService; +} namespace ui::menu::dashboard { WidgetHandle create_mesh_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); @@ -12,4 +20,6 @@ WidgetHandle create_gps_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t he WidgetHandle create_recent_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); WidgetHandle create_compass_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height); WidgetHandle create_agenda_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ui::agenda::IAgendaSource* source); +WidgetHandle create_controls_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ui::controls::IQuickControls* source); +WidgetHandle create_music_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ::music::IService* source); } // namespace ui::menu::dashboard diff --git a/modules/ui_shared/include/ui/menu/menu_dashboard.h b/modules/ui_shared/include/ui/menu/menu_dashboard.h index e1de3ffd..3e9be1a6 100644 --- a/modules/ui_shared/include/ui/menu/menu_dashboard.h +++ b/modules/ui_shared/include/ui/menu/menu_dashboard.h @@ -2,12 +2,23 @@ #include "lvgl.h" -#include "ui/menu/menu_layout.h" +namespace ui::menu_layout +{ +struct InitOptions; +} namespace ui::agenda { class IAgendaSource; } +namespace ui::controls +{ +class IQuickControls; +} +namespace music +{ +class IService; +} namespace ui::menu::dashboard { @@ -19,6 +30,8 @@ void refresh_localized_text(); void setActive(bool active); // Target-owned read port; its lifetime must exceed the menu's lifetime. void bindAgendaSource(ui::agenda::IAgendaSource* source); +void bindControlsSource(ui::controls::IQuickControls* source); +void bindMusicSource(::music::IService* source); bool isFeedOpen(); bool handleKey(uint32_t key, bool at_left_edge = false); bool handleEncoder(int delta); @@ -43,6 +56,8 @@ inline void setActive(bool active) (void)active; } inline void bindAgendaSource(ui::agenda::IAgendaSource*) {} +inline void bindControlsSource(ui::controls::IQuickControls*) {} +inline void bindMusicSource(::music::IService*) {} inline bool isFeedOpen() { return false; } inline bool handleKey(uint32_t, bool = false) { return false; } inline bool handleEncoder(int) { return false; } diff --git a/modules/ui_shared/include/ui/screens/music/music_page_shell.h b/modules/ui_shared/include/ui/screens/music/music_page_shell.h new file mode 100644 index 00000000..1ede882d --- /dev/null +++ b/modules/ui_shared/include/ui/screens/music/music_page_shell.h @@ -0,0 +1,12 @@ +#pragma once +#include "lvgl.h" +namespace music +{ +class IService; +} +namespace ui::music_page +{ +void bind_service(::music::IService* service); +void enter(void* host, lv_obj_t* parent); +void exit(void* host, lv_obj_t* parent); +} // namespace ui::music_page diff --git a/modules/ui_shared/library.json b/modules/ui_shared/library.json index c94f023a..da7d1664 100644 --- a/modules/ui_shared/library.json +++ b/modules/ui_shared/library.json @@ -15,6 +15,7 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", + "-I../../modules/core_music/include", "-I../../modules/core_team/include", "-I../../modules/ui_presentation/include", "-I../../modules/chat_presentation_adapters/include", @@ -29,6 +30,7 @@ ] }, "dependencies": [ + { "name": "core_music" }, { "name": "chat_presentation_adapters" }, diff --git a/modules/ui_shared/src/ui/app_catalog_builder.cpp b/modules/ui_shared/src/ui/app_catalog_builder.cpp index 7ede061d..3020c4c4 100644 --- a/modules/ui_shared/src/ui/app_catalog_builder.cpp +++ b/modules/ui_shared/src/ui/app_catalog_builder.cpp @@ -47,6 +47,11 @@ #include "ui/screens/chat/chat_page_shell.h" #if TRAIL_MATE_ENABLE_CALCULATOR_APP #include "ui/screens/calculator/calculator_page_shell.h" +#include "ui/screens/music/music_page_shell.h" +namespace ui::assets +{ +extern "C" const lv_image_dsc_t music; +} #endif #if TRAIL_MATE_ENABLE_GEOCACHING_APP #include "ui/screens/geocaching/geocaching_page_shell.h" @@ -74,7 +79,7 @@ namespace { -constexpr size_t kMaxMenuApps = 17; +constexpr size_t kMaxMenuApps = 18; #define APP_CATALOG_LOG(...) std::printf("[UI][Catalog] " __VA_ARGS__) @@ -243,6 +248,8 @@ ui::CallbackAppScreen s_calculator_app("calculator", "Calculator", CATALOG_ICON( calculator::ui::shell::enter, calculator::ui::shell::exit, &s_menu_host); +ui::CallbackAppScreen s_music_app("music", "Music", &ui::assets::music, + ui::music_page::enter, ui::music_page::exit, &s_menu_host); #endif #if !defined(TRAIL_MATE_ENABLE_SSTV) || TRAIL_MATE_ENABLE_SSTV ui::CallbackAppScreen s_sstv_app("sstv", "SSTV", CATALOG_ICON(sstv), @@ -504,6 +511,7 @@ AppCatalog build(const FeatureFlags& flags) #endif #if TRAIL_MATE_ENABLE_CALCULATOR_APP add(&s_calculator_app); + add(&s_music_app); #endif if (flags.profile == CatalogProfile::IdfDefault && flags.include_tracker) { diff --git a/modules/ui_shared/src/ui/assets/music.c b/modules/ui_shared/src/ui/assets/music.c new file mode 100644 index 00000000..34b37614 --- /dev/null +++ b/modules/ui_shared/src/ui/assets/music.c @@ -0,0 +1,166 @@ +#ifdef __has_include + #if __has_include("lvgl.h") + #ifndef LV_LVGL_H_INCLUDE_SIMPLE + #define LV_LVGL_H_INCLUDE_SIMPLE + #endif + #endif +#endif + +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) + #include "lvgl.h" +#else + #include "lvgl/lvgl.h" +#endif + + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef LV_ATTRIBUTE_IMAGE_MUSIC +#define LV_ATTRIBUTE_IMAGE_MUSIC +#endif + +const LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST LV_ATTRIBUTE_IMAGE_MUSIC uint8_t music_map[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0xc3, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x25, 0x62, 0xc2, 0x61, 0x04, 0x62, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x45, 0x62, 0xc9, 0x62, 0xc9, 0x62, 0xe3, 0x61, 0xc3, 0x61, 0x00, 0x00, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x04, 0x62, 0x04, 0x62, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc3, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x03, 0x6a, 0xc2, 0x61, 0xc2, 0x61, 0x23, 0x6a, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x03, 0x6a, 0xc2, 0x61, 0xc2, 0x61, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x03, 0x6a, 0xa3, 0x82, 0xa5, 0xc4, 0xc6, 0xed, 0x25, 0xd5, 0x43, 0x72, 0xe3, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xe3, 0x69, 0xc3, 0x82, 0xa5, 0xc4, 0xc6, 0xed, 0x25, 0xd5, 0x43, 0x72, 0xe3, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0x43, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0x43, 0x72, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x04, 0xb4, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xa5, 0xc4, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x25, 0xd5, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xa5, 0xc4, 0x23, 0x72, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe2, 0x69, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc5, 0xcc, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc5, 0xcc, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x03, 0x6a, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x6a, 0xe3, 0x8a, 0xa5, 0xc4, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0xc6, 0xed, 0xc6, 0xed, 0xa5, 0xc4, 0x43, 0x72, 0x63, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc3, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x25, 0xd5, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x03, 0x72, 0xe3, 0x69, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0xc2, 0x61, 0xe3, 0x71, 0xe6, 0xca, 0xe6, 0xca, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0xe3, 0x69, 0x24, 0x82, 0x64, 0x9a, 0xe3, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x61, 0xe3, 0x69, 0xe3, 0x69, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc3, 0x61, 0xc2, 0x61, 0xa5, 0xc4, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23, 0x72, 0x43, 0x72, 0x04, 0xb4, 0x25, 0xd5, 0x25, 0xd5, 0xc2, 0x61, 0x45, 0xdd, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x45, 0xdd, 0xc2, 0x61, 0x25, 0xd5, 0x25, 0xd5, 0x04, 0xb4, 0x43, 0x72, 0x03, 0x6a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x03, 0x6a, 0xc2, 0x61, 0xc2, 0x61, 0x03, 0x6a, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0x03, 0x6a, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe3, 0x69, 0xa5, 0xc4, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xa5, 0xc4, 0xe3, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe3, 0x69, 0xa5, 0xc4, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x04, 0xb4, 0xe3, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x43, 0x72, 0xc6, 0xed, 0x46, 0xfe, 0x46, 0xfe, 0x46, 0xfe, 0xc6, 0xed, 0xc2, 0x61, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe3, 0x61, 0x43, 0x72, 0x04, 0xb4, 0x25, 0xd5, 0xa5, 0xc4, 0x43, 0x72, 0x23, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe3, 0x61, 0x43, 0x72, 0x04, 0xb4, 0x25, 0xd5, 0xa5, 0xc4, 0x43, 0x72, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0x23, 0x72, 0x03, 0x6a, 0x03, 0x6a, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc2, 0x61, 0xe3, 0x69, 0x03, 0x6a, 0x03, 0x6a, 0xc2, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0x7f, 0xbf, 0xdf, 0xff, 0xdf, 0xdf, 0xbf, 0x9f, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x9f, 0xff, 0xdf, 0x7f, 0x7f, 0x60, 0x40, 0x40, 0x60, 0x60, 0x9f, 0xff, 0xdf, 0xbf, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xbf, 0xff, 0x7f, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0xff, 0xbf, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xbf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7f, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0xdf, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x20, 0x00, 0x00, 0x00, 0x00, 0x20, 0xbf, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0xbf, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xdf, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6b, 0xff, 0xec, 0x9f, 0xdf, 0x9f, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb1, 0xff, 0xff, 0xf6, 0x40, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xcd, 0xed, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xdf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x9f, 0xeb, 0xff, 0xff, 0xab, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0xeb, 0xff, 0xff, 0x8f, 0x20, 0x00, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x00, 0xc7, 0xef, 0xff, 0xff, 0xff, 0xfb, 0xe3, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xe7, 0xf7, 0xff, 0xff, 0xff, 0xf7, 0xe3, 0x20, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9f, 0x9f, 0x7f, 0xf7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0x8f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0x8f, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xab, 0xab, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xcf, 0xab, 0xab, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc5, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdf, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xa7, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xca, 0xfa, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xdf, 0x9f, 0x20, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x93, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0x40, 0x9f, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x20, 0x9f, 0x9f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x90, 0xee, 0xff, 0xe3, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x20, 0xf6, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0xf0, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xf8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x40, 0x86, 0x69, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x89, 0xf9, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xe9, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe3, 0xf7, 0xff, 0xff, 0xff, 0xf7, 0xcf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe3, 0xfb, 0xff, 0xff, 0xff, 0xf7, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xcf, 0xeb, 0xc7, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0xe7, 0xeb, 0xc7, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +const lv_image_dsc_t music = { + .header.magic = LV_IMAGE_HEADER_MAGIC, + .header.cf = LV_COLOR_FORMAT_RGB565A8, + .header.flags = 0, + .header.w = 64, + .header.h = 64, + .header.stride = 128, + .data_size = sizeof(music_map), + .data = music_map, +}; + diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp index 70f99000..750b7aa5 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_agenda_widget.cpp @@ -1,3 +1,4 @@ +#include "platform/ui/agenda_widget_runtime.h" #include "ui/localization.h" #include "ui/menu/dashboard/dashboard_style.h" #include "ui/menu/dashboard/dashboard_widgets.h" @@ -15,6 +16,11 @@ struct AgendaWidgetUi : WidgetLifetime DashboardCardChrome chrome{}; lv_obj_t* text = nullptr; ui::agenda::IAgendaSource* source = nullptr; + bool owns_source = false; + ~AgendaWidgetUi() + { + if (owns_source) platform::ui::destroy_agenda_widget_source(source); + } ui::agenda::AgendaSnapshot snapshot{}; uint32_t next_query = 0; char buffer[640]{}; @@ -71,6 +77,11 @@ WidgetHandle create_agenda_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t auto* state = create_widget_state(); if (!state) return {}; state->source = source; + if (!source) + { + state->source = platform::ui::create_agenda_widget_source(); + state->owns_source = state->source != nullptr; + } state->chrome = create_card_chrome(parent, ui::i18n::tr("Agenda"), width, height); state->text = lv_label_create(state->chrome.body); style_body_label(state->text, &lv_font_montserrat_14, color_text()); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp index bc966e88..3ae4493c 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp @@ -25,6 +25,11 @@ namespace struct CompassWidgetUi : WidgetLifetime { + bool heading_lease = false; + ~CompassWidgetUi() + { + if (heading_lease) platform::ui::orientation::release_heading(); + } DashboardCardChrome chrome{}; lv_obj_t* dial = nullptr; lv_obj_t* dial_ring = nullptr; @@ -105,6 +110,7 @@ WidgetHandle create_compass_widget(lv_obj_t* parent, lv_coord_t card_w, lv_coord auto* memory = create_widget_state(); if (!memory) return {}; auto& compass = *memory; + compass.heading_lease = platform::ui::orientation::acquire_heading(); compass.chrome = create_card_chrome(parent, ::ui::i18n::tr("Mini Compass"), card_w, card_h); const lv_coord_t body_w = lv_obj_get_content_width(compass.chrome.body); diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp new file mode 100644 index 00000000..3b35e9af --- /dev/null +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp @@ -0,0 +1,189 @@ +#include "ui/localization.h" +#include "ui/menu/dashboard/dashboard_style.h" +#include "ui/menu/dashboard/dashboard_widgets.h" +#include "ui_presentation/controls/quick_controls.h" +#include +#include +#include +namespace ui::menu::dashboard +{ +namespace +{ +struct ControlsWidget : WidgetLifetime +{ + DashboardCardChrome chrome{}; + ui::controls::IQuickControls* source = nullptr; + ui::controls::Snapshot snapshot{}; + lv_obj_t *switches[3]{}, *names[3]{}, *status[3]{}; + lv_obj_t *sliders[2]{}, *values[2]{}, *message = nullptr; + bool applying = false; +}; +lv_obj_t* caption(lv_obj_t* parent, const char* text, lv_coord_t x, lv_coord_t y, lv_coord_t w, bool muted = false) +{ + auto* label = lv_label_create(parent); + style_body_label(label, muted ? &lv_font_montserrat_12 : &lv_font_montserrat_14, muted ? color_text_dim() : color_text()); + ui::i18n::set_label_text(label, text); + lv_obj_set_pos(label, x, y); + lv_obj_set_width(label, w); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + return label; +} +void changed(lv_obj_t* label, const char* text) +{ + if (std::strcmp(lv_label_get_text(label), text)) ui::i18n::set_label_text_raw(label, text); +} +void refresh_controls(void* context) +{ + auto& self = *static_cast(context); + if (self.source) self.source->snapshot(self.snapshot); + const auto& data = self.snapshot; + const ui::controls::SwitchState states[] = {data.wifi, data.lora, data.gps}; + for (unsigned i = 0; i < 3; i++) + { + const auto value = states[i]; + const bool enabled = self.source && value != ui::controls::SwitchState::Unavailable && value != ui::controls::SwitchState::Changing; + const bool on = i == 0 ? data.wifi_enabled : i == 1 ? data.lora_enabled + : data.gps_enabled; + const char* text = value == ui::controls::SwitchState::Changing ? "Switching..." : value == ui::controls::SwitchState::Failed ? "Failed" + : value == ui::controls::SwitchState::Unavailable ? "Unavailable" + : on ? "On" + : "Off"; + changed(self.status[i], ui::i18n::tr(text)); + lv_obj_set_style_bg_color(self.switches[i], on ? color_soft_amber() : color_panel_bg(), 0); + if (enabled) lv_obj_remove_state(self.switches[i], LV_STATE_DISABLED); + else lv_obj_add_state(self.switches[i], LV_STATE_DISABLED); + } + self.applying = true; + for (unsigned i = 0; i < 2; i++) + { + const auto value = i ? data.brightness : data.volume; + const bool enabled = self.source && (i ? data.brightness_supported : data.volume_supported); + if (enabled) lv_obj_remove_state(self.sliders[i], LV_STATE_DISABLED); + else lv_obj_add_state(self.sliders[i], LV_STATE_DISABLED); + if (!lv_obj_has_state(self.sliders[i], LV_STATE_PRESSED)) lv_slider_set_value(self.sliders[i], value, LV_ANIM_OFF); + char percent[16]; + std::snprintf(percent, sizeof(percent), enabled ? "%u%%" : "--", lv_slider_get_value(self.sliders[i])); + changed(self.values[i], percent); + } + self.applying = false; + changed(self.message, data.message); + if (data.message[0]) lv_obj_remove_flag(self.message, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(self.message, LV_OBJ_FLAG_HIDDEN); +} +void switch_event(lv_event_t* event) +{ + auto& self = *static_cast(lv_event_get_user_data(event)); + if (!self.source) return; + const auto index = reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + const bool old = index == 0 ? self.snapshot.wifi_enabled : index == 1 ? self.snapshot.lora_enabled + : self.snapshot.gps_enabled; + if (!self.source->set(static_cast(index), old ? 0 : 1)) + std::snprintf(self.snapshot.message, sizeof(self.snapshot.message), "Change could not be applied"); + refresh_controls(&self); +} +void slider_event(lv_event_t* event) +{ + auto& self = *static_cast(lv_event_get_user_data(event)); + if (self.applying) return; + auto* slider = lv_event_get_target_obj(event); + const unsigned index = slider == self.sliders[1] ? 1 : 0; + char percent[16]; + std::snprintf(percent, sizeof(percent), "%d%%", static_cast(lv_slider_get_value(slider))); + ui::i18n::set_label_text_raw(self.values[index], percent); + const auto* input = lv_indev_active(); + const bool keyboard = input && lv_indev_get_type(input) != LV_INDEV_TYPE_POINTER; + if (self.source && (lv_event_get_code(event) == LV_EVENT_RELEASED || keyboard)) + if (!self.source->set(index ? ui::controls::Control::Brightness : ui::controls::Control::Volume, lv_slider_get_value(slider))) + ui::i18n::set_label_text(self.message, "Change could not be applied"); +} +bool controls_key(void* context, uint32_t key) +{ + auto& self = *static_cast(context); + if (!self.source) return false; + using namespace ui::controls; + bool handled = true; + if (key >= '1' && key <= '3') + { + const auto state = key == '1' ? self.snapshot.wifi : key == '2' ? self.snapshot.lora + : self.snapshot.gps; + if (state != SwitchState::Unavailable && state != SwitchState::Changing) + self.source->set(static_cast(key - '1'), (key == '1' ? self.snapshot.wifi_enabled : key == '2' ? self.snapshot.lora_enabled + : self.snapshot.gps_enabled) + ? 0 + : 1); + } + else if (key == '+' || key == '-' || key == '=') self.source->set(Control::Volume, std::clamp(self.snapshot.volume + (key == '-' ? -5 : 5), 0, 100)); + else if (key == '[' || key == ']') self.source->set(Control::Brightness, std::clamp(self.snapshot.brightness + (key == '[' ? -5 : 5), 10, 100)); + else handled = false; + if (handled) refresh_controls(&self); + return handled; +} +} // namespace +WidgetHandle create_controls_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ui::controls::IQuickControls* source) +{ + auto* self = create_widget_state(); + if (!self) return {}; + self->source = source; + self->chrome = create_card_chrome(parent, ui::i18n::tr("Quick controls"), width, height); + auto* body = self->chrome.body; + // This widget has no persistent footer content. Reclaim the shared chrome's footer entirely. + lv_obj_delete(self->chrome.footer); + self->chrome.footer = nullptr; + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_height(body, lv_obj_get_content_height(self->chrome.card) - lv_obj_get_y(body)); + lv_obj_update_layout(body); + const auto w = lv_obj_get_content_width(body), h = lv_obj_get_content_height(body); + const bool tall = h > 180; + const lv_coord_t content_w = std::min(w, tall ? 560 : 560), x = (w - content_w) / 2; + const lv_coord_t gap = tall ? 16 : 8, toggle_w = (content_w - 2 * gap) / 3, toggle_h = tall ? 76 : 44; + const lv_coord_t top = tall ? std::max(8, (h - 278) / 2) : std::max(0, (h - 96) / 2); + const char* names[] = {"Wi-Fi", "LoRa", "GPS"}; + for (unsigned i = 0; i < 3; i++) + { + auto* button = lv_button_create(body); + self->switches[i] = button; + lv_group_remove_obj(button); + lv_obj_set_pos(button, x + i * (toggle_w + gap), top); + lv_obj_set_size(button, toggle_w, toggle_h); + lv_obj_set_style_pad_all(button, 0, 0); + lv_obj_set_style_shadow_width(button, 0, 0); + lv_obj_set_style_radius(button, 10, 0); + lv_obj_set_style_border_width(button, 1, 0); + lv_obj_set_style_border_color(button, color_line(), 0); + self->names[i] = caption(button, names[i], 4, tall ? 16 : 3, toggle_w - 8); + self->status[i] = caption(button, "--", 4, tall ? 42 : 22, toggle_w - 8, true); + lv_obj_set_style_text_align(self->names[i], LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_align(self->status[i], LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_user_data(button, reinterpret_cast(i)); + lv_obj_add_event_cb(button, switch_event, LV_EVENT_CLICKED, self); + } + const lv_coord_t slider_w = tall ? content_w : (content_w - gap) / 2; + for (unsigned i = 0; i < 2; i++) + { + const lv_coord_t sx = tall ? x : x + i * (slider_w + gap), sy = top + toggle_h + (tall ? 38 + static_cast(i) * 82 : 8); + caption(body, i ? "Brightness" : "Volume", sx, sy, slider_w - 42); + self->values[i] = caption(body, "--", sx + slider_w - 40, sy, 40, true); + lv_obj_set_style_text_align(self->values[i], LV_TEXT_ALIGN_RIGHT, 0); + auto* slider = lv_slider_create(body); + self->sliders[i] = slider; + lv_group_remove_obj(slider); + lv_obj_set_pos(slider, sx + 7, sy + (tall ? 34 : 26)); + lv_obj_set_size(slider, slider_w - 14, tall ? 8 : 6); + lv_slider_set_range(slider, i ? 10 : 0, 100); + lv_obj_set_style_bg_color(slider, color_line(), LV_PART_MAIN); + lv_obj_set_style_bg_color(slider, color_amber(), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(slider, color_amber_dark(), LV_PART_KNOB); + lv_obj_set_style_pad_all(slider, 4, LV_PART_KNOB); + lv_obj_set_ext_click_area(slider, 10); + lv_obj_add_event_cb(slider, slider_event, LV_EVENT_VALUE_CHANGED, self); + lv_obj_add_event_cb(slider, slider_event, LV_EVENT_RELEASED, self); + } + self->message = caption(body, "", 0, h - 16, LV_PCT(100), true); + lv_obj_add_flag(self->message, LV_OBJ_FLAG_HIDDEN); + set_status_chip(self->chrome, "Device", color_soft_amber(), color_text()); + auto handle = own_widget(self->chrome.card, self, refresh_controls); + handle.key = controls_key; + refresh_controls(self); + return handle; +} +} // namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp new file mode 100644 index 00000000..22503b5f --- /dev/null +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp @@ -0,0 +1,215 @@ +#include "music/service.h" +#include "src/misc/cache/instance/lv_image_cache.h" +#include "ui/localization.h" +#include "ui/menu/dashboard/dashboard_style.h" +#include "ui/menu/dashboard/dashboard_widgets.h" +#include "ui/menu/menu_layout.h" +#include +#include +#include +namespace ui::assets +{ +extern "C" const lv_image_dsc_t music; +} +namespace ui::menu::dashboard +{ +namespace +{ +struct MusicWidget : WidgetLifetime +{ + DashboardCardChrome chrome{}; + ::music::IService* source = nullptr; + ::music::Snapshot snapshot{}; + ::music::Request request{}; + ::music::Artwork* art = nullptr; + lv_image_dsc_t image{}; + lv_obj_t *cover = nullptr, *title = nullptr, *artist = nullptr, *album = nullptr, *play = nullptr, *mode = nullptr; + uint32_t art_revision = UINT32_MAX; + uint16_t edge = 0; + ~MusicWidget() + { + lv_image_cache_drop(&image); + if (art) art->drop(); + if (source) source->detach(); + } +}; +void refresh_music(void* context) +{ + auto& self = *static_cast(context); + if (self.source) self.source->snapshot(self.snapshot); + const auto& value = self.snapshot; + const char* title = value.current.id ? value.current.title : "Choose a song"; + if (std::strcmp(lv_label_get_text(self.title), title)) ui::i18n::set_label_text_raw(self.title, title); + if (std::strcmp(lv_label_get_text(self.artist), value.current.artist)) ui::i18n::set_label_text_raw(self.artist, value.current.artist); + if (std::strcmp(lv_label_get_text(self.album), value.current.album)) ui::i18n::set_label_text_raw(self.album, value.current.album); + ui::i18n::set_label_text(self.play, value.playback == ::music::Playback::Playing || value.playback == ::music::Playback::Interrupted ? "Pause" : "Play"); + ui::i18n::set_label_text(self.mode, value.mode == ::music::PlayMode::RepeatOne ? "Repeat one" : value.mode == ::music::PlayMode::Shuffle ? "Shuffle" + : "Repeat all"); + const char* status = !self.source ? "Unavailable" : value.playback == ::music::Playback::Interrupted ? "Voice / radio" + : value.playback == ::music::Playback::Playing ? "Playing" + : value.current.id ? "Paused" + : "Idle"; + set_status_chip(self.chrome, status, color_soft_amber(), color_text()); + if (self.source && self.art_revision != value.artwork_revision) + { + lv_obj_add_flag(self.cover, LV_OBJ_FLAG_HIDDEN); + lv_image_set_src(self.cover, nullptr); + lv_image_cache_drop(&self.image); + if (self.art) + { + self.art->drop(); + self.art = nullptr; + } + self.art = self.source->acquire_artwork(); + self.art_revision = value.artwork_revision; + if (self.art && self.art->track_id == value.current.id) + { + self.image = {}; + self.image.header.magic = LV_IMAGE_HEADER_MAGIC; + self.image.header.cf = LV_COLOR_FORMAT_RGB565; + self.image.header.w = self.art->width; + self.image.header.h = self.art->height; + self.image.header.stride = self.art->width * 2; + self.image.data_size = self.art->width * self.art->height * 2; + self.image.data = reinterpret_cast(self.art->pixels); + lv_image_set_src(self.cover, &self.image); + lv_image_set_scale(self.cover, self.edge * 256 / std::max(self.art->width, self.art->height)); + lv_obj_center(self.cover); + lv_obj_remove_flag(self.cover, LV_OBJ_FLAG_HIDDEN); + } + } +} +void send(MusicWidget& self, unsigned action) +{ + if (action == 4 || (action == 1 && !self.snapshot.current.id)) + { + ui::menu_layout::launchAppByStableId("music"); + return; + } + if (!self.source) return; + self.request = {}; + self.request.action = action == 0 ? ::music::Action::Previous : action == 1 ? ::music::Action::TogglePause + : action == 2 ? ::music::Action::Next + : ::music::Action::SetPlayMode; + if (action == 3) self.request.value = (static_cast(self.snapshot.mode) + 1) % 3; + self.source->send(self.request); + refresh_music(&self); +} +void clicked(lv_event_t* event) +{ + send(*static_cast(lv_event_get_user_data(event)), reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); +} +bool music_key(void* context, uint32_t key) +{ + auto& self = *static_cast(context); + if (key == ' ') + { + send(self, 1); + return true; + } + if (key == 'j' || key == 'J') + { + send(self, 0); + return true; + } + if (key == 'k' || key == 'K') + { + send(self, 2); + return true; + } + if (key == 'r' || key == 'R') + { + send(self, 3); + return true; + } + if (key == LV_KEY_ENTER) + { + send(self, 4); + return true; + } + return false; +} +lv_obj_t* text(lv_obj_t* parent, const char* value, lv_coord_t x, lv_coord_t y, lv_coord_t w, bool muted = false) +{ + auto* label = lv_label_create(parent); + lv_obj_set_pos(label, x, y); + lv_obj_set_width(label, w); + style_body_label(label, muted ? &lv_font_montserrat_12 : &lv_font_montserrat_14, muted ? color_text_dim() : color_text()); + ui::i18n::set_label_text(label, value); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + return label; +} +} // namespace +WidgetHandle create_music_widget(lv_obj_t* parent, lv_coord_t width, lv_coord_t height, ::music::IService* source) +{ + auto* self = create_widget_state(); + if (!self) return {}; + self->source = source; + self->chrome = create_card_chrome(parent, ui::i18n::tr("Music"), width, height); + auto* body = self->chrome.body; + const auto w = lv_obj_get_content_width(body), h = lv_obj_get_content_height(body); + const bool tall = h > 180; + self->edge = tall ? std::min(192, h - 140) : std::min(72, h - 36); + const lv_coord_t content_w = std::min(w, 560), x = (w - content_w) / 2, top = tall ? (h - self->edge - 120) / 2 : 0; + auto* frame = lv_obj_create(body); + lv_obj_set_pos(frame, x, top); + lv_obj_set_size(frame, self->edge, self->edge); + lv_obj_set_style_pad_all(frame, 0, 0); + lv_obj_set_style_border_width(frame, 0, 0); + lv_obj_set_style_radius(frame, 8, 0); + lv_obj_clear_flag(frame, LV_OBJ_FLAG_SCROLLABLE); + auto* icon = lv_image_create(frame); + lv_image_set_src(icon, &ui::assets::music); + lv_obj_center(icon); + self->cover = lv_image_create(frame); + lv_obj_center(self->cover); + lv_obj_add_flag(self->cover, LV_OBJ_FLAG_HIDDEN); + const lv_coord_t tx = x + self->edge + 16, tw = content_w - self->edge - 16; + self->title = text(body, "Choose a song", tx, top + (tall ? 30 : 0), tw); + self->artist = text(body, "", tx, top + (tall ? 60 : 24), tw, true); + self->album = text(body, "", tx, top + (tall ? 84 : 44), tw, true); + const char* names[] = {LV_SYMBOL_PREV, "Play", LV_SYMBOL_NEXT, "Repeat all", "Open"}; + const lv_coord_t widths[] = {36, 64, 36, 90, 48}; + const lv_coord_t total = 36 + 64 + 36 + 90 + 48 + 4 * 6; + // Small TDeck cards expose Open in the footer rather than squeezing five controls. + const unsigned controls = content_w >= total ? 5 : 4; + const lv_coord_t used = controls == 5 ? total : 36 + 64 + 36 + 90 + 3 * 6; + const lv_coord_t by = tall ? top + self->edge + 42 : h - 27; + lv_coord_t bx = x + (content_w - used) / 2; + for (unsigned i = 0; i < controls; i++) + { + auto* button = lv_button_create(body); + lv_group_remove_obj(button); + lv_obj_set_pos(button, bx, by); + lv_obj_set_size(button, widths[i], 24); + bx += widths[i] + 6; + lv_obj_set_style_pad_all(button, 0, 0); + lv_obj_set_style_shadow_width(button, 0, 0); + lv_obj_set_style_bg_color(button, i == 1 ? color_amber() : color_soft_amber(), 0); + auto* label = text(button, names[i], 0, 0, LV_SIZE_CONTENT); + lv_obj_center(label); + if (i == 1) self->play = label; + if (i == 3) self->mode = label; + lv_obj_set_user_data(button, reinterpret_cast(i)); + lv_obj_add_event_cb(button, clicked, LV_EVENT_CLICKED, self); + } + auto* footer = text(self->chrome.footer, "Tap to open Music", 0, 0, LV_PCT(100), true); + lv_obj_add_flag(footer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_user_data(footer, reinterpret_cast(4)); + lv_obj_add_event_cb(footer, clicked, LV_EVENT_CLICKED, self); +#if defined(ARDUINO_T_LORA_PAGER) + ui::i18n::set_label_text(footer, "Space play J/K tracks R mode Enter open"); +#endif + // Observe existing playback only. A widget never scans or opens the song library. + if (source) + { + source->attach(0); + source->request_lyrics(false); + source->request_artwork(self->edge); + } + auto handle = own_widget(self->chrome.card, self, refresh_music, 500); + handle.key = music_key; + refresh_music(self); + return handle; +} +} // namespace ui::menu::dashboard diff --git a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp index 5a10f5bd..91926fc1 100644 --- a/modules/ui_shared/src/ui/menu/menu_dashboard.cpp +++ b/modules/ui_shared/src/ui/menu/menu_dashboard.cpp @@ -1,4 +1,7 @@ #include "ui/menu/menu_dashboard.h" +#include "platform/ui/music_runtime.h" +#include "platform/ui/quick_controls_runtime.h" +#include "ui/menu/menu_layout.h" #if defined(TRAIL_MATE_ESP_BOARD_TAB5) || defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) || defined(ARDUINO_T_LORA_PAGER) #include "ui/app_runtime.h" @@ -20,6 +23,8 @@ namespace constexpr uint32_t kDashboardTimerMs = 1000; constexpr lv_coord_t kPagerHeight = 24; ui::agenda::IAgendaSource* s_agenda_source = nullptr; +ui::controls::IQuickControls* s_controls_source = nullptr; +::music::IService* s_music_source = nullptr; bool dashboard_enabled() { @@ -65,6 +70,10 @@ WidgetHandle create_widget(WidgetKind kind, lv_obj_t* parent, lv_coord_t width, return create_compass_widget(parent, width, height); case WidgetKind::Agenda: return create_agenda_widget(parent, width, height, s_agenda_source); + case WidgetKind::Music: + return create_music_widget(parent, width, height, s_music_source ? s_music_source : platform::ui::music_service()); + case WidgetKind::Controls: + return create_controls_widget(parent, width, height, s_controls_source ? s_controls_source : platform::ui::quick_controls_source()); default: return {}; } @@ -172,6 +181,9 @@ void menu_pointer(lv_event_t* event) if (code == LV_EVENT_PRESSED) { state->pressed = false; + // A slider owns its complete drag, even when a page threshold is crossed. + for (auto* object = lv_indev_get_active_obj(); object; object = lv_obj_get_parent(object)) + if (lv_obj_check_type(object, &lv_slider_class)) return; lv_area_t area; lv_obj_get_coords(ui::menu_layout::menuPanel(), &area); if (point.x < area.x1 || point.x > area.x2 || point.y < area.y1 || point.y > area.y2) return; @@ -224,6 +236,8 @@ void panel_deleted(lv_event_t* event) } // namespace void bindAgendaSource(ui::agenda::IAgendaSource* source) { s_agenda_source = source; } +void bindControlsSource(ui::controls::IQuickControls* source) { s_controls_source = source; } +void bindMusicSource(::music::IService* source) { s_music_source = source; } bool isFeedOpen() { @@ -246,6 +260,8 @@ bool handleKey(uint32_t key, bool at_left_edge) (lv_group_get_default() && lv_group_get_default() != menu_g)) return false; if (state->feed_open) { + for (auto& widget : state->widgets) + if (widget.key && widget.key(widget.state, key)) return true; if (key == 'w' || key == 'W' || key == LV_KEY_UP || key == LV_KEY_PREV) change_page(-1); else if (key == 's' || key == 'S' || key == LV_KEY_DOWN || key == LV_KEY_NEXT) change_page(1); else if (key == 'd' || key == 'D' || key == LV_KEY_RIGHT || key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) set_feed_open(false); diff --git a/modules/ui_shared/src/ui/menu/menu_layout.cpp b/modules/ui_shared/src/ui/menu/menu_layout.cpp index 9c957f38..4aa2305d 100644 --- a/modules/ui_shared/src/ui/menu/menu_layout.cpp +++ b/modules/ui_shared/src/ui/menu/menu_layout.cpp @@ -47,7 +47,7 @@ constexpr const char* kTag = "ui-menu-layout"; #define MENU_LAYOUT_DIAG(...) #endif -constexpr size_t kMaxMenuApps = 17; +constexpr size_t kMaxMenuApps = 18; constexpr const char* kBottomHelpPlainPrefix = "H Help"; constexpr const char* kBottomHelpRichPrefix = "#D6403A H#elp"; @@ -578,8 +578,8 @@ void menuButtonClickCallback(lv_event_t* e) #if defined(ESP_PLATFORM) ESP_LOGI(kTag, "direct app enter target=%s", target_app ? target_app->name() : "(null)"); #endif - ui_switch_to_app(target_app, s_app_panel); menuHidden(); + ui_switch_to_app(target_app, s_app_panel); } void positionChatBadge(lv_obj_t* badge, lv_obj_t* icon) @@ -1377,8 +1377,9 @@ bool launchAppByStableId(const char* stable_id) } set_default_group(nullptr); - ui_switch_to_app(app, s_app_panel); + // Release widget subscriptions before the app attaches to a service. menuHidden(); + ui_switch_to_app(app, s_app_panel); return ui_get_active_app() == app; } return false; diff --git a/modules/ui_shared/src/ui/screens/music/music_page_input.cpp b/modules/ui_shared/src/ui/screens/music/music_page_input.cpp new file mode 100644 index 00000000..e3d12da1 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/music/music_page_input.cpp @@ -0,0 +1,308 @@ +#include "music_state.h" +#include "ui/app_runtime.h" +#include "ui/ui_common.h" +#include +#include + +namespace ui::music_page +{ +bool command(::music::Action action, uint64_t id, int32_t value, const char* text) +{ + auto& self = *state(); + if (!self.service) return false; + self.request = {}; + self.request.action = action; + self.request.id = id; + self.request.value = value; + self.request.browse = self.snapshot.browse; + self.request.playlist_id = self.snapshot.playlist_id; + std::snprintf(self.request.text, sizeof(self.request.text), "%s", text); + if (!self.service->send(self.request)) + { + std::snprintf(self.snapshot.notice, sizeof(self.snapshot.notice), "Music is busy. Try again."); + update(); + return false; + } + self.service->snapshot(self.snapshot); + if ((self.view == View::Playing || self.view == View::Details) && self.rendered_track != self.snapshot.current.id) draw(); + else update(); + return true; +} +void navigate(View view) +{ + auto& self = *state(); + self.menu.close(); + self.view = view; + draw(); +} +void browse(::music::Browse kind, const char* category, uint64_t playlist, uint32_t offset) +{ + auto& self = *state(); + if (!self.service) return; + self.request = {}; + self.request.action = ::music::Action::Browse; + self.request.browse = kind; + self.request.id = playlist; + self.request.value = static_cast(offset); + std::snprintf(self.request.text, sizeof(self.request.text), "%s", category); + self.selected = 0; + self.waiting_for_library = true; + if (!self.service->send(self.request)) + { + self.waiting_for_library = false; + std::snprintf(self.snapshot.notice, sizeof(self.snapshot.notice), "Music is busy. Try again."); + update(); + return; + } + // Immediate preview/inline sources may already have completed the request. + const auto revision = self.snapshot.library_revision; + if (self.service->snapshot(self.snapshot) && self.snapshot.library_revision != revision) self.waiting_for_library = false; + if (self.waiting_for_library) + { + self.snapshot.row_count = 0; + self.snapshot.busy = true; + } + draw(); +} +void back(void*) +{ + auto* self = state(); + if (!self) return; + if (self->menu.is_open()) + { + self->menu.close(); + return; + } + switch (self->view) + { + case View::Lyrics: + case View::Details: + navigate(View::Playing); + return; + case View::NewPlaylist: + self->view = self->adding_track ? View::ChoosePlaylist : View::Library; + browse(::music::Browse::Playlists); + return; + case View::ConfirmDelete: + navigate(View::Library); + return; + case View::ChoosePlaylist: + self->adding_track = 0; + self->view = View::Library; + browse(::music::Browse::Songs); + return; + case View::Library: + if (self->snapshot.browse >= ::music::Browse::PlaylistTracks) + { + browse(::music::Browse::Songs); + return; + } + break; + default: + break; + } + if (self->host) ui::page::request_exit(self->host); + else ui_request_exit_to_menu(); +} +void editor_ready(lv_event_t*) +{ + auto& self = *state(); + if (!self.editor) return; + if (!command(::music::Action::CreatePlaylist, self.adding_track, 0, lv_textarea_get_text(self.editor))) return; + self.adding_track = 0; + self.view = View::Library; + browse(::music::Browse::Playlists); +} +void action_event(lv_event_t* event) +{ + auto* self = state(); + if (!self) return; + const auto action = reinterpret_cast(lv_event_get_user_data(event)); + if (action == 99) + { + if (self->progress && self->snapshot.current.duration_ms) + command(::music::Action::Seek, 0, static_cast(lv_slider_get_value(self->progress)) * self->snapshot.current.duration_ms / 1000); + return; + } + switch (action) + { + case PlayMode: + command(::music::Action::SetPlayMode, 0, (static_cast(self->snapshot.mode) + 1) % 3); + break; + case Toggle: + if (self->view != View::Playing) navigate(View::Playing); + else command(::music::Action::TogglePause); + break; + case Previous: + command(::music::Action::Previous); + break; + case Next: + command(::music::Action::Next); + break; + case Library: + if (self->view == View::Library || self->view == View::ChoosePlaylist) self->menu.toggle(); + else + { + self->view = View::Library; + browse(::music::Browse::Songs); + } + break; + case Lyrics: + navigate(View::Lyrics); + break; + case OpenMenu: + // bottom_bar::Menu owns its anchor's click callback. + break; + case Songs: + case Artists: + case Albums: + case Folders: + case Playlists: + self->view = View::Library; + self->adding_track = 0; + browse(static_cast<::music::Browse>(action - Songs)); + break; + case NewPlaylist: + navigate(View::NewPlaylist); + break; + case AddToPlaylist: + { + const auto index = reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + self->adding_track = self->view == View::Playing ? self->snapshot.current.id : index && index <= self->snapshot.row_count ? self->snapshot.rows[index - 1].id + : self->selected; + self->view = View::ChoosePlaylist; + browse(::music::Browse::Playlists); + break; + } + case Details: + command(::music::Action::MetadataPage, self->snapshot.current.id); + if (self->service) self->service->snapshot(self->snapshot); + navigate(View::Details); + break; + case Rescan: + command(::music::Action::Rescan); + self->menu.close(); + break; + case Stop: + command(::music::Action::Stop); + self->view = View::Library; + browse(::music::Browse::Songs); + break; + case VolumeDown: + case VolumeUp: + command(::music::Action::Volume, 0, std::clamp(self->snapshot.volume + (action == VolumeUp ? 5 : -5), 0, 100)); + self->menu.close(); + break; + case PagePrevious: + case PageNext: + { + if (self->view == View::Details) + { + const int32_t offset = std::clamp(static_cast(self->snapshot.metadata.offset) + (action == PageNext ? ::music::kMetadataRows : -static_cast(::music::kMetadataRows)), 0, self->snapshot.metadata.total ? ((self->snapshot.metadata.total - 1) / ::music::kMetadataRows) * ::music::kMetadataRows : 0); + command(::music::Action::MetadataPage, self->snapshot.current.id, offset); + return; + } + const int32_t offset = std::clamp(static_cast(self->snapshot.offset) + (action == PageNext ? self->visible_rows : -self->visible_rows), 0, + self->snapshot.total ? ((self->snapshot.total - 1) / self->visible_rows) * self->visible_rows : 0); + browse(self->snapshot.browse, self->snapshot.category, self->snapshot.playlist_id, offset); + break; + } + case CreatePlaylist: + editor_ready(nullptr); + break; + case Cancel: + back(nullptr); + break; + case DeletePlaylist: + navigate(View::ConfirmDelete); + break; + case ConfirmDelete: + command(::music::Action::DeletePlaylist, self->snapshot.playlist_id); + self->view = View::Library; + browse(::music::Browse::Playlists); + break; + default: + break; + } +} +void row_event(lv_event_t* event) +{ + auto& self = *state(); + const auto index = reinterpret_cast(lv_event_get_user_data(event)); + if (index >= self.snapshot.row_count) return; + const auto& row = self.snapshot.rows[index]; + self.selected = row.id; + if (lv_event_get_code(event) != LV_EVENT_CLICKED || self.menu.is_open()) return; + if (self.view == View::ChoosePlaylist) + { + self.snapshot.playlist_id = row.id; + if (!command(::music::Action::AddToPlaylist, self.adding_track)) return; + self.adding_track = 0; + self.view = View::Library; + browse(::music::Browse::Playlists); + return; + } + using Browse = ::music::Browse; + switch (self.snapshot.browse) + { + case Browse::Artists: + browse(Browse::ArtistTracks, row.title); + break; + case Browse::Albums: + browse(Browse::AlbumTracks, row.title); + break; + case Browse::Folders: + browse(Browse::FolderTracks, row.title); + break; + case Browse::Playlists: + browse(Browse::PlaylistTracks, row.title, row.id); + break; + default: + if (!command(::music::Action::Play, row.id, static_cast(self.snapshot.offset + index))) return; + navigate(View::Playing); + break; + } +} +void key_event(lv_event_t* event) +{ + auto* self = state(); + if (!self) return; + const auto key = lv_event_get_key(event); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) + { + back(nullptr); + lv_event_stop_processing(event); + return; + } + if (self->menu.is_open()) return; + if (key == ' ') + { + command(::music::Action::TogglePause); + return; + } + if (key == 'l' || key == 'L') + { + navigate(View::Lyrics); + return; + } + if (key == 'b' || key == 'B') + { + self->view = View::Library; + browse(::music::Browse::Songs); + return; + } + if (self->view == View::Playing) + { + if (key == 'a' || key == LV_KEY_LEFT) command(::music::Action::Previous); + else if (key == 'd' || key == LV_KEY_RIGHT) command(::music::Action::Next); + else if (key == 'w' || key == LV_KEY_UP) command(::music::Action::Volume, 0, std::min(100, static_cast(self->snapshot.volume) + 5)); + else if (key == 's' || key == LV_KEY_DOWN) command(::music::Action::Volume, 0, std::max(0, static_cast(self->snapshot.volume) - 5)); + } + else if (self->view == View::Details && (key == 'w' || key == LV_KEY_UP || key == 's' || key == LV_KEY_DOWN)) + lv_obj_scroll_by(lv_event_get_target_obj(event), 0, (key == 'w' || key == LV_KEY_UP) ? 40 : -40, LV_ANIM_OFF); + else if (self->view == View::Lyrics && !self->snapshot.synchronized_lyrics && (key == 'w' || key == LV_KEY_UP || key == 's' || key == LV_KEY_DOWN)) + command(::music::Action::ScrollLyrics, 0, (key == 'w' || key == LV_KEY_UP) ? -1 : 1); + else if (key == 'w' || key == LV_KEY_UP) lv_group_focus_prev(app_g); + else if (key == 's' || key == LV_KEY_DOWN) lv_group_focus_next(app_g); +} +} // namespace ui::music_page diff --git a/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp b/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp new file mode 100644 index 00000000..eade24ad --- /dev/null +++ b/modules/ui_shared/src/ui/screens/music/music_page_layout.cpp @@ -0,0 +1,492 @@ +#include "music_state.h" +#include "src/misc/cache/instance/lv_image_cache.h" +#include "ui/localization.h" +#include "ui/ui_common.h" +#include "ui/ui_theme.h" +#include "ui_lvgl_ux_packs/common/touch_text_editor.h" +#include +#include +#include +#if !LV_FONT_MONTSERRAT_12 +#define lv_font_montserrat_12 lv_font_montserrat_14 +#endif +#if !LV_FONT_MONTSERRAT_16 +#define lv_font_montserrat_16 lv_font_montserrat_14 +#endif + +namespace ui::assets +{ +extern "C" const lv_image_dsc_t music; +} +namespace ui::music_page +{ +namespace +{ +lv_obj_t* label(lv_obj_t* parent, const char* text, lv_coord_t x, lv_coord_t y, lv_coord_t width, + const lv_font_t* font = &lv_font_montserrat_14, bool muted = false) +{ + auto* object = lv_label_create(parent); + lv_obj_set_pos(object, x, y); + lv_obj_set_width(object, width); + lv_obj_set_style_text_font(object, font, 0); + lv_obj_set_style_text_color(object, muted ? ui::theme::text_muted() : ui::theme::text(), 0); + ui::i18n::set_label_text(object, text); + return object; +} +lv_obj_t* card(lv_obj_t* parent, lv_coord_t x, lv_coord_t y, lv_coord_t w, lv_coord_t h) +{ + auto* object = lv_obj_create(parent); + lv_obj_set_pos(object, x, y); + lv_obj_set_size(object, w, h); + lv_obj_set_style_pad_all(object, 0, 0); + lv_obj_set_style_radius(object, 12, 0); + lv_obj_set_style_bg_color(object, ui::theme::surface(), 0); + lv_obj_set_style_border_color(object, ui::theme::separator(), 0); + lv_obj_set_style_border_width(object, 1, 0); + lv_obj_clear_flag(object, LV_OBJ_FLAG_SCROLLABLE); + return object; +} +void changed(lv_obj_t* object, const char* text) +{ + if (object && std::strcmp(lv_label_get_text(object), text) != 0) ui::i18n::set_label_text_raw(object, text); +} +void spacer(lv_obj_t* parent) +{ + auto* object = lv_obj_create(parent); + lv_obj_set_size(object, 0, 1); + lv_obj_set_style_pad_all(object, 0, 0); + lv_obj_set_style_border_width(object, 0, 0); + lv_obj_set_style_bg_opa(object, LV_OPA_TRANSP, 0); + lv_obj_set_flex_grow(object, 1); + lv_obj_clear_flag(object, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(object, LV_OBJ_FLAG_SCROLLABLE); +} +const char* browse_name(::music::Browse kind) +{ + switch (kind) + { + case ::music::Browse::Artists: + return "Artists"; + case ::music::Browse::Albums: + return "Albums"; + case ::music::Browse::Folders: + return "Folders"; + case ::music::Browse::Playlists: + return "Playlists"; + default: + return "Songs"; + } +} +void lyrics(lv_obj_t* parent, lv_coord_t w, lv_coord_t h) +{ + auto& self = *state(); + label(parent, "Lyrics", 18, 14, w - 36, &lv_font_montserrat_14, true); + const lv_coord_t center = h / 2; + self.lyric[0] = label(parent, "", 18, center - 44, w - 36, &lv_font_montserrat_14, true); + self.lyric[1] = label(parent, "", 18, center - 14, w - 36, &lv_font_montserrat_16); + self.lyric[2] = label(parent, "", 18, center + 44, w - 36, &lv_font_montserrat_14, true); + for (auto* line : self.lyric) + { + lv_obj_set_style_text_align(line, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_height(line, line == self.lyric[1] ? 44 : 28); + lv_label_set_long_mode(line, LV_LABEL_LONG_WRAP); + } + lv_obj_set_style_text_color(self.lyric[1], lv_color_hex(0xA56818), 0); +} +void cover(lv_obj_t* parent, lv_coord_t x, lv_coord_t y, lv_coord_t edge) +{ + auto& self = *state(); + self.artwork_edge = static_cast(edge); + auto* frame = card(parent, x, y, edge, edge); + lv_obj_set_style_bg_color(frame, lv_color_hex(0xF6E7C5), 0); + lv_obj_set_style_radius(frame, 8, 0); + auto* fallback = lv_image_create(frame); + lv_image_set_src(fallback, &ui::assets::music); + lv_obj_center(fallback); + self.cover = lv_image_create(frame); + lv_obj_center(self.cover); + lv_obj_add_flag(self.cover, LV_OBJ_FLAG_HIDDEN); +} +void now_playing(lv_coord_t width, lv_coord_t height) +{ + auto& self = *state(); + const lv_coord_t margin = self.wide ? std::max(24, (width - 1040) / 2) : 8; + const lv_coord_t gap = 20; + const lv_coord_t w = self.wide ? (width - 2 * margin - gap) / 2 : width - 2 * margin; + const lv_coord_t h = self.wide ? std::min(400, height - 40) : height - 8; + const lv_coord_t y = (height - h) / 2; + auto* panel = card(self.body, margin, y, w, h); + lv_coord_t title_y = 10, text_x = 14; + // Include the slider knob's painted extent, not just its five-pixel track. + const lv_coord_t edge = self.wide ? std::min(200, h - 210) : std::min(96, h - 96); + cover(panel, self.wide ? (w - edge) / 2 : 12, self.wide ? 16 : 10, edge); + if (self.wide) + { + title_y = edge + 32; + } + else text_x = edge + 24; + const lv_coord_t text_w = w - text_x - 14; + self.title = label(panel, self.snapshot.current.title[0] ? self.snapshot.current.title : "Choose a song", + text_x, title_y, text_w, &lv_font_montserrat_16); + lv_label_set_long_mode(self.title, LV_LABEL_LONG_DOT); + lv_obj_set_height(self.title, 20); + self.artist = label(panel, self.snapshot.current.artist, text_x, title_y + 26, text_w, &lv_font_montserrat_14, true); + lv_label_set_long_mode(self.artist, LV_LABEL_LONG_DOT); + self.album = label(panel, self.snapshot.current.album, text_x, title_y + 46, text_w - 44, &lv_font_montserrat_12, true); + lv_label_set_long_mode(self.album, LV_LABEL_LONG_DOT); + char volume[16]; + std::snprintf(volume, sizeof(volume), "%u%%", self.snapshot.volume); + self.volume = label(panel, volume, w - 54, title_y + 46, 40, &lv_font_montserrat_12, true); + self.playback_status = label(panel, "", text_x, title_y + 64, text_w, &lv_font_montserrat_12, true); + lv_label_set_long_mode(self.playback_status, LV_LABEL_LONG_DOT); + const lv_coord_t controls_y = h - 44; + const lv_coord_t progress_y = controls_y - 34; + self.progress = lv_slider_create(panel); + lv_obj_set_pos(self.progress, 14, progress_y); + lv_obj_set_size(self.progress, w - 28, 5); + lv_slider_set_range(self.progress, 0, 1000); + lv_obj_set_style_bg_color(self.progress, ui::theme::separator(), LV_PART_MAIN); + lv_obj_set_style_bg_color(self.progress, ui::theme::accent(), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(self.progress, ui::theme::accent(), LV_PART_KNOB); + lv_obj_set_style_pad_all(self.progress, 3, LV_PART_KNOB); + lv_obj_add_event_cb(self.progress, action_event, LV_EVENT_RELEASED, reinterpret_cast(99)); + lv_obj_add_event_cb(self.progress, key_event, LV_EVENT_KEY, nullptr); + self.elapsed = label(panel, "0:00", 14, progress_y + 12, 70, &lv_font_montserrat_12, true); + self.duration = label(panel, "--:--", w - 84, progress_y + 12, 70, &lv_font_montserrat_12, true); + lv_obj_set_style_text_align(self.duration, LV_TEXT_ALIGN_RIGHT, 0); + auto* mode = button(panel, "Repeat all", PlayMode, 92); + self.mode = lv_obj_get_child(mode, 0); + auto* previous = button(panel, LV_SYMBOL_PREV, Previous, 36); + auto* play = button(panel, "Play", Toggle, 64); + auto* next = button(panel, LV_SYMBOL_NEXT, Next, 36); + self.play_label = lv_obj_get_child(play, 0); + lv_obj_set_pos(mode, (w - 256) / 2, controls_y); + lv_obj_set_pos(previous, (w - 256) / 2 + 104, controls_y); + lv_obj_set_pos(play, (w - 256) / 2 + 148, controls_y); + lv_obj_set_pos(next, (w - 256) / 2 + 220, controls_y); + for (auto* control : {mode, previous, play, next}) lv_obj_set_height(control, 30); + lv_obj_set_style_bg_color(play, ui::theme::accent(), 0); + lv_group_focus_obj(play); + if (self.wide) + { + auto* pane = card(self.body, margin + w + gap, y, w, h); + lyrics(pane, w, h); + } +} +void library(lv_coord_t width, lv_coord_t height) +{ + auto& self = *state(); + const lv_coord_t x = self.wide ? 226 : 8; + const lv_coord_t w = width - x - (self.wide ? 24 : 8); + self.notice = label(self.body, "", x + 6, 5, w - 12, &lv_font_montserrat_12, true); + if (self.wide) + { + auto* sidebar = card(self.body, 24, 20, 176, height - 40); + label(sidebar, "YOUR LIBRARY", 14, 16, 148, &lv_font_montserrat_12, true); + const char* names[] = {"Songs", "Artists", "Albums", "Folders", "Playlists"}; + for (int index = 0; index < 5; index++) + { + auto* item = button(sidebar, names[index], Songs + index, 148); + lv_obj_set_pos(item, 14, 46 + index * 44); + lv_obj_set_height(item, 34); + } + } + if (!self.service || !self.snapshot.storage_ready || self.snapshot.row_count == 0) + { + const char* message = !self.service ? "Music is unavailable on this device." : !self.snapshot.storage_ready ? "Insert an SD card" + : self.snapshot.busy ? "Reading music library..." + : self.snapshot.browse == ::music::Browse::Playlists ? "No playlists yet" + : "No songs found"; + auto* empty = label(self.body, message, x + 12, height / 2 - 16, w - 24, &lv_font_montserrat_16); + lv_obj_set_style_text_align(empty, LV_TEXT_ALIGN_CENTER, 0); + if (self.snapshot.browse == ::music::Browse::Playlists) + { + auto* create = button(self.body, "New playlist", NewPlaylist, 120); + lv_obj_set_pos(create, x + (w - 120) / 2, height / 2 + 24); + } + return; + } + const lv_coord_t row_h = (height - 32) / self.visible_rows - 4; + for (unsigned index = 0; index < self.snapshot.row_count; index++) + { + const auto& data = self.snapshot.rows[index]; + auto* row = lv_button_create(self.body); + ui::components::bottom_bar::style_button(row); + lv_obj_set_pos(row, x, 26 + index * (row_h + 4)); + lv_obj_set_size(row, w, row_h); + lv_obj_set_style_bg_color(row, ui::theme::surface(), 0); + lv_obj_set_style_bg_color(row, lv_color_hex(0xF6E7C5), LV_STATE_FOCUSED); + lv_obj_set_style_radius(row, 8, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_add_event_cb(row, row_event, LV_EVENT_CLICKED, reinterpret_cast(index)); + lv_obj_add_event_cb(row, row_event, LV_EVENT_FOCUSED, reinterpret_cast(index)); + lv_obj_add_event_cb(row, key_event, LV_EVENT_KEY, nullptr); + auto* title = label(row, data.title, 10, 3, w - 48, &lv_font_montserrat_14); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + lv_obj_set_height(title, 17); + auto* subtitle = label(row, data.subtitle, 10, row_h - 16, w - 48, &lv_font_montserrat_12, true); + lv_label_set_long_mode(subtitle, LV_LABEL_LONG_DOT); + lv_obj_set_height(subtitle, 14); + const bool tracks = self.snapshot.browse == ::music::Browse::Songs || self.snapshot.browse >= ::music::Browse::PlaylistTracks; + if (tracks && self.view != View::ChoosePlaylist) + { + auto* add = button(row, "+", AddToPlaylist, 26); + lv_obj_align(add, LV_ALIGN_RIGHT_MID, -6, 0); + lv_obj_set_user_data(add, reinterpret_cast(index + 1)); + } + if ((self.selected == data.id) || (index == 0 && self.selected == 0)) + { + self.selected = data.id; + lv_group_focus_obj(row); + } + } +} +void menu_items() +{ + auto& self = *state(); + self.menu_content = lv_obj_create(self.root); + lv_obj_set_size(self.menu_content, 180, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(self.menu_content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(self.menu_content, 4, 0); + lv_obj_set_style_pad_row(self.menu_content, 2, 0); + const auto add = [&](const char* text, int action) + {auto* item=button(self.menu_content,text,action,LV_PCT(100));lv_obj_set_height(item,24); }; + if (self.view == View::Playing) + { + add("Track info", Details); + add("Add to playlist", AddToPlaylist); + add("Volume -", VolumeDown); + add("Volume +", VolumeUp); + add("Stop playback", Stop); + } + else + { + add("Songs", Songs); + add("Artists", Artists); + add("Albums", Albums); + if (self.wide) add("Folders", Folders); + add("Playlists", Playlists); + add("New playlist", NewPlaylist); + if (self.snapshot.browse == ::music::Browse::PlaylistTracks) add("Delete playlist", DeletePlaylist); + add("Scan for music", Rescan); + } + lv_obj_add_flag(self.menu_content, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_max_height(self.menu_content, lv_display_get_vertical_resolution(nullptr) - 56, 0); +} +} // namespace +lv_obj_t* button(lv_obj_t* parent, const char* text, intptr_t action, lv_coord_t width) +{ + auto* object = lv_button_create(parent); + ui::components::bottom_bar::style_button(object); + lv_obj_set_size(object, width, 24); + lv_obj_set_style_pad_left(object, 8, 0); + lv_obj_set_style_pad_right(object, 8, 0); + lv_obj_set_style_pad_top(object, 2, 0); + lv_obj_set_style_pad_bottom(object, 2, 0); + auto* caption = label(object, text, 0, 0, LV_SIZE_CONTENT, &lv_font_montserrat_14); + lv_obj_center(caption); + lv_obj_add_event_cb(object, action_event, LV_EVENT_CLICKED, reinterpret_cast(action)); + lv_obj_add_event_cb(object, key_event, LV_EVENT_KEY, nullptr); + return object; +} +void draw() +{ + auto& self = *state(); + self.menu.reset(); + if (self.menu_content) + { + lv_obj_delete(self.menu_content); + self.menu_content = nullptr; + } + lv_obj_clean(self.body); + lv_obj_clean(self.footer); + lv_image_cache_drop(&self.cover_image); + if (self.artwork) + { + self.artwork->drop(); + self.artwork = nullptr; + } + self.cover = self.volume = self.playback_status = self.mode = nullptr; + self.artwork_edge = 0; + self.rendered_artwork = UINT32_MAX; + self.title = self.artist = self.album = self.progress = self.elapsed = self.duration = self.play_label = self.notice = self.editor = self.page_number = nullptr; + for (auto*& line : self.lyric) line = nullptr; + lv_obj_update_layout(self.body); + const auto width = lv_obj_get_content_width(self.body), height = lv_obj_get_content_height(self.body); + const char* heading = self.view == View::Playing ? "Now Playing" : self.view == View::Lyrics ? "Lyrics" + : self.view == View::Details ? "Track info" + : self.view == View::NewPlaylist ? "New playlist" + : self.view == View::ChoosePlaylist ? "Add to playlist" + : self.view == View::ConfirmDelete ? "Delete playlist" + : "Music"; + ui::widgets::top_bar_set_title(self.bar, heading); + if (self.view == View::Playing) now_playing(width, height); + else if (self.view == View::Library || self.view == View::ChoosePlaylist) library(width, height); + else if (self.view == View::Lyrics) + { + auto* panel = card(self.body, 8, 6, width - 16, height - 12); + lyrics(panel, width - 16, height - 12); + } + else if (self.view == View::NewPlaylist) + { + const lv_coord_t w = std::min(width - 24, 460); + auto* panel = card(self.body, (width - w) / 2, 12, w, std::min(height - 24, 160)); + label(panel, "Playlist name", 12, 12, w - 24); + self.editor = lv_textarea_create(panel); + lv_obj_set_pos(self.editor, 12, 40); + lv_obj_set_size(self.editor, w - 24, 36); + lv_textarea_set_one_line(self.editor, true); + lv_textarea_set_max_length(self.editor, 48); + lv_textarea_set_placeholder_text(self.editor, "e.g. Evening trail"); + lv_obj_add_event_cb(self.editor, editor_ready, LV_EVENT_READY, nullptr); + ui::widgets::attach_touch_text_editor(self.editor); + lv_group_focus_obj(self.editor); + self.notice = label(panel, "Keep a short, clear name", 12, 88, w - 24, &lv_font_montserrat_12, true); + } + else if (self.view == View::Details) + { + const lv_coord_t edge = self.wide ? 192 : 72; + auto* art_panel = card(self.body, 12, 8, edge + 16, height - 16); + cover(art_panel, 8, 8, edge); + label(art_panel, self.snapshot.current.picture_size ? "ID3 cover" : "No cover", 4, edge + 16, edge + 8, &lv_font_montserrat_12, true); + auto* panel = card(self.body, edge + 40, 8, width - edge - 52, height - 16); + lv_obj_add_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(panel, 12, 0); + lv_obj_set_style_pad_row(panel, 8, 0); + if (self.snapshot.metadata.track_id != self.snapshot.current.id || !self.snapshot.metadata.count) + label(panel, "Reading track information...", 0, 0, LV_PCT(100)); + for (unsigned index = 0; index < self.snapshot.metadata.count; index++) + { + const auto& field = self.snapshot.metadata.fields[index]; + char caption[64]; + std::snprintf(caption, sizeof(caption), "%s%s%s", field.name, field.id[0] ? " / " : "", field.id); + auto* name = label(panel, caption, 0, 0, LV_PCT(100), &lv_font_montserrat_12, true); + (void)name; + label(panel, field.value[0] ? field.value : "--", 0, 0, LV_PCT(100)); + if (field.truncated) label(panel, "Text shortened", 0, 0, LV_PCT(100), &lv_font_montserrat_12, true); + } + if (self.snapshot.metadata.unsupported_flags) label(panel, "Some compressed or encrypted tags could not be read", 0, 0, LV_PCT(100), &lv_font_montserrat_12, true); + lv_obj_add_event_cb(panel, key_event, LV_EVENT_KEY, nullptr); + lv_group_focus_obj(panel); + } + else if (self.view == View::ConfirmDelete) + { + label(self.body, "Delete this playlist?", 16, 24, width - 32, &lv_font_montserrat_16); + label(self.body, self.snapshot.category, 16, 60, width - 32); + label(self.body, "Songs on the SD card are kept.", 16, 90, width - 32, &lv_font_montserrat_12, true); + } + if (self.view == View::ConfirmDelete) + { + button(self.footer, "Cancel", Cancel); + spacer(self.footer); + button(self.footer, "Delete", ConfirmDelete); + } + else if (self.view == View::NewPlaylist) + { + button(self.footer, "Cancel", Cancel); + spacer(self.footer); + button(self.footer, "Create", CreatePlaylist); + } + else + { + lv_obj_t* anchor = nullptr; + if (self.view == View::Library) anchor = button(self.footer, "Browse", OpenMenu); + else if (self.view == View::ChoosePlaylist) button(self.footer, "Cancel", Cancel); + else button(self.footer, "Library", Library); + spacer(self.footer); + if (self.view == View::Library || self.view == View::ChoosePlaylist || self.view == View::Details) + { + button(self.footer, LV_SYMBOL_LEFT, PagePrevious, 26); + self.page_number = label(self.footer, "", 0, 0, LV_SIZE_CONTENT, &lv_font_montserrat_12, true); + button(self.footer, LV_SYMBOL_RIGHT, PageNext, 26); + if (self.view != View::Details && ::music::has_session(self.snapshot)) button(self.footer, "Playing", Toggle); + } + else if (self.view == View::Playing) button(self.footer, "Lyrics", Lyrics); + if (!anchor) anchor = button(self.footer, "Menu", OpenMenu); + menu_items(); + self.menu.init(self.root, anchor, self.menu_content); + } + self.rendered_track = self.snapshot.current.id; + self.rendered_library = self.snapshot.library_revision; + self.rendered_metadata = self.snapshot.metadata_revision; + if (self.service) + { + self.service->request_artwork(self.artwork_edge); + self.service->request_lyrics(self.view == View::Lyrics || (self.wide && self.view == View::Playing)); + } + update(); +} +void update() +{ + auto& self = *state(); + char time[32]; + std::snprintf(time, sizeof(time), "%u:%02u", self.snapshot.position_ms / 60000, (self.snapshot.position_ms / 1000) % 60); + changed(self.elapsed, time); + if (self.snapshot.current.duration_ms) + std::snprintf(time, sizeof(time), "%s%u:%02u", self.snapshot.current.estimated_duration ? "~" : "", self.snapshot.current.duration_ms / 60000, (self.snapshot.current.duration_ms / 1000) % 60); + else std::snprintf(time, sizeof(time), "--:--"); + changed(self.duration, time); + changed(self.mode, ui::i18n::tr(self.snapshot.mode == ::music::PlayMode::RepeatOne ? "Repeat one" : self.snapshot.mode == ::music::PlayMode::Shuffle ? "Shuffle" + : "Repeat all")); + std::snprintf(time, sizeof(time), "%u%%", self.snapshot.volume); + changed(self.volume, time); + changed(self.playback_status, self.snapshot.playback == ::music::Playback::Interrupted ? ui::i18n::tr("Paused for voice / radio") : self.snapshot.playback == ::music::Playback::Paused ? ui::i18n::tr("Paused") + : self.snapshot.notice); + changed(self.play_label, ui::i18n::tr(self.snapshot.playback == ::music::Playback::Playing || self.snapshot.playback == ::music::Playback::Interrupted ? "Pause" : "Play")); + if (self.progress && !lv_obj_has_state(self.progress, LV_STATE_PRESSED)) + lv_slider_set_value(self.progress, self.snapshot.current.duration_ms ? static_cast(self.snapshot.position_ms) * 1000 / self.snapshot.current.duration_ms : 0, LV_ANIM_OFF); + if (self.page_number) + { + const bool details = self.view == View::Details; + const uint32_t offset = details ? self.snapshot.metadata.offset : self.snapshot.offset; + const uint32_t total = details ? self.snapshot.metadata.total : self.snapshot.total; + const uint32_t size = details ? ::music::kMetadataRows : self.visible_rows; + std::snprintf(time, sizeof(time), "%u / %u", offset / size + 1, std::max(1, (total + size - 1) / size)); + changed(self.page_number, time); + } + if (self.notice) + { + if (self.snapshot.notice[0]) changed(self.notice, self.snapshot.notice); + else if (self.view == View::NewPlaylist) changed(self.notice, ui::i18n::tr("Keep a short, clear name")); + else + { + char caption[160]; + std::snprintf(caption, sizeof(caption), "%s | %u", self.snapshot.category[0] ? self.snapshot.category : ui::i18n::tr(browse_name(self.snapshot.browse)), self.snapshot.total); + changed(self.notice, caption); + } + } + const char* lines[] = {self.snapshot.lyric_previous, self.snapshot.has_lyrics ? self.snapshot.lyric_current : ui::i18n::tr("No lyrics found"), self.snapshot.has_lyrics ? self.snapshot.lyric_next : ui::i18n::tr("Add a .lrc file beside the song")}; + for (unsigned index = 0; index < 3; index++) changed(self.lyric[index], lines[index]); + if (self.cover && self.service && self.rendered_artwork != self.snapshot.artwork_revision) + { + // Detach LVGL before releasing the borrowed pixels. Both UI and worker hold references. + lv_obj_add_flag(self.cover, LV_OBJ_FLAG_HIDDEN); + lv_image_set_src(self.cover, nullptr); + lv_image_cache_drop(&self.cover_image); + if (self.artwork) + { + self.artwork->drop(); + self.artwork = nullptr; + } + self.artwork = self.service->acquire_artwork(); + self.rendered_artwork = self.snapshot.artwork_revision; + if (self.artwork && self.artwork->track_id == self.snapshot.current.id) + { + self.cover_image = {}; + self.cover_image.header.magic = LV_IMAGE_HEADER_MAGIC; + self.cover_image.header.cf = LV_COLOR_FORMAT_RGB565; + self.cover_image.header.w = self.artwork->width; + self.cover_image.header.h = self.artwork->height; + self.cover_image.header.stride = self.artwork->width * 2; + self.cover_image.data_size = self.artwork->width * self.artwork->height * 2; + self.cover_image.data = reinterpret_cast(self.artwork->pixels); + lv_image_set_src(self.cover, &self.cover_image); + const auto scale = static_cast(self.artwork_edge) * 256 / std::max(self.artwork->width, self.artwork->height); + lv_image_set_scale(self.cover, scale); + lv_obj_center(self.cover); + lv_obj_remove_flag(self.cover, LV_OBJ_FLAG_HIDDEN); + } + } +} +} // namespace ui::music_page diff --git a/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp b/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp new file mode 100644 index 00000000..84f80c88 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/music/music_page_shell.cpp @@ -0,0 +1,102 @@ +#include "ui/screens/music/music_page_shell.h" +#include "music_state.h" +#include "platform/ui/music_runtime.h" +#include "src/misc/cache/instance/lv_image_cache.h" +#include "ui/runtime/ui_feedback.h" +#include "ui/ui_common.h" +#include "ui/ui_theme.h" +#include + +namespace ui::music_page +{ +namespace +{ +::music::IService* bound_service = nullptr; +void deleted(lv_event_t* event) +{ + auto* self = state(); + if (!self || lv_event_get_target(event) != self->root) return; + if (self->timer) lv_timer_delete(self->timer); + self->menu.reset(); + lv_obj_clean(self->root); + lv_image_cache_drop(&self->cover_image); + if (self->artwork) + { + self->artwork->drop(); + self->artwork = nullptr; + } + if (self->service) self->service->detach(); + destroy_state(); +} +void tick(lv_timer_t*) +{ + auto* self = state(); + if (!self || !self->service || !self->service->snapshot(self->snapshot)) return; + if (self->waiting_for_library && self->snapshot.library_revision == self->rendered_library) return; + self->waiting_for_library = false; + if ((self->view == View::Library || self->view == View::ChoosePlaylist) && + self->rendered_library != self->snapshot.library_revision) + draw(); + else if ((self->view == View::Playing || self->view == View::Details) && self->rendered_track != self->snapshot.current.id) + draw(); + else if (self->view == View::Details && self->rendered_metadata != self->snapshot.metadata_revision) + draw(); + else update(); +} +} // namespace +void bind_service(::music::IService* service) { bound_service = service; } +void enter(void* host, lv_obj_t* parent) +{ + exit(nullptr, nullptr); + auto* self = create_state(); + if (!self) + { + lv_async_call([](void*) + { + ui_request_exit_to_menu(); + ui::feedback::show_notice("Not enough PSRAM for Music", 2200); }, + nullptr); + return; + } + self->service = bound_service ? bound_service : platform::ui::music_service(); + self->host = static_cast(host); + self->wide = lv_display_get_horizontal_resolution(nullptr) >= 700; + self->visible_rows = static_cast(std::clamp((lv_display_get_vertical_resolution(nullptr) - 76) / 40, 1, 8)); + if (self->service) + { + self->service->attach(self->visible_rows); + self->service->snapshot(self->snapshot); + } + self->view = ::music::has_session(self->snapshot) ? View::Playing : View::Library; + self->root = lv_obj_create(parent); + lv_obj_set_size(self->root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(self->root, 0, 0); + lv_obj_set_style_border_width(self->root, 0, 0); + lv_obj_set_style_radius(self->root, 0, 0); + lv_obj_set_style_bg_color(self->root, ui::theme::page_bg(), 0); + lv_obj_clear_flag(self->root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(self->root, deleted, LV_EVENT_DELETE, nullptr); + set_default_group(app_g); + ui::widgets::top_bar_init(self->bar, self->root); + ui::widgets::top_bar_set_back_callback(self->bar, back, nullptr); + lv_obj_add_event_cb(self->bar.back_btn, key_event, LV_EVENT_KEY, nullptr); + self->body = lv_obj_create(self->root); + lv_obj_update_layout(self->bar.container); + const lv_coord_t top_height = lv_obj_get_height(self->bar.container); + lv_obj_set_pos(self->body, 0, top_height); + lv_obj_set_size(self->body, LV_PCT(100), lv_display_get_vertical_resolution(nullptr) - top_height - 24); + lv_obj_set_style_pad_all(self->body, 0, 0); + lv_obj_set_style_border_width(self->body, 0, 0); + lv_obj_set_style_bg_opa(self->body, LV_OPA_TRANSP, 0); + lv_obj_set_style_radius(self->body, 0, 0); + lv_obj_clear_flag(self->body, LV_OBJ_FLAG_SCROLLABLE); + self->footer = ui::components::bottom_bar::create(self->root); + lv_obj_align(self->footer, LV_ALIGN_BOTTOM_MID, 0, 0); + self->timer = lv_timer_create(tick, 500, nullptr); + draw(); +} +void exit(void*, lv_obj_t*) +{ + if (auto* self = state(); self && self->root && lv_obj_is_valid(self->root)) lv_obj_delete(self->root); +} +} // namespace ui::music_page diff --git a/modules/ui_shared/src/ui/screens/music/music_state.cpp b/modules/ui_shared/src/ui/screens/music/music_state.cpp new file mode 100644 index 00000000..f3736334 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/music/music_state.cpp @@ -0,0 +1,35 @@ +#include "music_state.h" +#include +#include +#if defined(ESP_PLATFORM) +#include "esp_heap_caps.h" +#endif +namespace ui::music_page +{ +namespace +{ +State* instance = nullptr; +} +State* state() { return instance; } +State* create_state() +{ +#if defined(ESP_PLATFORM) + auto* memory = heap_caps_malloc(sizeof(State), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + auto* memory = std::malloc(sizeof(State)); +#endif + instance = memory ? new (memory) State{} : nullptr; + return instance; +} +void destroy_state() +{ + if (!instance) return; + instance->~State(); +#if defined(ESP_PLATFORM) + heap_caps_free(instance); +#else + std::free(instance); +#endif + instance = nullptr; +} +} // namespace ui::music_page diff --git a/modules/ui_shared/src/ui/screens/music/music_state.h b/modules/ui_shared/src/ui/screens/music/music_state.h new file mode 100644 index 00000000..6429389a --- /dev/null +++ b/modules/ui_shared/src/ui/screens/music/music_state.h @@ -0,0 +1,103 @@ +#pragma once +#include "music/service.h" +#include "platform/ui/psram_ui_lease.h" +#include "ui/app_runtime.h" +#include "ui/components/bottom_bar.h" +#include "ui/page/page_host.h" +#include "ui/widgets/top_bar.h" + +namespace ui::music_page +{ +enum class View : uint8_t +{ + Library, + Playing, + Lyrics, + Details, + NewPlaylist, + ChoosePlaylist, + ConfirmDelete +}; +enum UiAction : intptr_t +{ + Toggle = 1, + Previous, + Next, + Library, + Lyrics, + OpenMenu, + Songs = 10, + Artists, + Albums, + Folders, + Playlists, + NewPlaylist = 20, + AddToPlaylist, + Rescan, + Details, + Stop, + VolumeDown, + VolumeUp, + PagePrevious, + PageNext, + CreatePlaylist, + Cancel, + DeletePlaylist, + ConfirmDelete, + PlayMode +}; +struct State +{ + platform::ui::PsramUiLease memory; + ::music::IService* service = nullptr; + const ui::page::Host* host = nullptr; + lv_obj_t* root = nullptr; + lv_obj_t* body = nullptr; + lv_obj_t* footer = nullptr; + lv_obj_t* menu_content = nullptr; + lv_obj_t* title = nullptr; + lv_obj_t* artist = nullptr; + lv_obj_t* album = nullptr; + lv_obj_t* progress = nullptr; + lv_obj_t* elapsed = nullptr; + lv_obj_t* duration = nullptr; + lv_obj_t* play_label = nullptr; + lv_obj_t* cover = nullptr; + lv_obj_t* volume = nullptr; + lv_obj_t* playback_status = nullptr; + lv_obj_t* mode = nullptr; + ::music::Artwork* artwork = nullptr; + lv_image_dsc_t cover_image{}; + lv_obj_t* lyric[3]{}; + lv_obj_t* notice = nullptr; + lv_obj_t* editor = nullptr; + lv_obj_t* page_number = nullptr; + lv_timer_t* timer = nullptr; + ui::widgets::TopBar bar{}; + ui::components::bottom_bar::Menu menu; + ::music::Snapshot snapshot{}; + ::music::Request request{}; + View view = View::Library; + uint64_t selected = 0, rendered_track = 0, adding_track = 0; + uint32_t rendered_library = 0; + uint32_t rendered_artwork = UINT32_MAX, rendered_metadata = 0; + uint16_t artwork_edge = 0; + uint8_t visible_rows = 4; + bool wide = false; + bool waiting_for_library = false; +}; +State* state(); +State* create_state(); +void destroy_state(); +void draw(); +void update(); +void navigate(View view); +void browse(::music::Browse kind, const char* category = "", uint64_t playlist = 0, uint32_t offset = 0); +bool command(::music::Action action, uint64_t id = 0, int32_t value = 0, const char* text = ""); +void back(void*); +void action_event(lv_event_t* event); +void row_event(lv_event_t* event); +void key_event(lv_event_t* event); +void editor_ready(lv_event_t* event); +lv_obj_t* button(lv_obj_t* parent, const char* text, intptr_t action, lv_coord_t width = LV_SIZE_CONTENT); +} // namespace ui::music_page diff --git a/modules/ui_shared/tests/agenda/test_catalog.cpp b/modules/ui_shared/tests/agenda/test_catalog.cpp index 09d4f2de..16da1316 100644 --- a/modules/ui_shared/tests/agenda/test_catalog.cpp +++ b/modules/ui_shared/tests/agenda/test_catalog.cpp @@ -24,6 +24,7 @@ bool show_notice(const char*, uint32_t) { return true; } } STUB_PAGE(chat::ui::shell) STUB_PAGE(calculator::ui::shell) +STUB_PAGE(ui::music_page) STUB_PAGE(geocaching::ui::shell) STUB_PAGE(gps::ui::shell) namespace gps::ui::shell @@ -52,6 +53,7 @@ extern "C" #define STUB_ICON(name) extern const lv_image_dsc_t name = {} STUB_ICON(Chat); STUB_ICON(calc); + STUB_ICON(music); STUB_ICON(gps_icon); STUB_ICON(Satellite); STUB_ICON(contact); @@ -80,12 +82,12 @@ int main() ui::app_catalog_builder::FeatureFlags flags; flags.include_usb = flags.include_network = flags.include_walkie_talkie = flags.include_power_off = true; auto catalog = ui::app_catalog_builder::build(flags); - assert(ui::catalogCount(catalog) == 16); + assert(ui::catalogCount(catalog) == 17); flags.calendar_app = &calendar; catalog = ui::app_catalog_builder::build(flags); - assert(ui::catalogCount(catalog) == 17); + assert(ui::catalogCount(catalog) == 18); assert(ui::catalogAt(catalog, 0) == &calendar); - bool settings = false, shutdown = false, geocaching = false; + bool settings = false, shutdown = false, geocaching = false, music = false; for (std::size_t i = 0; i < ui::catalogCount(catalog); ++i) { auto* app = ui::catalogAt(catalog, i); @@ -93,15 +95,16 @@ int main() settings |= std::strcmp(app->stable_id(), "settings") == 0; shutdown |= std::strcmp(app->stable_id(), "shutdown") == 0; geocaching |= std::strcmp(app->stable_id(), "geocaching") == 0; + music |= std::strcmp(app->stable_id(), "music") == 0; for (std::size_t j = i + 1; j < ui::catalogCount(catalog); ++j) assert(std::strcmp(app->stable_id(), ui::catalogAt(catalog, j)->stable_id()) != 0); } - assert(settings && shutdown && geocaching && !ui::catalogAt(catalog, 17)); + assert(settings && shutdown && geocaching && music && !ui::catalogAt(catalog, 18)); // The IDF catalog and callers that do not inject Agenda retain their shape. flags.calendar_app = nullptr; flags.profile = ui::app_catalog_builder::CatalogProfile::IdfDefault; catalog = ui::app_catalog_builder::build(flags); - assert(ui::catalogCount(catalog) == 16); + assert(ui::catalogCount(catalog) == 17); for (std::size_t i = 0; i < ui::catalogCount(catalog); ++i) assert(std::strcmp(ui::catalogAt(catalog, i)->stable_id(), "calendar") != 0); } diff --git a/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp b/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp index 84d3523b..771ff962 100644 --- a/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp +++ b/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp @@ -67,6 +67,7 @@ enum Seen : uint32_t SeenGaugeFull = 1UL << 15U, SeenWifiEnabled = 1UL << 16U, SeenWifiCount = 1UL << 17U, + SeenRadioEnabled = 1UL << 18U, }; constexpr uint32_t kRequiredBase = SeenScreenTimeout | SeenBrightness | SeenSpeakerVolume | @@ -100,6 +101,7 @@ struct ParseState uint32_t gauge_design_mah = 1500U; uint32_t gauge_full_mah = 1500U; bool wifi_enabled = false; + bool radio_enabled = true; uint8_t wifi_profile_count = 0U; bool wifi_ssid_seen[::platform::ui::wifi::kMaxSavedProfileCount]{}; bool wifi_password_seen[::platform::ui::wifi::kMaxSavedProfileCount]{}; @@ -964,6 +966,12 @@ tms::RecordConsumeResult consumeRecord(void*, const tms::RecordReader& reader) ? tms::RecordConsumeResult::Accepted : tms::RecordConsumeResult::Invalid; } + if (key_equals(reader, "radio.enabled")) + { + return mark_once(SeenRadioEnabled) && reader.boolean(&s_state.radio_enabled) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } if (key_equals(reader, "wifi.profile_count")) { return mark_once(SeenWifiCount) && @@ -1079,6 +1087,7 @@ bool write_base_scalar_settings() return unsigned_integer(kSettingsNs, "screen_timeout", s_state.screen_timeout_ms) && integer(kSettingsNs, "screen_brightness", s_state.screen_brightness) && integer(kSettingsNs, "speaker_volume", s_state.speaker_volume) && + (!(s_state.seen & SeenRadioEnabled) || boolean(kSettingsNs, "radio_enabled", s_state.radio_enabled)) && boolean(kSettingsNs, "vibration_enabled", s_state.vibration_enabled) && integer(kSettingsNs, "timezone_profile", s_state.timezone_profile) && integer(kSettingsNs, "timezone_offset", s_state.timezone_offset_minutes) && @@ -1201,6 +1210,7 @@ bool writeRecords(void* context, tms::RecordWriter& writer) "screen_brightness", ::platform::ui::device::screen_brightness()) || !write_i32_setting(writer, "ui.audio.speaker_volume", kSettingsNs, "speaker_volume", 45) || + !write_bool_setting(writer, "radio.enabled", kSettingsNs, "radio_enabled", true) || !write_bool_setting(writer, "ui.haptics.vibration_enabled", kSettingsNs, "vibration_enabled", true) || !write_text_setting(writer, diff --git a/platform/esp/arduino_common/src/app_context.cpp b/platform/esp/arduino_common/src/app_context.cpp index ed2b3a00..482ddbad 100644 --- a/platform/esp/arduino_common/src/app_context.cpp +++ b/platform/esp/arduino_common/src/app_context.cpp @@ -21,6 +21,7 @@ #include "platform/esp/arduino_common/memory_diag.h" #include "platform/esp/arduino_common/storage/storage_runtime.h" #include "platform/esp/arduino_common/voice/vmp_pager_session.h" +#include "platform/ui/quick_controls_runtime.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/reticulum_directory_runtime.h" #include "sys/event_bus.h" @@ -974,6 +975,7 @@ void AppContext::getEffectiveUserInfo(char* out_long, size_t long_len, void AppContext::updateCoreServices() { + ::platform::ui::service_quick_controls(); flushConfigPersistence(millis()); #if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) // Text and VMP attachments share the deferred-storage readiness boundary. diff --git a/platform/esp/arduino_common/src/app_tasks.cpp b/platform/esp/arduino_common/src/app_tasks.cpp index 66d71965..c6893752 100644 --- a/platform/esp/arduino_common/src/app_tasks.cpp +++ b/platform/esp/arduino_common/src/app_tasks.cpp @@ -5,8 +5,10 @@ #include "platform/esp/arduino_common/app_tasks.h" +#include "platform/radio/radio_power.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/screen_runtime.h" +#include "platform/ui/settings_store.h" #if defined(ARDUINO) #include #include @@ -272,6 +274,7 @@ bool AppTasks::init(LoraBoard& board, chat::IMeshAdapter* adapter) mesh_task_quiesced_ = false; radio_receive_active_ = false; radio_receive_restart_pending_ = !radio_receive_suppressed_; + (void)platform::radio::power_policy().request(platform::ui::settings_store::get_bool("settings", "radio_enabled", true)); if (!radio_rx_scratch_) { radio_rx_scratch_ = allocate_radio_packet_buffer(255U); @@ -332,6 +335,7 @@ bool AppTasks::pauseRadioTasks(uint32_t timeout_ms) return false; } + if (!platform::radio::power_policy().begin_exclusive()) return false; radio_tasks_paused_ = true; requestRadioReceiveRestart(); const uint32_t start_ms = millis(); @@ -341,6 +345,7 @@ bool AppTasks::pauseRadioTasks(uint32_t timeout_ms) if (timeout_ms != 0U && millis() - start_ms >= timeout_ms) { radio_tasks_paused_ = false; + platform::radio::power_policy().end_exclusive(); Serial.printf("[LORA] task quiesce timeout radio=%u mesh=%u wait_ms=%lu\n", radio_task_quiesced_ ? 1U : 0U, mesh_task_quiesced_ ? 1U : 0U, @@ -401,6 +406,7 @@ void AppTasks::resumeRadioTasks() radio_tasks_paused_ = false; requestRadioReceiveRestart(); + platform::radio::power_policy().end_exclusive(); } void AppTasks::setRadioReceiveActive(bool active) @@ -413,6 +419,7 @@ void AppTasks::setRadioReceiveActive(bool active) } radio_receive_active_ = active; radio_receive_restart_pending_ = !active; + if (active) platform::radio::power_policy().acknowledge(true); } void AppTasks::requestRadioReceiveRestart() @@ -465,7 +472,7 @@ bool AppTasks::isRadioTransmitActive() bool AppTasks::enqueueRadioTransmit(const uint8_t* data, size_t size) { - if (!data || size == 0 || size > 255 || !radio_tx_queue_) + if (!platform::radio::power_policy().enabled() || !data || size == 0 || size > 255 || !radio_tx_queue_) { return false; } @@ -517,6 +524,7 @@ void AppTasks::radioTask(void* pvParameters) const uint32_t terminal_irq_mask = radio_terminal_irq_mask(); RadioRxSummary rx_summary{}; uint32_t foreground_rx_quiet_until_ms = 0; + uint32_t next_standby_attempt_ms = 0; while (true) { @@ -528,6 +536,36 @@ void AppTasks::radioTask(void* pvParameters) } radio_task_quiesced_ = false; + auto& power = platform::radio::power_policy(); + if (!power.enabled()) + { + const auto status = power.status(); + if (status.applied && deadline_reached(millis(), next_standby_attempt_ms)) + { + // Complete any in-flight TX before this point. Reuse the board's + // checked standby operation; do not cut shared SD/display rails. + if (!board_ || board_->quiesceForExternalStorage()) power.acknowledge(false); + else power.fail(); + next_standby_attempt_ms = millis() + 1000; + radio_receive_active_ = false; + radio_receive_restart_pending_ = false; + } + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + if (!power.status().applied) + { + if (!board_ || !board_->isRadioOnline()) + { + power.fail(); + vTaskDelay(pdMS_TO_TICKS(200)); + continue; + } + requestRadioReceiveRestart(); + // RX suppression is a protocol policy, independent of RF permission. + if (radio_receive_suppressed_ && board_ && board_->isRadioOnline()) power.acknowledge(true); + } + if (::platform::ui::reticulum_call::realtime_mode_active()) { requestRadioReceiveRestart(); diff --git a/platform/esp/arduino_common/src/exclusive_lora_runtime.cpp b/platform/esp/arduino_common/src/exclusive_lora_runtime.cpp index c7b0155b..571f0af8 100644 --- a/platform/esp/arduino_common/src/exclusive_lora_runtime.cpp +++ b/platform/esp/arduino_common/src/exclusive_lora_runtime.cpp @@ -3,6 +3,7 @@ #include "board/LoraBoard.h" #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/boards/board_runtime.h" +#include "platform/radio/radio_power.h" namespace platform::esp::arduino_common::exclusive_lora_runtime { @@ -25,6 +26,7 @@ bool tryAcquire(Session* out_session) } *out_session = {}; + if (!platform::radio::power_policy().enabled()) return false; out_session->lora = resolveLoraBoard(); if (!out_session->lora) { @@ -41,6 +43,11 @@ bool tryAcquire(Session* out_session) out_session->paused_radio_tasks = true; } + if (!platform::radio::power_policy().enabled()) + { + release(out_session); + return false; + } return true; } diff --git a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp index 0b82880f..e1b8acd5 100644 --- a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp @@ -1,5 +1,6 @@ #include "platform/ui/device_runtime.h" +#include "platform/audio/media_volume.h" #include #include "board/BoardBase.h" @@ -172,6 +173,7 @@ uint8_t default_message_tone_volume() void set_message_tone_volume(uint8_t volume_percent) { board.setMessageToneVolume(volume_percent); + ::platform::audio::publish_media_volume(volume_percent); } void play_message_tone() diff --git a/platform/esp/arduino_common/src/platform_ui_orientation_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_orientation_runtime.cpp index 3ed6ed66..a7659cda 100644 --- a/platform/esp/arduino_common/src/platform_ui_orientation_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_orientation_runtime.cpp @@ -2,6 +2,8 @@ namespace platform::ui::orientation { +bool acquire_heading() { return false; } +void release_heading() {} // These targets expose GPS/team bearings but have no heading-sensor service. // Do not create a polling task or imply that an absent magnetometer is warming up. HeadingState get_heading() { return {}; } diff --git a/platform/esp/common/include/platform/esp/common/music/music_ports.h b/platform/esp/common/include/platform/esp/common/music/music_ports.h new file mode 100644 index 00000000..c6df68de --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/music/music_ports.h @@ -0,0 +1,43 @@ +#pragma once +#include "music/storage.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +namespace platform::esp::music_runtime +{ +namespace sd = ::platform::esp::arduino_common::storage; +class SdStorage final : public ::music::Storage +{ + class Handle final : public ::music::File + { + public: + SdStorage* owner = nullptr; + sd::SdRuntimeFile file{sd::SdRuntimeMemoryPolicy::PsramOnly}; + bool used = false; + uint32_t size() const override; + std::size_t read_at(uint32_t offset, void* out, std::size_t size) override; + std::size_t write(const void* input, std::size_t size) override; + bool flush() override; + } handles_[8]; + sd::SdRuntimeDir directory_{sd::SdRuntimeMemoryPolicy::PsramOnly}; + char directory_path_[256]{}; + uint32_t directory_session_ = 0; + mutable ::music::StorageResult result_ = ::music::StorageResult::Ok; + + public: + SdStorage(); + ~SdStorage(); + bool ready() const override; + uint32_t session() const override; + ::music::File* open(const char* path, ::music::FileMode mode) override; + void close(::music::File*& file) override; + ::music::StorageResult last_result() const override { return result_; } + ::music::StorageResult next(const char* directory, uint64_t& cursor, ::music::Entry& entry) override; + bool exists(const char* path) override; + bool mkdir(const char* path) override; + bool remove(const char* path) override; + bool rename(const char* from, const char* to) override; +}; +bool audio_supported(); +bool audio_begin(uint32_t rate); +bool audio_write(const int16_t* samples, std::size_t count); +void audio_end(); +} // namespace platform::esp::music_runtime diff --git a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h index 10e7a5b5..3b8bae97 100644 --- a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h +++ b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h @@ -1431,7 +1431,14 @@ bool stop_wifi_runtime(const char* success_message) if (s_runtime.wifi_started) { (void)esp_wifi_disconnect(); - (void)esp_wifi_stop(); + const auto stopped = esp_wifi_stop(); + if (stopped != ESP_OK && stopped != ESP_ERR_WIFI_NOT_INIT) + { + set_status_message("Wi-Fi driver could not stop"); + return false; + } + s_runtime.wifi_started = false; + clear_connection_details(); } if (s_runtime.wifi_initialized) { @@ -1440,6 +1447,10 @@ bool stop_wifi_runtime(const char* success_message) { std::printf("[WiFi] esp_wifi_deinit failed err=0x%x\n", static_cast(deinit_err)); + // Keep the initialization flag and reserve truthful so a retry + // can finish cleanup instead of allocating a second Wi-Fi owner. + set_status_message("Wi-Fi driver cleanup failed"); + return false; } else { @@ -1655,6 +1666,29 @@ bool apply_enabled(bool enabled) return true; } +bool set_user_enabled(bool enabled) +{ + const auto access = ::platform::ui::wifi_access::status(); + if (!is_supported() || s_runtime.externally_suspended || access.non_preemptible_active || + access.ota_active || access.exclusive_owner != ::platform::ui::wifi_access::ExclusiveOwner::None) + return false; + Config previous{}; + if (!load_config(previous)) return false; + const auto persist = [](bool value) + { + ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, value); + if (::platform::ui::settings_store::get_bool(kSettingsNs, kWifiEnabledKey, !value) != value) return false; + s_runtime.config.enabled = value; + s_runtime.config_cached = true; + return true; + }; + if (!persist(enabled)) return false; + if (apply_enabled(enabled)) return true; + (void)persist(previous.enabled); + (void)apply_enabled(previous.enabled); + return false; +} + bool suspend_for_external_storage(ExternalStorageSuspension* out_suspension) { if (!out_suspension) diff --git a/platform/esp/common/library.json b/platform/esp/common/library.json index 2a4bcf69..92d78a61 100644 --- a/platform/esp/common/library.json +++ b/platform/esp/common/library.json @@ -9,6 +9,9 @@ "srcDir": "src" }, "dependencies": [ + { "name": "ui_presentation" }, + { "name": "core_music" }, + { "name": "music_image" }, { "name": "core_waypoint" }, { "name": "core_agenda" diff --git a/platform/esp/common/src/agenda_widget_runtime.cpp b/platform/esp/common/src/agenda_widget_runtime.cpp new file mode 100644 index 00000000..45244cdb --- /dev/null +++ b/platform/esp/common/src/agenda_widget_runtime.cpp @@ -0,0 +1,150 @@ +#include "platform/ui/agenda_widget_runtime.h" +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +#include "agenda/usecase/agenda_query.h" +#include "agenda/usecase/agenda_service.h" +#include "esp_heap_caps.h" +#include "platform/esp/arduino_common/agenda_clock.h" +#include "platform/esp/arduino_common/storage/agenda_storage.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/storage/agenda_file_store.h" +#include "ui_presentation/agenda/agenda_source.h" +#include +#include +namespace platform::ui +{ +namespace +{ +namespace sd = ::platform::esp::arduino_common::storage; +class ReadOnlyFiles final : public ::platform::esp::storage::RecordFileIo +{ + sd::SdRuntimeFile file_{sd::SdRuntimeMemoryPolicy::PsramOnly}; + uint32_t session_ = 0; + bool usable(Handle file) const { return file == &file_ && session_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active() && file_.is_open(); } + + public: + bool missing = false; + void bind() + { + file_.close(); + session_ = sd::sd_media_session(); + missing = false; + } + OpenResult open(const char* path, Mode mode) override + { + // A visualization must never create, repair or modify an Agenda store. + if (mode != Mode::Read || file_.is_open()) return {}; + if (session_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return {nullptr, OpenStatus::Unavailable}; + if (file_.open(path, "rb", session_)) return {&file_, OpenStatus::Ready}; + if (file_.read_busy()) return {}; + uint8_t probe = 0; + const auto status = sd::sd_read_file(path, &probe, 1).status; + if (session_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return {nullptr, OpenStatus::Unavailable}; + missing = status == sd::SdFileReadStatus::Missing; + return {nullptr, missing ? OpenStatus::Missing : OpenStatus::IoError}; + } + bool close(Handle file) override + { + if (file != &file_) return false; + const bool ready = usable(file); + file_.close(); + return ready; + } + bool size(Handle file, uint64_t& bytes) override + { + bytes = 0; + if (!usable(file)) return false; + bytes = file_.size(); + return !file_.read_busy(); + } + bool seek(Handle file, uint64_t offset) override { return usable(file) && file_.seek(offset); } + std::size_t read(Handle file, void* out, std::size_t bytes) override + { + if (!usable(file)) return 0; + const auto count = file_.read(out, bytes); + return count > 0 ? count : 0; + } + std::size_t write(Handle, const void*, std::size_t) override { return 0; } + bool sync(Handle) override { return false; } + bool publish(const char*, const char*) override { return false; } +}; +class Source final : public ::ui::agenda::IAgendaSource +{ + ReadOnlyFiles files_; + ::platform::esp::storage::AgendaFileStore store_{sd::kAgendaFile, sd::kAgendaInitializationFile, files_}; + ::platform::esp::arduino_common::AgendaClock clock_; + ::agenda::StoreResult prepare() + { + files_.bind(); + return store_.begin(); + } + + public: + ::agenda::ClockSample currentTime() const override { return clock_.sample(); } + void snapshot(const ::ui::agenda::AgendaRequest& request, ::ui::agenda::AgendaSnapshot& out) override + { + using Snapshot = ::ui::agenda::AgendaSnapshot; + out.~Snapshot(); + new (&out) Snapshot{}; + const auto time = clock_.sample(); + out.clock_valid = time.valid; + out.storage_ready = sd::sd_card_ready() && !sd::sd_external_block_owner_active(); + out.revision = sd::sd_media_session(); + if (time.valid) out.today_start = time.calendar_seconds / 86400 * 86400; + if (!out.storage_ready) + { + out.result = ::agenda::StoreResult::IoError; + return; + } + if (!time.valid) return; + out.result = prepare(); + if (files_.missing) + { + out.result = ::agenda::StoreResult::Ok; + return; + } + if (out.result == ::agenda::StoreResult::Ok) + out.result = ::agenda::queryAgenda(store_, request.day_start, ::agenda::kLastCalendarSecond + 1, request.visible_rows, request.after, out.page); + if (out.revision != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) + { + out.page = {}; + out.storage_ready = false; + out.result = ::agenda::StoreResult::IoError; + } + } + ::agenda::AgendaResult detail(uint32_t id, ::agenda::EventRecord& out) override + { + if (prepare() != ::agenda::StoreResult::Ok) return ::agenda::AgendaResult::StorageError; + ::agenda::AgendaService service(store_); + return service.read(id, out); + } + bool newDraft(::ui::agenda::AgendaEditorModel&) const override { return false; } + void month(uint16_t year, uint8_t month, ::ui::agenda::MonthSnapshot& out) override + { + out = {}; + out.year = year; + out.month = month; + out.result = prepare(); + if (out.result == ::agenda::StoreResult::Ok) out.result = ::agenda::queryMonth(store_, year, month, out.occupied_days); + } + ::ui::agenda::CommandResult commandResult() const override { return {}; } +}; +} // namespace +::ui::agenda::IAgendaSource* create_agenda_widget_source() +{ + auto* memory = heap_caps_malloc(sizeof(Source), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + return memory ? new (memory) Source{} : nullptr; +} +void destroy_agenda_widget_source(::ui::agenda::IAgendaSource* source) +{ + if (!source) return; + static_cast(source)->~Source(); + heap_caps_free(source); +} +} // namespace platform::ui +#else +namespace platform::ui +{ +::ui::agenda::IAgendaSource* create_agenda_widget_source() { return nullptr; } +void destroy_agenda_widget_source(::ui::agenda::IAgendaSource*) {} +} // namespace platform::ui +#endif diff --git a/platform/esp/common/src/music/music_audio.cpp b/platform/esp/common/src/music/music_audio.cpp new file mode 100644 index 00000000..69d196d8 --- /dev/null +++ b/platform/esp/common/src/music/music_audio.cpp @@ -0,0 +1,99 @@ +#include "platform/audio/audio_focus.h" +#include "platform/esp/common/music/music_ports.h" +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +#include "bsp/trail_mate_t_display_p4_runtime.h" +#elif defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) +#include "platform/esp/boards/board_runtime.h" +#if defined(ARDUINO_T_LORA_PAGER) +#include "boards/tlora_pager/tlora_pager_board.h" +#elif defined(ARDUINO_T_DECK) +#include "boards/tdeck/tdeck_board.h" +#else +#include "platform/esp/wio_tracker_l2/wio_tracker_l2_board.h" +#endif +#endif +namespace platform::esp::music_runtime +{ +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) +namespace +{ +#if defined(ARDUINO_T_LORA_PAGER) +using MusicBoard = ::boards::tlora_pager::TLoRaPagerBoard; +constexpr auto kOwner = ::boards::tlora_pager::PagerAudioOwner::Music; +#elif defined(ARDUINO_T_DECK) +using MusicBoard = ::boards::tdeck::TDeckBoard; +#else +using MusicBoard = ::boards::wio_tracker_l2::WioTrackerL2Board; +#endif +MusicBoard* board() +{ + ::platform::esp::boards::AppContextInitHandles handles; + return ::platform::esp::boards::tryResolveAppContextInitHandles(&handles) && handles.board ? static_cast(handles.board) : nullptr; +} +} // namespace +#endif +bool audio_supported() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) + return true; +#else + return false; +#endif +} +bool audio_begin(uint32_t rate) +{ + if (::platform::audio::music_blocked()) return false; +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + return trail_mate_t_display_p4_audio_begin(TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC, rate); +#elif defined(ARDUINO_T_LORA_PAGER) + auto* output = board(); + if (!output || output->openAudioSession(kOwner, 16, 1, rate, true) != 0) return false; + if (!output->audioSetVolume(kOwner, 100) || !output->audioSetOutMute(kOwner, false)) + { + output->closeAudioSession(kOwner); + return false; + } + return true; +#elif defined(ARDUINO_T_DECK) + auto* output = board(); + return output && output->audioRuntime().beginMusic(rate); +#elif defined(ARDUINO_WIO_TRACKER_L2) + auto* output = board(); + return output && output->beginMusic(rate); +#else + (void)rate; + return false; +#endif +} +bool audio_write(const int16_t* samples, std::size_t count) +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + return trail_mate_t_display_p4_audio_write_mono(TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC, samples, count); +#elif defined(ARDUINO_T_LORA_PAGER) + auto* output = board(); + return output && output->audioWrite(kOwner, reinterpret_cast(samples), count * sizeof(int16_t)) == static_cast(count * sizeof(int16_t)); +#elif defined(ARDUINO_T_DECK) + auto* output = board(); + return output && output->audioRuntime().writeMusic(samples, count); +#elif defined(ARDUINO_WIO_TRACKER_L2) + auto* output = board(); + return output && output->writeMusic(samples, count); +#else + (void)samples; + (void)count; + return false; +#endif +} +void audio_end() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + trail_mate_t_display_p4_audio_end(TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC); +#elif defined(ARDUINO_T_LORA_PAGER) + if (auto* output = board()) output->closeAudioSession(kOwner); +#elif defined(ARDUINO_T_DECK) + if (auto* output = board()) output->audioRuntime().endMusic(); +#elif defined(ARDUINO_WIO_TRACKER_L2) + if (auto* output = board()) output->endMusic(); +#endif +} +} // namespace platform::esp::music_runtime diff --git a/platform/esp/common/src/music/music_runtime.cpp b/platform/esp/common/src/music/music_runtime.cpp new file mode 100644 index 00000000..33479191 --- /dev/null +++ b/platform/esp/common/src/music/music_runtime.cpp @@ -0,0 +1,479 @@ +#include "platform/ui/music_runtime.h" +#include "music/service.h" +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "music/cover_decoder.h" +#include "music/player.h" +#include "platform/audio/audio_focus.h" +#include "platform/audio/media_volume.h" +#include "platform/esp/common/music/music_ports.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/settings_store.h" +#include +#include +#include +#include +namespace platform::esp::music_runtime +{ +namespace +{ +class Service; +struct AudioState +{ + std::atomic opening{false}, active{false}; +}; +class Output final : public ::music::AudioOutput +{ + AudioState& shared_; + TickType_t last_write_ = 0; + + public: + explicit Output(AudioState& state) : shared_(state) {} + bool supported() const override { return audio_supported(); } + bool blocked() const override { return ::platform::audio::music_blocked(); } + bool needs_frame() const override { return last_write_ == 0 || pdTICKS_TO_MS(xTaskGetTickCount() - last_write_) >= 6; } + bool begin(uint32_t rate) override + { + shared_.opening.store(true); + const bool opened = !blocked() && audio_begin(rate); + shared_.active.store(opened); + shared_.opening.store(false); + return opened; + } + bool write(const int16_t* samples, std::size_t count) override + { + if (blocked() || !audio_write(samples, count)) return false; + last_write_ = xTaskGetTickCount(); + return true; + } + void end() override + { + audio_end(); + shared_.active.store(false); + } +}; +class Images final : public ::music::ImageLoader +{ + Service* service_ = nullptr; + bool (*pump_)(void*) = nullptr; + void* context_ = nullptr; + uint16_t edge_ = 0; + static bool pump(void* context); + + public: + explicit Images(Service* service) : service_(service) {} + ::music::Artwork* decode(::music::Reader& input, const ::music::Track& track, uint16_t edge, bool (*pump)(void*), void* context, ::music::CoverResult& result) override; +}; +struct Runtime +{ + SdStorage storage; + Output output; + Images images; + ::music::Player player; + ::music::Request command{}; + bool volume_dirty = false; + uint32_t volume_changed = 0; + explicit Runtime(Service* service, AudioState& audio) : output(audio), images(service), player(storage, output, images) {} +}; +class Service final : public ::music::IService +{ + StaticSemaphore_t mutex_storage_{}; + SemaphoreHandle_t mutex_ = nullptr; + TaskHandle_t worker_ = nullptr; + Runtime* runtime_ = nullptr; + ::music::Snapshot* published_ = nullptr; + ::music::ResumeState* resume_ = nullptr; + ::music::Artwork* artwork_ = nullptr; + ::music::Request* requests_ = nullptr; + uint8_t read_ = 0, count_ = 0, rows_ = 0; + bool visible_ = false, lyrics_ = false, properties_dirty_ = false; + std::atomic edge_{0}; + std::atomic volume_{45}; + AudioState audio_; + enum class Failure : uint8_t + { + None, + Memory, + Task, + Queue + }; + Failure failure_ = Failure::None; + struct Lock + { + Service& service; + bool held; + explicit Lock(Service& value, TickType_t wait = portMAX_DELAY) : service(value), held(xSemaphoreTake(value.mutex_, wait) == pdTRUE) {} + ~Lock() + { + if (held) xSemaphoreGive(service.mutex_); + } + explicit operator bool() const { return held; } + }; + void start_locked() + { + if (worker_) + { + xTaskNotifyGive(worker_); + return; + } + if (xTaskCreate(task, "music", 4096, this, tskIDLE_PRIORITY + 1, &worker_) != pdPASS) + { + worker_ = nullptr; + failure_ = Failure::Task; + } + } + static void task(void* context) + { + static_cast(context)->run(); + vTaskDelete(nullptr); + } + void publish(Runtime& runtime) + { + ::music::Artwork* old = nullptr; + { + Lock lock(*this); + if (!published_) published_ = ::music::make<::music::Snapshot>(); + if (published_) *published_ = runtime.player.state; + else failure_ = Failure::Memory; + auto* art = runtime.player.artwork(); + if (art != artwork_) + { + if (art) art->retain(); + old = artwork_; + artwork_ = art; + } + } + if (old) old->drop(); + } + Runtime* finish_locked() + { + Runtime* retired = nullptr; + if (runtime_ && !visible_) + { + if (runtime_->player.state.playback == ::music::Playback::Paused && runtime_->player.state.current.id) + { + if (!resume_) resume_ = ::music::make<::music::ResumeState>(); + if (resume_) runtime_->player.save_resume(*resume_); + } + else ::music::destroy(resume_); + retired = runtime_; + runtime_ = nullptr; + ::music::destroy(published_); + if (artwork_) + { + artwork_->drop(); + artwork_ = nullptr; + } + } + ::music::release(requests_); + requests_ = nullptr; + read_ = count_ = 0; + worker_ = nullptr; + return retired; + } + void run() + { + Runtime* runtime = nullptr; + { + // start_locked holds this mutex through xTaskCreate's handle publication. + Lock lock(*this); + if (!runtime_) runtime_ = ::music::make(this, audio_); + runtime = runtime_; + if (!runtime) + { + failure_ = Failure::Memory; + finish_locked(); + return; + } + } + if (resume_ && runtime->player.state.current.id == 0) runtime->player.restore_resume(*resume_); + runtime->player.state.volume = volume_.load(); + for (;;) + { + bool command = false, visible = false, lyrics = false; + uint8_t rows = 0; + uint16_t edge = 0; + { + Lock lock(*this); + visible = visible_; + rows = rows_; + lyrics = lyrics_; + edge = edge_.load(); + if (count_) + { + runtime->command = requests_[read_]; + read_ = (read_ + 1) % 8; + --count_; + command = true; + } + properties_dirty_ = false; + } + runtime->player.attach(visible ? rows : 0); + runtime->player.request_artwork(visible ? edge : 0); + runtime->player.request_lyrics(visible && lyrics); + if (command) + { + if (runtime->command.action == ::music::Action::Volume && std::clamp(runtime->command.value, 0, 100) != volume_.load()) command = false; + } + if (command) + { + if (!runtime->player.command(runtime->command) && !runtime->player.state.notice[0]) + ::music::copy_text(runtime->player.state.notice, sizeof(runtime->player.state.notice), "Music request could not be completed"); + if (runtime->command.action == ::music::Action::Volume) + { + runtime->volume_dirty = true; + runtime->volume_changed = xTaskGetTickCount(); + ::platform::ui::device::set_message_tone_volume(runtime->player.state.volume); + } + } + runtime->player.state.volume = volume_.load(); + runtime->player.tick(); + publish(*runtime); + const bool active = runtime->player.active(); + if (runtime->volume_dirty && !active && !::platform::audio::music_blocked() && (!visible || pdTICKS_TO_MS(xTaskGetTickCount() - runtime->volume_changed) >= 500)) + { + ::platform::ui::settings_store::put_int("settings", "speaker_volume", volume_.load()); + runtime->volume_dirty = false; + } + Runtime* retired = nullptr; + bool finished = false; + { + Lock lock(*this); + if (!active && !runtime->volume_dirty && !count_ && !properties_dirty_) + { + retired = finish_locked(); + finished = true; + } + } + // SD handle closure must never run under the UI publication mutex. + ::music::destroy(retired); + if (finished) return; + if (!active || ::platform::audio::music_blocked()) ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); + else vTaskDelay(1); + } + } + void wake() + { + // Ending a notification must not instantiate a worker/SD index for a user-paused session. + Lock lock(*this); + if (worker_) xTaskNotifyGive(worker_); + } + static bool suspend(void* context) + { + auto& self = *static_cast(context); + if (!self.audio_.active.load() && !self.audio_.opening.load()) return true; + self.wake(); + const auto started = xTaskGetTickCount(); + while (self.audio_.active.load() || self.audio_.opening.load()) + { + if (pdTICKS_TO_MS(xTaskGetTickCount() - started) >= 350) return false; + vTaskDelay(1); + } + return true; + } + static void resumed(void* context) { static_cast(context)->wake(); } + static void volume_changed(void* context, uint8_t percent) + { + auto& self = *static_cast(context); + percent = ::platform::audio::media_volume(percent); + self.volume_.store(percent); + Lock lock(self, pdMS_TO_TICKS(20)); + if (!lock) return; + if (self.published_) self.published_->volume = percent; + if (self.resume_) self.resume_->volume = percent; + if (self.runtime_ && !self.worker_) self.runtime_->player.state.volume = percent; + if (self.worker_) xTaskNotifyGive(self.worker_); + } + + public: + Service() + { + mutex_ = xSemaphoreCreateMutexStatic(&mutex_storage_); + volume_.store(::platform::audio::media_volume(std::clamp(::platform::ui::settings_store::get_int("settings", "speaker_volume", 45), 0, 100))); + ::platform::audio::observe_media_volume(volume_changed, this); + ::platform::audio::register_music_focus_client({this, suspend, resumed}); + } + uint16_t requested_edge() const { return edge_.load(); } + bool artwork_wanted(uint16_t edge) + { + if (edge_.load() != edge) return false; + Lock lock(*this, pdMS_TO_TICKS(5)); + return lock && visible_ && count_ == 0; + } + uint8_t volume() const override { return volume_.load(); } + void attach(uint8_t rows) override + { + Lock lock(*this); + visible_ = true; + rows_ = std::min(rows, ::music::kPageRows); + properties_dirty_ = true; + // An idle negative-screen widget observes a proxy without starting SD/audio/task work. + if (rows_ || runtime_ || resume_) start_locked(); + } + void detach() override + { + Lock lock(*this); + visible_ = false; + rows_ = 0; + lyrics_ = false; + edge_.store(0); + properties_dirty_ = true; + if (runtime_) start_locked(); + } + void request_artwork(uint16_t edge) override + { + Lock lock(*this); + if (edge_.load() == edge) return; + edge_.store(edge); + properties_dirty_ = true; + if (runtime_ || resume_) start_locked(); + } + void request_lyrics(bool visible) override + { + Lock lock(*this); + if (lyrics_ == visible) return; + lyrics_ = visible; + properties_dirty_ = true; + if (runtime_ || resume_) start_locked(); + } + bool snapshot(::music::Snapshot& out) override + { + Lock lock(*this, pdMS_TO_TICKS(20)); + if (!lock) return false; + if (resume_ && resume_->media_session != sd::sd_media_session()) ::music::destroy(resume_); + if (published_) out = *published_; + else + { + using Snapshot = ::music::Snapshot; + out.~Snapshot(); + new (&out) Snapshot{}; + if (resume_) + { + out.current = resume_->current; + out.position_ms = resume_->position_ms; + out.mode = resume_->mode; + out.playback = ::music::Playback::Paused; + } + out.storage_ready = sd::sd_card_ready() && !sd::sd_external_block_owner_active(); + out.audio_supported = audio_supported(); + out.volume = volume_.load(); + } + out.storage_ready = sd::sd_card_ready() && !sd::sd_external_block_owner_active(); + out.volume = volume_.load(); + if (failure_ != Failure::None) ::music::copy_text(out.notice, sizeof(out.notice), failure_ == Failure::Memory ? "Not enough PSRAM for music" : failure_ == Failure::Task ? "Music worker is unavailable" + : "Music is busy. Try again."); + return true; + } + bool send(const ::music::Request& request) override + { + if (request.action == ::music::Action::Volume) + { + bool cached = false; + const auto value = std::clamp(request.value, 0, 100); + { + Lock lock(*this); + if (!worker_) + { + volume_.store(value); + if (runtime_) runtime_->player.state.volume = value; + if (published_) published_->volume = value; + if (resume_) resume_->volume = value; + cached = true; + } + } + if (cached) + { + // An idle volume gesture needs no decoder, SD handles or task. + ::platform::ui::device::set_message_tone_volume(value); + ::platform::ui::settings_store::put_int("settings", "speaker_volume", value); + return ::platform::ui::settings_store::get_int("settings", "speaker_volume", -1) == value; + } + } + struct VolumePublication + { + bool accepted = false; + uint8_t value = 0; + ~VolumePublication() + { + if (accepted) ::platform::audio::publish_media_volume(value); + } + } publication; // Runs after the following lock has been released. + Lock lock(*this); + if (count_ == 8) + { + failure_ = Failure::Queue; + return false; + } + if (!requests_) + { + requests_ = static_cast<::music::Request*>(::music::allocate(sizeof(::music::Request) * 8)); + if (requests_) + for (unsigned i = 0; i < 8; i++) new (requests_ + i)::music::Request{}; + } + if (!requests_) + { + failure_ = Failure::Memory; + return false; + } + requests_[(read_ + count_) % 8] = request; + ++count_; + failure_ = Failure::None; + start_locked(); + if (!worker_) + { + --count_; + return false; + } + if (request.action == ::music::Action::Volume) + { + publication.value = std::clamp(request.value, 0, 100); + volume_.store(publication.value); + publication.accepted = true; + } + return true; + } + ::music::Artwork* acquire_artwork() override + { + Lock lock(*this); + if (artwork_) artwork_->retain(); + return artwork_; + } +}; +bool Images::pump(void* context) +{ + auto& self = *static_cast(context); + return self.service_->artwork_wanted(self.edge_) && (!self.pump_ || self.pump_(self.context_)); +} +::music::Artwork* Images::decode(::music::Reader& input, const ::music::Track& track, uint16_t edge, bool (*pump_callback)(void*), void* context, ::music::CoverResult& result) +{ + pump_ = pump_callback; + context_ = context; + edge_ = edge; + auto* artwork = ::music::decode_cover(input, track, edge, result, pump, this); + if (!service_->artwork_wanted(edge) || ::platform::audio::music_blocked()) + { + if (artwork) artwork->drop(); + result = ::music::CoverResult::Cancelled; + return nullptr; + } + return artwork; +} +} // namespace +::music::IService* resolve_service() +{ + static Service service; + return &service; +} +} // namespace platform::esp::music_runtime +namespace platform::ui +{ +::music::IService* music_service() { return ::platform::esp::music_runtime::resolve_service(); } +} // namespace platform::ui +#else +namespace platform::ui +{ +::music::IService* music_service() { return nullptr; } +} // namespace platform::ui +#endif diff --git a/platform/esp/common/src/music/music_storage.cpp b/platform/esp/common/src/music/music_storage.cpp new file mode 100644 index 00000000..5d31f7c1 --- /dev/null +++ b/platform/esp/common/src/music/music_storage.cpp @@ -0,0 +1,108 @@ +#include "music/storage.h" +#include "platform/esp/common/music/music_ports.h" +#include +#include +namespace platform::esp::music_runtime +{ +using ::music::StorageResult; +SdStorage::SdStorage() +{ + for (auto& handle : handles_) handle.owner = this; +} +SdStorage::~SdStorage() +{ + directory_.close(); + for (auto& handle : handles_) handle.file.close(); +} +bool SdStorage::ready() const { return sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } +uint32_t SdStorage::session() const { return sd::sd_media_session(); } +uint32_t SdStorage::Handle::size() const +{ + const auto bytes = file.size(); + if (file.read_busy()) owner->result_ = StorageResult::Busy; + return bytes > UINT32_MAX ? 0 : static_cast(bytes); +} +std::size_t SdStorage::Handle::read_at(uint32_t offset, void* out, std::size_t size) +{ + if (!file.seek(offset)) + { + owner->result_ = file.read_busy() ? StorageResult::Busy : StorageResult::Error; + return 0; + } + const int count = file.read(out, size); + owner->result_ = count > 0 ? StorageResult::Ok : file.read_busy() ? StorageResult::Busy + : StorageResult::Error; + return count > 0 ? count : 0; +} +std::size_t SdStorage::Handle::write(const void* input, std::size_t size) +{ + const auto written = file.write(input, size); + owner->result_ = written == size ? StorageResult::Ok : StorageResult::Error; + return written; +} +bool SdStorage::Handle::flush() { return file.flush(); } +::music::File* SdStorage::open(const char* path, ::music::FileMode mode) +{ + if (!ready()) + { + result_ = StorageResult::Unavailable; + return nullptr; + } + for (auto& handle : handles_) + if (!handle.used) + { + const char* flags = mode == ::music::FileMode::Read ? "r" : mode == ::music::FileMode::Write ? "w" + : "a"; + if (!handle.file.open(path, flags, session())) + { + result_ = handle.file.read_busy() ? StorageResult::Busy : StorageResult::Missing; + return nullptr; + } + handle.used = true; + result_ = StorageResult::Ok; + return &handle; + } + result_ = StorageResult::Busy; + return nullptr; +} +void SdStorage::close(::music::File*& file) +{ + if (!file) return; + auto* handle = static_cast(file); + handle->file.close(); + handle->used = false; + file = nullptr; +} +StorageResult SdStorage::next(const char* path, uint64_t& cursor, ::music::Entry& entry) +{ + if (!ready()) return result_ = StorageResult::Unavailable; + if (!directory_.is_open() || directory_session_ != session() || std::strcmp(directory_path_, path)) + { + directory_.close(); + const auto status = directory_.open_read_status(path, session()); + if (status != sd::SdFileReadStatus::Ready) return result_ = status == sd::SdFileReadStatus::Busy ? StorageResult::Busy : status == sd::SdFileReadStatus::Missing ? StorageResult::Missing + : StorageResult::Error; + ::music::copy_text(directory_path_, sizeof(directory_path_), path); + directory_session_ = session(); + } + const auto seek = directory_.seek_read_status(cursor); + if (seek != sd::SdFileReadStatus::Ready) return result_ = seek == sd::SdFileReadStatus::Busy ? StorageResult::Busy : StorageResult::Error; + uint64_t next = cursor; + const auto status = directory_.read_next_info(entry.name, sizeof(entry.name), &entry.directory, &entry.size, &next); + if (status == sd::SdDirReadStatus::Entry) + { + cursor = next; + return result_ = StorageResult::Ok; + } + directory_.close(); + directory_path_[0] = 0; + directory_session_ = 0; + return result_ = status == sd::SdDirReadStatus::End ? StorageResult::End : status == sd::SdDirReadStatus::Busy ? StorageResult::Busy + : status == sd::SdDirReadStatus::Unavailable ? StorageResult::Unavailable + : StorageResult::Error; +} +bool SdStorage::exists(const char* path) { return ready() && sd::sd_exists(path); } +bool SdStorage::mkdir(const char* path) { return ready() && (sd::sd_is_directory(path) || sd::sd_mkdir(path, session())); } +bool SdStorage::remove(const char* path) { return ready() && sd::sd_remove(path, session()); } +bool SdStorage::rename(const char* from, const char* to) { return ready() && sd::sd_rename(from, to, session()); } +} // namespace platform::esp::music_runtime diff --git a/platform/esp/common/src/quick_controls_runtime.cpp b/platform/esp/common/src/quick_controls_runtime.cpp new file mode 100644 index 00000000..d6962526 --- /dev/null +++ b/platform/esp/common/src/quick_controls_runtime.cpp @@ -0,0 +1,189 @@ +#include "platform/ui/quick_controls_runtime.h" +#include "ui_presentation/controls/quick_controls.h" + +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) || defined(ARDUINO_WIO_TRACKER_L2) +#include "app/app_facade_access.h" +#include "board/LoraBoard.h" +#include "music/service.h" +#include "platform/esp/boards/board_runtime.h" +#include "platform/radio/radio_power.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/music_runtime.h" +#include "platform/ui/reticulum_call_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/wifi_access_runtime.h" +#include "platform/ui/wifi_runtime.h" +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +#include "platform/esp/idf_common/sx126x_radio.h" +#endif +#include +#include +#include + +namespace platform::ui +{ +namespace +{ +using namespace ::ui::controls; +enum class Error : uint8_t +{ + None, + Busy, + Unsupported, + Apply, + Persist +}; +// Five coalescing control words, no dynamic queue, worker, timer or hardware lease. +// State touched by multiple tasks is kept internal; view snapshots live in PSRAM. +std::atomic pending[5]{}; +std::atomic applying[5]{}; +std::atomic errors[5]{}; +bool radio_online() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + return ::platform::esp::idf_common::Sx126xRadio::instance().isOnline(); +#else + ::platform::esp::boards::AppContextInitHandles handles; + return ::platform::esp::boards::tryResolveAppContextInitHandles(&handles) && handles.lora_board && handles.lora_board->isRadioOnline(); +#endif +} +bool radio_available() +{ + ::platform::esp::boards::AppContextInitHandles handles; + return ::platform::esp::boards::tryResolveAppContextInitHandles(&handles) && handles.lora_board; +} +bool wifi_busy() +{ + const auto status = wifi_access::status(); + return status.non_preemptible_active || status.ota_active || status.exclusive_owner != wifi_access::ExclusiveOwner::None; +} +class Source final : public IQuickControls +{ + public: + bool snapshot(Snapshot& out) override + { + const auto wireless = wifi::status(); + const auto radio = ::platform::radio::power_policy().status(); + out.wifi_enabled = wireless.enabled; + out.lora_enabled = radio.applied; + out.gps_enabled = device::gps_supported() && gps::is_enabled(); + out.wifi = wireless.supported ? (wireless.enabled ? SwitchState::On : SwitchState::Off) : SwitchState::Unavailable; + out.lora = radio_available() ? (radio.applied ? SwitchState::On : SwitchState::Off) : SwitchState::Unavailable; + if (radio.enabled && radio.applied && !radio_online()) out.lora = SwitchState::Unavailable; + out.gps = device::gps_supported() ? (out.gps_enabled ? SwitchState::On : SwitchState::Off) : SwitchState::Unavailable; + if (wireless.state == wifi::ConnectionState::ResourceDeferred) out.wifi = SwitchState::Unavailable; + if (out.lora != SwitchState::Unavailable && radio.enabled != radio.applied) + out.lora = radio.failed ? SwitchState::Failed : SwitchState::Changing; + SwitchState* switches[] = {&out.wifi, &out.lora, &out.gps}; + for (unsigned i = 0; i < 3; ++i) + if (*switches[i] != SwitchState::Unavailable && (pending[i].load() || applying[i].load())) *switches[i] = SwitchState::Changing; + auto* music = music_service(); + out.volume_supported = music != nullptr; + out.volume = music ? music->volume() : 0; + const auto maximum = device::screen_brightness_max(); + out.brightness_supported = device::supports_screen_brightness() && maximum; + out.brightness = maximum ? std::clamp((device::screen_brightness() * 100U + maximum / 2U) / maximum, 10, 100) : 0; + out.message[0] = 0; + static constexpr const char* names[] = {"Wi-Fi", "LoRa", "GPS", "Volume", "Brightness"}; + for (unsigned i = 0; i < 5; ++i) + { + const auto error = errors[i].load(); + if (error == Error::None) continue; + std::snprintf(out.message, sizeof(out.message), "%s: %s", names[i], error == Error::Busy ? "in use; try again when finished" : error == Error::Unsupported ? "unavailable" + : error == Error::Persist ? "preference could not be saved" + : "change failed; try again"); + if (i < 3 && *switches[i] != SwitchState::Unavailable && *switches[i] != SwitchState::Changing) *switches[i] = SwitchState::Failed; + break; + } + return true; + } + bool set(Control control, uint8_t value) override + { + const auto index = static_cast(control); + if (index >= 5 || value > (index < 3 ? 1 : 100) || (control == Control::Brightness && value < 10)) return false; + // The service rechecks ownership when consuming this intent. + errors[index].store(Error::None); + pending[index].store(0x100U | value); + return true; + } +}; +Error apply(Control control, uint8_t value) +{ + switch (control) + { + case Control::Wifi: + if (wifi_busy()) return Error::Busy; + return wifi::set_user_enabled(value != 0) ? Error::None : Error::Apply; + case Control::Lora: + if (!radio_available()) return Error::Unsupported; + if (reticulum_call::realtime_mode_active() || !::platform::radio::power_policy().request(value != 0)) return Error::Busy; + settings_store::put_bool("settings", "radio_enabled", value != 0); + return settings_store::get_bool("settings", "radio_enabled", value == 0) == (value != 0) ? Error::None : Error::Persist; + case Control::Gps: + { + if (!device::gps_supported() || !app::hasAppFacade()) return Error::Unsupported; + // Config edits and their live projections share the existing display boundary. + if (!::platform::esp::boards::lockDisplay(50)) return Error::Busy; + bool edited = false; + { + auto edit = app::configFacade().beginConfigEdit(); + if (edit) + { + edit.config().gps_enabled = value != 0; + edit.commit(app::AppConfigChangeSet::gps()); + gps::set_enabled(value != 0); + app::configFacade().applyPositionConfig(); + edited = true; + } + } + ::platform::esp::boards::unlockDisplay(); + return edited ? Error::None : Error::Busy; + } + case Control::Volume: + { + auto* music = music_service(); + if (!music) return Error::Unsupported; + ::music::Request request; + request.action = ::music::Action::Volume; + request.value = value; + return music->send(request) ? Error::None : Error::Apply; + } + case Control::Brightness: + { + const auto maximum = device::screen_brightness_max(); + if (!device::supports_screen_brightness() || !maximum) return Error::Unsupported; + const auto level = std::max(1, (value * maximum + 50U) / 100U); + device::set_screen_brightness(level); + settings_store::put_int("settings", "screen_brightness", level); + return settings_store::get_int("settings", "screen_brightness", -1) == static_cast(level) ? Error::None : Error::Persist; + } + } + return Error::Unsupported; +} +} // namespace +::ui::controls::IQuickControls* quick_controls_source() +{ + static Source source; + return &source; +} +void service_quick_controls() +{ + for (unsigned index = 0; index < 5; ++index) + { + if (!pending[index].load()) continue; + applying[index].store(true); + const auto request = pending[index].exchange(0); + errors[index].store(apply(static_cast(index), request & 0xFFU)); + applying[index].store(false); + return; // One user request per existing service iteration. + } +} +} // namespace platform::ui +#else +namespace platform::ui +{ +::ui::controls::IQuickControls* quick_controls_source() { return nullptr; } +void service_quick_controls() {} +} // namespace platform::ui +#endif diff --git a/platform/esp/common/src/resource_lock.cpp b/platform/esp/common/src/resource_lock.cpp new file mode 100644 index 00000000..f8da8f1f --- /dev/null +++ b/platform/esp/common/src/resource_lock.cpp @@ -0,0 +1,12 @@ +#include "platform/sync/resource_lock.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" +namespace platform::sync +{ +namespace +{ +portMUX_TYPE locks[] = {portMUX_INITIALIZER_UNLOCKED, portMUX_INITIALIZER_UNLOCKED}; +} +void enter(Resource resource) { portENTER_CRITICAL(&locks[static_cast(resource)]); } +void leave(Resource resource) { portEXIT_CRITICAL(&locks[static_cast(resource)]); } +} // namespace platform::sync diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/sx126x_radio.h b/platform/esp/idf_common/include/platform/esp/idf_common/sx126x_radio.h index 1ae44674..f510ac6d 100644 --- a/platform/esp/idf_common/include/platform/esp/idf_common/sx126x_radio.h +++ b/platform/esp/idf_common/include/platform/esp/idf_common/sx126x_radio.h @@ -35,7 +35,7 @@ class Sx126xRadio uint8_t crc_len); bool startReceive(); - void standby(); + bool standby(); float readRssi(); int startTransmit(const uint8_t* data, size_t size); diff --git a/platform/esp/idf_common/src/platform_ui_device_runtime.cpp b/platform/esp/idf_common/src/platform_ui_device_runtime.cpp index a437cbd7..dd4aaa8a 100644 --- a/platform/esp/idf_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_device_runtime.cpp @@ -1,5 +1,6 @@ #include "platform/ui/device_runtime.h" +#include "platform/audio/media_volume.h" #include #include @@ -205,10 +206,8 @@ uint8_t default_message_tone_volume() void set_message_tone_volume(uint8_t volume_percent) { const uint8_t volume = volume_percent > 100U ? 100U : volume_percent; -#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) - ::boards::t_display_p4::TDisplayP4Board::instance().setMessageToneVolume(volume); -#endif ::platform::esp::idf_common::reticulum_call_support::set_speaker_volume(volume); + ::platform::audio::publish_media_volume(volume); } void play_message_tone() diff --git a/platform/esp/idf_common/src/platform_ui_orientation_runtime.cpp b/platform/esp/idf_common/src/platform_ui_orientation_runtime.cpp index e928443e..6cab3467 100644 --- a/platform/esp/idf_common/src/platform_ui_orientation_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_orientation_runtime.cpp @@ -9,6 +9,21 @@ namespace platform::ui::orientation { +bool acquire_heading() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + return ::boards::t_display_p4::heading_runtime::acquire(); +#else + return false; +#endif +} +void release_heading() +{ +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + ::boards::t_display_p4::heading_runtime::release(); +#endif +} + HeadingState get_heading() { #if defined(TRAIL_MATE_ESP_BOARD_TAB5) diff --git a/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp b/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp index d5c0498f..91fa0292 100644 --- a/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp +++ b/platform/esp/idf_common/src/reticulum_call_runtime_support.cpp @@ -10,7 +10,9 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include #include +#include #include #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) @@ -37,6 +39,8 @@ volatile bool s_incoming_tone_stop_requested = false; #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) constexpr auto kCallOwner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL; constexpr auto kNotificationOwner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NOTIFICATION; +std::atomic s_call_volume{100}; +uint8_t call_volume() { return s_call_volume.load(); } bool media_supported() { @@ -71,8 +75,8 @@ uint8_t speaker_volume() void set_backend_volume(uint8_t volume_percent) { const uint8_t volume = volume_percent > 100U ? 100U : volume_percent; - ::boards::t_display_p4::TDisplayP4Board::instance().setMessageToneVolume(volume); - trail_mate_t_display_p4_audio_set_volume_percent(volume); + s_call_volume.store(volume); + (void)trail_mate_t_display_p4_audio_set_session_volume(kCallOwner, volume); } #else bool media_supported() @@ -103,6 +107,7 @@ uint8_t speaker_volume() { return 0; } +uint8_t call_volume() { return 0; } void set_backend_volume(uint8_t) { @@ -263,7 +268,7 @@ void ensure_registered() audio_backend.close = close_call_audio; audio_backend.read_mono = read_call_audio; audio_backend.write_mono = write_call_audio; - audio_backend.speaker_volume = speaker_volume; + audio_backend.speaker_volume = call_volume; audio_backend.set_speaker_volume = set_backend_volume; if (!::platform::esp::common::reticulum_call_audio::install_backend(audio_backend)) { @@ -283,7 +288,13 @@ void ensure_registered() void set_speaker_volume(uint8_t volume_percent) { - set_backend_volume(volume_percent); +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + const auto volume = std::min(volume_percent, 100); + ::boards::t_display_p4::TDisplayP4Board::instance().setMessageToneVolume(volume); + trail_mate_t_display_p4_audio_set_volume_percent(volume); +#else + (void)volume_percent; +#endif } bool play_message_notification() diff --git a/platform/esp/idf_common/src/sx126x_radio.cpp b/platform/esp/idf_common/src/sx126x_radio.cpp index 9a1b1b37..e1603ed7 100644 --- a/platform/esp/idf_common/src/sx126x_radio.cpp +++ b/platform/esp/idf_common/src/sx126x_radio.cpp @@ -1,4 +1,5 @@ #include "platform/esp/idf_common/sx126x_radio.h" +#include "platform/radio/radio_power.h" #include #include @@ -351,6 +352,7 @@ Sx126xRadio& Sx126xRadio::instance() bool Sx126xRadio::acquire() { + if (!platform::radio::power_policy().enabled()) return false; #if !defined(TRAIL_MATE_ESP_BOARD_TAB5) && !defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) return false; #else @@ -1113,6 +1115,7 @@ bool Sx126xRadio::configureFsk(float freq_mhz, bool Sx126xRadio::startReceive() { + if (!platform::radio::power_policy().enabled()) return false; if (!take_mutex(mutex_)) { return false; @@ -1128,15 +1131,16 @@ bool Sx126xRadio::startReceive() return ok; } -void Sx126xRadio::standby() +bool Sx126xRadio::standby() { if (!take_mutex(mutex_)) { - return; + return false; } const uint8_t mode = kStandbyRc; - write_command_locked(kCmdSetStandby, &mode, 1, true); + const bool ok = write_command_locked(kCmdSetStandby, &mode, 1, true); give_mutex(mutex_); + return ok; } float Sx126xRadio::readRssi() @@ -1153,6 +1157,7 @@ float Sx126xRadio::readRssi() int Sx126xRadio::startTransmit(const uint8_t* data, size_t size) { + if (!platform::radio::power_policy().enabled()) return -1; if (!take_mutex(mutex_)) { return -1; diff --git a/platform/esp/idf_components/t_display_p4/CMakeLists.txt b/platform/esp/idf_components/t_display_p4/CMakeLists.txt index 5163f621..a059e3d1 100644 --- a/platform/esp/idf_components/t_display_p4/CMakeLists.txt +++ b/platform/esp/idf_components/t_display_p4/CMakeLists.txt @@ -20,6 +20,7 @@ idf_component_register( "include/bsp" "." PRIV_INCLUDE_DIRS + "${CMAKE_SOURCE_DIR}/modules/core_sys/include" "${CMAKE_SOURCE_DIR}/boards/t_display_p4/include" "${CMAKE_SOURCE_DIR}/platform/shared/include" "${LILYGO_CPP_BUS_DRIVER_DIR}/src" diff --git a/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h b/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h index 97a3afd9..b7625f11 100644 --- a/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h +++ b/platform/esp/idf_components/t_display_p4/include/bsp/trail_mate_t_display_p4_runtime.h @@ -24,6 +24,7 @@ extern "C" TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL = 2, TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_WALKIE = 3, TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_SSTV = 4, + TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC = 5, } trail_mate_t_display_p4_audio_owner_t; bool trail_mate_t_display_p4_audio_is_ready(void); @@ -37,6 +38,7 @@ extern "C" const int16_t* pcm, size_t sample_count); void trail_mate_t_display_p4_audio_set_volume_percent(uint8_t volume_percent); + bool trail_mate_t_display_p4_audio_set_session_volume(trail_mate_t_display_p4_audio_owner_t owner, uint8_t volume_percent); bool trail_mate_t_display_p4_audio_set_input_gain(trail_mate_t_display_p4_audio_owner_t owner, float gain_db); diff --git a/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp b/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp index 4345dd49..8dd1c872 100644 --- a/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp +++ b/platform/esp/idf_components/t_display_p4/t_display_p4_audio_runtime.cpp @@ -1,6 +1,7 @@ #include "bsp/trail_mate_t_display_p4_runtime.h" #include "boards/t_display_p4/t_display_p4_board.h" +#include "platform/audio/audio_focus.h" #include "driver/i2s_std.h" #include "esp_codec_dev.h" @@ -45,6 +46,27 @@ struct AudioRuntime }; AudioRuntime s_audio{}; +platform::audio::FocusToken s_audio_focus; +struct FocusReservation +{ + platform::audio::FocusToken token; + bool allowed = false; + explicit FocusReservation(trail_mate_t_display_p4_audio_owner_t owner) + { + if (owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC) + { + allowed = !platform::audio::music_blocked(); + return; + } + using platform::audio::FocusOwner; + const auto kind = owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_WALKIE ? FocusOwner::Walkie : owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL ? FocusOwner::Call + : owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_SSTV ? FocusOwner::Sstv + : FocusOwner::Notification; + token = platform::audio::request_focus(kind); + allowed = bool(token); + } + ~FocusReservation() { platform::audio::release_focus(token); } +}; SemaphoreHandle_t audio_mutex() { @@ -231,6 +253,15 @@ extern "C" bool trail_mate_t_display_p4_audio_begin( return false; } + { + AudioLock lock; + if (!lock) return false; + if (s_audio.owner == owner) return s_audio.sample_rate_hz == sample_rate_hz; + if (s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE && s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC) return false; + } + // Suspend the music worker before taking the codec lock it needs to close its session. + FocusReservation focus(owner); + if (!focus.allowed) return false; AudioLock lock; if (!lock || !initialize_audio_locked()) { @@ -258,15 +289,18 @@ extern "C" bool trail_mate_t_display_p4_audio_begin( return false; } + const uint8_t hardware_volume = owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC ? 100 : s_audio.volume_percent; if (esp_codec_dev_set_in_gain(s_audio.codec, kMicrophoneGainDb) != ESP_CODEC_DEV_OK || - esp_codec_dev_set_out_vol(s_audio.codec, s_audio.volume_percent) != ESP_CODEC_DEV_OK || - esp_codec_dev_set_out_mute(s_audio.codec, s_audio.volume_percent == 0) != ESP_CODEC_DEV_OK) + esp_codec_dev_set_out_vol(s_audio.codec, hardware_volume) != ESP_CODEC_DEV_OK || + esp_codec_dev_set_out_mute(s_audio.codec, hardware_volume == 0) != ESP_CODEC_DEV_OK) { (void)esp_codec_dev_close(s_audio.codec); return false; } s_audio.owner = owner; s_audio.sample_rate_hz = sample_rate_hz; + s_audio_focus = focus.token; + focus.token = {}; ESP_LOGI(kTag, "session begin owner=%u rate=%lu volume=%u", static_cast(owner), @@ -287,17 +321,23 @@ extern "C" bool trail_mate_t_display_p4_audio_set_input_gain( extern "C" void trail_mate_t_display_p4_audio_end( trail_mate_t_display_p4_audio_owner_t owner) { - AudioLock lock; - if (!lock || !owner_active_locked(owner)) + platform::audio::FocusToken focus; { - return; - } + AudioLock lock; + if (!lock || !owner_active_locked(owner)) + { + return; + } - (void)esp_codec_dev_set_out_mute(s_audio.codec, true); - (void)esp_codec_dev_close(s_audio.codec); - s_audio.owner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE; - s_audio.sample_rate_hz = 0; - ESP_LOGI(kTag, "session end owner=%u", static_cast(owner)); + (void)esp_codec_dev_set_out_mute(s_audio.codec, true); + (void)esp_codec_dev_close(s_audio.codec); + s_audio.owner = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE; + s_audio.sample_rate_hz = 0; + focus = s_audio_focus; + s_audio_focus = {}; + ESP_LOGI(kTag, "session end owner=%u", static_cast(owner)); + } + platform::audio::release_focus(focus); } extern "C" bool trail_mate_t_display_p4_audio_read_mono( @@ -385,9 +425,17 @@ extern "C" void trail_mate_t_display_p4_audio_set_volume_percent(uint8_t volume_ } s_audio.volume_percent = volume_percent > 100U ? 100U : volume_percent; - if (s_audio.ready && s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE) + if (s_audio.ready && s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE && s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC && s_audio.owner != TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL) { (void)esp_codec_dev_set_out_vol(s_audio.codec, s_audio.volume_percent); (void)esp_codec_dev_set_out_mute(s_audio.codec, s_audio.volume_percent == 0); } } + +extern "C" bool trail_mate_t_display_p4_audio_set_session_volume(trail_mate_t_display_p4_audio_owner_t owner, uint8_t volume_percent) +{ + AudioLock lock; + if (!lock || !s_audio.ready || owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE || s_audio.owner != owner) return false; + const auto volume = std::min(volume_percent, 100); + return esp_codec_dev_set_out_vol(s_audio.codec, volume) == ESP_CODEC_DEV_OK && esp_codec_dev_set_out_mute(s_audio.codec, volume == 0) == ESP_CODEC_DEV_OK; +} diff --git a/platform/esp/wio_tracker_l2/include/platform/esp/wio_tracker_l2/wio_tracker_l2_board.h b/platform/esp/wio_tracker_l2/include/platform/esp/wio_tracker_l2/wio_tracker_l2_board.h index 6b283238..82c6232a 100644 --- a/platform/esp/wio_tracker_l2/include/platform/esp/wio_tracker_l2/wio_tracker_l2_board.h +++ b/platform/esp/wio_tracker_l2/include/platform/esp/wio_tracker_l2/wio_tracker_l2_board.h @@ -45,6 +45,9 @@ class WioTrackerL2Board final : public BoardBase, void setMessageToneVolume(uint8_t level) override; uint8_t getMessageToneVolume() const override { return tone_volume_; } bool isVoicePlaybackReady() const { return audio_ready_; } + bool beginMusic(uint32_t sample_rate); + bool writeMusic(const int16_t* mono, size_t samples); + void endMusic(); bool playCodec2Voice(const uint8_t* data, size_t size, uint8_t volume); bool installSD() override; diff --git a/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp b/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp index 8e33f147..38563a67 100644 --- a/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp +++ b/platform/esp/wio_tracker_l2/src/wio_tracker_l2_board.cpp @@ -1,8 +1,10 @@ #if defined(ARDUINO_WIO_TRACKER_L2) #include "platform/esp/wio_tracker_l2/wio_tracker_l2_board.h" +#include "platform/audio/audio_focus.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/esp/common/memory_budget.h" +#include "platform/radio/radio_power.h" #include "platform/ui/screen_runtime.h" // Keep the codec library's GPIO class out of the global ESP/Lovyan namespace. @@ -51,6 +53,7 @@ audio_driver::DriverDeviceInfo s_codec_pins; audio_driver::AudioDriverES8311Class s_codec; constexpr uint32_t kAudioSampleRate = 16000; int16_t s_tone_pcm[256]; +bool s_music_active = false; // Protected by the existing audio mutex. class BusLock { @@ -601,11 +604,13 @@ bool WioTrackerL2Board::syncTimeFromGPS(uint32_t) int WioTrackerL2Board::transmitRadio(const uint8_t* data, size_t len) { + if (!platform::radio::power_policy().enabled()) return RADIOLIB_ERR_SPI_WRITE_FAILED; BusLock lock(s_radio_mutex, 2000); return lock && radio_ready_ ? radio_.transmit(data, len) : RADIOLIB_ERR_SPI_WRITE_FAILED; } int WioTrackerL2Board::startRadioReceive() { + if (!platform::radio::power_policy().enabled()) return RADIOLIB_ERR_SPI_WRITE_FAILED; BusLock lock(s_radio_mutex); return lock && radio_ready_ ? radio_.startReceive() : RADIOLIB_ERR_SPI_WRITE_FAILED; } @@ -719,7 +724,7 @@ bool WioTrackerL2Board::initializeAudio() config.dma_buf_count = 4; config.dma_buf_len = 128; config.tx_desc_auto_clear = true; - config.fixed_mclk = kAudioSampleRate * 256; + config.fixed_mclk = 0; // Follow the current sample rate for native-rate music playback. config.mclk_multiple = I2S_MCLK_MULTIPLE_256; if (i2s_driver_install(I2S_NUM_0, &config, 0, nullptr) != ESP_OK) return false; i2s_pin_config_t pins{}; @@ -818,6 +823,8 @@ void WioTrackerL2Board::playMessageTone() void WioTrackerL2Board::playTone() { + platform::audio::FocusScope focus(platform::audio::FocusOwner::Notification); + if (!focus) return; BusLock lock(s_audio_mutex, 0); if (!lock || !writeExpander(ExpanderPin::AudioAmplifierEnable, true)) return; delay(250); @@ -841,6 +848,8 @@ void WioTrackerL2Board::playTone() bool WioTrackerL2Board::playCodec2Voice(const uint8_t* data, size_t size, uint8_t volume) { + platform::audio::FocusScope focus(platform::audio::FocusOwner::VoiceMessage); + if (!focus) return false; if (!audio_ready_ || !data || size == 0 || size > 875 || size % 7 != 0) return false; if (!ensureAudioReady()) { @@ -881,6 +890,72 @@ bool WioTrackerL2Board::playCodec2Voice(const uint8_t* data, size_t size, uint8_ void WioTrackerL2Board::setMessageToneVolume(uint8_t level) { tone_volume_ = std::min(level, 100); } +bool WioTrackerL2Board::beginMusic(uint32_t rate) +{ + if (platform::audio::music_blocked() || !ensureAudioReady()) return false; + BusLock lock(s_audio_mutex); + if (!lock || s_music_active) return false; + audio_driver::CodecConfig config; + config.input_device = audio_driver::ADC_INPUT_NONE; + config.output_device = audio_driver::DAC_OUTPUT_ALL; + config.i2s.bits = audio_driver::BIT_LENGTH_16BITS; + if (config.setRateNumeric(rate) != static_cast(rate)) return false; + bool configured = false; + { + BusLock i2c(s_i2c_mutex); + if (!i2c) return false; + configured = s_codec.setConfig(config) && s_codec.setVolume(75); + } + if (!configured || i2s_set_clk(I2S_NUM_0, rate, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_STEREO) != ESP_OK || !writeExpander(ExpanderPin::AudioAmplifierEnable, true)) + { + (void)writeExpander(ExpanderPin::AudioAmplifierEnable, false); + (void)i2s_set_clk(I2S_NUM_0, kAudioSampleRate, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_STEREO); + config.i2s.rate = audio_driver::RATE_16K; + BusLock i2c(s_i2c_mutex); + if (i2c) (void)s_codec.setConfig(config); + return false; + } + s_music_active = true; + return true; +} +bool WioTrackerL2Board::writeMusic(const int16_t* mono, size_t samples) +{ + BusLock lock(s_audio_mutex); + if (!lock || !s_music_active || !mono) return false; + for (size_t offset = 0; offset < samples;) + { + const auto count = std::min(128, samples - offset); + for (size_t i = 0; i < count; i++) s_tone_pcm[2 * i] = s_tone_pcm[2 * i + 1] = mono[offset + i]; + size_t written = 0; + const auto bytes = count * 2 * sizeof(int16_t); + if (i2s_write(I2S_NUM_0, s_tone_pcm, bytes, &written, pdMS_TO_TICKS(100)) != ESP_OK || written != bytes) return false; + offset += count; + } + return true; +} +void WioTrackerL2Board::endMusic() +{ + BusLock lock(s_audio_mutex); + if (!lock || !s_music_active) return; + i2s_zero_dma_buffer(I2S_NUM_0); + writeExpander(ExpanderPin::AudioAmplifierEnable, false); + (void)i2s_set_clk(I2S_NUM_0, kAudioSampleRate, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_STEREO); + audio_driver::CodecConfig config; + config.input_device = audio_driver::ADC_INPUT_NONE; + config.output_device = audio_driver::DAC_OUTPUT_ALL; + config.i2s.bits = audio_driver::BIT_LENGTH_16BITS; + config.i2s.rate = audio_driver::RATE_16K; + { + BusLock i2c(s_i2c_mutex); + if (i2c) + { + (void)s_codec.setConfig(config); + (void)s_codec.setVolume(75); + } + } + s_music_active = false; +} + } // namespace boards::wio_tracker_l2 BoardBase& board = boards::wio_tracker_l2::WioTrackerL2Board::instance(); diff --git a/platform/linux/common/src/platform/resource_lock.cpp b/platform/linux/common/src/platform/resource_lock.cpp new file mode 100644 index 00000000..99fb0025 --- /dev/null +++ b/platform/linux/common/src/platform/resource_lock.cpp @@ -0,0 +1,28 @@ +#include "platform/sync/resource_lock.h" +#include +#include +namespace platform::sync +{ +#if defined(__MINGW32__) && !defined(_GLIBCXX_HAS_GTHREADS) +// The legacy Windows host-test compiler has no std::mutex. Production Linux +// uses scheduler-aware mutexes; ESP uses its own critical-section adapter. +namespace +{ +std::atomic_flag locks[] = {ATOMIC_FLAG_INIT, ATOMIC_FLAG_INIT}; +} +void enter(Resource resource) +{ + while (locks[static_cast(resource)].test_and_set(std::memory_order_acquire)) + { + } +} +void leave(Resource resource) { locks[static_cast(resource)].clear(std::memory_order_release); } +#else +namespace +{ +std::mutex locks[2]; +} +void enter(Resource resource) { locks[static_cast(resource)].lock(); } +void leave(Resource resource) { locks[static_cast(resource)].unlock(); } +#endif +} // namespace platform::sync diff --git a/platform/linux/common/src/platform/ui/agenda_widget_runtime.cpp b/platform/linux/common/src/platform/ui/agenda_widget_runtime.cpp new file mode 100644 index 00000000..7c461dcd --- /dev/null +++ b/platform/linux/common/src/platform/ui/agenda_widget_runtime.cpp @@ -0,0 +1,6 @@ +#include "platform/ui/agenda_widget_runtime.h" +namespace platform::ui +{ +::ui::agenda::IAgendaSource* create_agenda_widget_source() { return nullptr; } +void destroy_agenda_widget_source(::ui::agenda::IAgendaSource*) {} +} // namespace platform::ui diff --git a/platform/linux/common/src/platform/ui/music_runtime.cpp b/platform/linux/common/src/platform/ui/music_runtime.cpp new file mode 100644 index 00000000..75ac5fc3 --- /dev/null +++ b/platform/linux/common/src/platform/ui/music_runtime.cpp @@ -0,0 +1,5 @@ +#include "platform/ui/music_runtime.h" +namespace platform::ui +{ +::music::IService* music_service() { return nullptr; } +} // namespace platform::ui diff --git a/platform/linux/common/src/platform/ui/orientation_runtime.cpp b/platform/linux/common/src/platform/ui/orientation_runtime.cpp index 1e98d815..2542044e 100644 --- a/platform/linux/common/src/platform/ui/orientation_runtime.cpp +++ b/platform/linux/common/src/platform/ui/orientation_runtime.cpp @@ -2,6 +2,8 @@ namespace platform::ui::orientation { +bool acquire_heading() { return false; } +void release_heading() {} HeadingState get_heading() { diff --git a/platform/linux/common/src/platform/ui/quick_controls_runtime.cpp b/platform/linux/common/src/platform/ui/quick_controls_runtime.cpp new file mode 100644 index 00000000..806d082a --- /dev/null +++ b/platform/linux/common/src/platform/ui/quick_controls_runtime.cpp @@ -0,0 +1,6 @@ +#include "platform/ui/quick_controls_runtime.h" +namespace platform::ui +{ +::ui::controls::IQuickControls* quick_controls_source() { return nullptr; } +void service_quick_controls() {} +} // namespace platform::ui diff --git a/platform/linux/common/src/platform/ui/wifi_runtime.cpp b/platform/linux/common/src/platform/ui/wifi_runtime.cpp index 05b5be9a..29d34174 100644 --- a/platform/linux/common/src/platform/ui/wifi_runtime.cpp +++ b/platform/linux/common/src/platform/ui/wifi_runtime.cpp @@ -86,6 +86,8 @@ bool apply_enabled(bool enabled) return !enabled; } +bool set_user_enabled(bool enabled) { return apply_enabled(enabled); } + bool suspend_for_external_storage(ExternalStorageSuspension* out_suspension) { if (out_suspension) diff --git a/tests/hostlink/test_settings_scalar_projection.cpp b/tests/hostlink/test_settings_scalar_projection.cpp index 1cc92e14..cc8e6893 100644 --- a/tests/hostlink/test_settings_scalar_projection.cpp +++ b/tests/hostlink/test_settings_scalar_projection.cpp @@ -141,8 +141,9 @@ int main() assert(text.find("ui.timezone.offset_minutes=i32:60\n") != std::string::npos); assert(text.find("ui.screen.timeout_ms=u32:300000\n") != std::string::npos); assert(text.find("ui.audio.speaker_volume=i32:0\n") != std::string::npos); + assert(text.find("radio.enabled=bool:1\n") != std::string::npos); assert(text.find("ui.haptics.vibration_enabled=bool:0\n") != std::string::npos); - assert(store::reads.size() == 12 && later_owners == 2); + assert(store::reads.size() == 13 && later_owners == 2); const auto keys = store::reads; for (const auto& key : keys) { diff --git a/tests/hostlink/test_tms_scalar_storage.cpp b/tests/hostlink/test_tms_scalar_storage.cpp index 4ecdc2f7..7826d54c 100644 --- a/tests/hostlink/test_tms_scalar_storage.cpp +++ b/tests/hostlink/test_tms_scalar_storage.cpp @@ -65,6 +65,7 @@ bool finishDocument(bool, uint16_t) { return accepted; } constexpr const char* kSettingsNs = "settings"; constexpr const char* kPowerNs = "power"; constexpr uint32_t kRequiredBase = 3; +constexpr uint32_t SeenRadioEnabled = 1UL << 18U; struct ScalarState { uint32_t seen = kRequiredBase; @@ -90,6 +91,7 @@ struct ScalarState uint8_t timezone_tzdef[192] = {}; std::size_t timezone_tzdef_len = 0; bool wifi_enabled = false; + bool radio_enabled = true; int wifi_profiles[1] = {}; uint8_t wifi_profile_count = 0; int network = 0; @@ -201,6 +203,18 @@ int main() s_state.network_seen = 0; assert(finishDocument(nullptr, true, 2)); // Legacy omission retains existing settings. assert(store::writes == 0 && store::reads == 0 && store::other_writes == 0); + s_state = ScalarState{}; + s_state.seen |= SeenRadioEnabled; + s_state.radio_enabled = false; + store::values.clear(); + store::writes = store::reads = 0; + assert(write_base_scalar_settings()); + assert(store::values.at("settings/radio_enabled") == 0); + assert(store::writes == 13 && store::reads == 13); + s_state.seen &= ~SeenRadioEnabled; + s_state.radio_enabled = true; + assert(write_base_scalar_settings()); + assert(store::values.at("settings/radio_enabled") == 0); // Old documents preserve the current preference. s_state_storage = nullptr; assert(!finishDocument(nullptr, true, 7)); } diff --git a/tests/menu_widgets/CMakeLists.txt b/tests/menu_widgets/CMakeLists.txt index 6ff4e859..7851f654 100644 --- a/tests/menu_widgets/CMakeLists.txt +++ b/tests/menu_widgets/CMakeLists.txt @@ -36,7 +36,20 @@ endif() target_link_options(widget_allocator_link PRIVATE "-Wl,--undefined=${WIDGET_ALLOCATOR_LINK_SYMBOL}") add_executable(widget_orientation test_orientation.cpp "${REPO}/platform/esp/arduino_common/src/platform_ui_orientation_runtime.cpp") target_include_directories(widget_orientation PRIVATE "${REPO}/modules/core_sys/include") -foreach(TARGET widget_ownership widget_idf_allocator widget_allocator_link widget_orientation) +add_executable(agenda_widget test_agenda_widget.cpp + "${REPO}/platform/esp/common/src/agenda_widget_runtime.cpp" + "${REPO}/platform/esp/common/src/storage/agenda_file_store.cpp" + "${REPO}/platform/esp/arduino_common/src/agenda_clock.cpp" + "${REPO}/modules/core_agenda/src/agenda_query.cpp" + "${REPO}/modules/core_agenda/src/agenda_service.cpp" + "${REPO}/modules/core_agenda/src/event_codec.cpp" + "${REPO}/modules/core_agenda/src/recurrence.cpp") +target_include_directories(agenda_widget PRIVATE agenda_fakes fakes + "${REPO}/modules/core_sys/include" "${REPO}/modules/core_agenda/include" + "${REPO}/modules/ui_presentation/include" "${REPO}/platform/esp/common/include" + "${REPO}/platform/esp/arduino_common/include") +target_compile_definitions(agenda_widget PRIVATE ESP_PLATFORM=1 TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4=1) +foreach(TARGET widget_ownership widget_idf_allocator widget_allocator_link widget_orientation agenda_widget) if(MSVC) target_compile_options(${TARGET} PRIVATE /UNDEBUG) else() diff --git a/tests/menu_widgets/agenda_fakes/esp_timer.h b/tests/menu_widgets/agenda_fakes/esp_timer.h new file mode 100644 index 00000000..b15b151b --- /dev/null +++ b/tests/menu_widgets/agenda_fakes/esp_timer.h @@ -0,0 +1,3 @@ +#pragma once +#include +inline int64_t esp_timer_get_time() { return 7000000; } diff --git a/tests/menu_widgets/agenda_fakes/platform/esp/arduino_common/storage/sd_card_runtime.h b/tests/menu_widgets/agenda_fakes/platform/esp/arduino_common/storage/sd_card_runtime.h new file mode 100644 index 00000000..53d9de54 --- /dev/null +++ b/tests/menu_widgets/agenda_fakes/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include +#include +#include +namespace agenda_test +{ +inline std::vector bytes; +inline bool present = false, ready = true, external = false, fail_open = false; +inline uint32_t session = 1; +inline unsigned opens = 0, reads = 0, handles = 0; +inline unsigned change_session_at_read = 0; +} // namespace agenda_test +namespace platform::esp::arduino_common::storage +{ +enum class SdRuntimeMemoryPolicy +{ + PsramOnly +}; +enum class SdFileReadStatus +{ + Ready, + Missing, + IoError +}; +struct SdFileReadResult +{ + SdFileReadStatus status; +}; +inline uint32_t sd_media_session() { return agenda_test::session; } +inline bool sd_card_ready() { return agenda_test::ready; } +inline bool sd_external_block_owner_active() { return agenda_test::external; } +inline SdFileReadResult sd_read_file(const char*, void*, std::size_t) { return {agenda_test::present ? SdFileReadStatus::IoError : SdFileReadStatus::Missing}; } +// Deliberately exposes no write, mount, format or initialization operation. +class SdRuntimeFile +{ + bool open_ = false; + std::size_t offset_ = 0; + + public: + explicit SdRuntimeFile(SdRuntimeMemoryPolicy policy) { assert(policy == SdRuntimeMemoryPolicy::PsramOnly); } + ~SdRuntimeFile() { close(); } + bool open(const char*, const char* mode, uint32_t session) + { + using namespace agenda_test; + assert(!std::strcmp(mode, "rb")); + ++opens; + if (!present || !ready || external || fail_open || session != sd_media_session()) return false; + assert(!open_); + open_ = true; + offset_ = 0; + ++handles; + return true; + } + bool is_open() const { return open_; } + bool read_busy() const { return false; } + void close() + { + if (open_) + { + open_ = false; + --agenda_test::handles; + } + } + uint64_t size() const { return agenda_test::bytes.size(); } + bool seek(uint64_t offset) + { + if (!open_ || offset > size()) return false; + offset_ = offset; + return true; + } + int read(void* out, std::size_t requested) + { + using namespace agenda_test; + ++reads; + if (change_session_at_read == reads) ++session; + if (!open_) return 0; + const auto count = std::min(requested, bytes.size() - offset_); + std::memcpy(out, bytes.data() + offset_, count); + offset_ += count; + return count; + } +}; +} // namespace platform::esp::arduino_common::storage diff --git a/tests/menu_widgets/test_agenda_widget.cpp b/tests/menu_widgets/test_agenda_widget.cpp new file mode 100644 index 00000000..2669fd6a --- /dev/null +++ b/tests/menu_widgets/test_agenda_widget.cpp @@ -0,0 +1,113 @@ +#include "agenda/usecase/agenda_service.h" +#include "esp_heap_caps.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/storage/agenda_file_store.h" +#include "platform/ui/agenda_widget_runtime.h" +#include "platform/ui/time_runtime.h" +#include "ui_presentation/agenda/agenda_source.h" +#include +#include +#include +#include +#include +#include +#include +#include +namespace +{ +unsigned allocations = 0; +bool fail_allocation = false; +} // namespace +void* heap_caps_malloc(std::size_t bytes, uint32_t caps) +{ + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (fail_allocation) return nullptr; + auto* result = std::malloc(bytes); + if (result) ++allocations; + return result; +} +void heap_caps_free(void* memory) +{ + if (memory) + { + assert(allocations); + --allocations; + } + std::free(memory); +} +namespace platform::ui::time +{ +time_t apply_timezone_offset_for_utc(time_t utc) { return utc; } +int timezone_profile_id() { return 0; } +int timezone_offset_min() { return 0; } +} // namespace platform::ui::time +int main() +{ + using agenda::StoreResult; + using namespace agenda_test; + fail_allocation = true; + assert(!platform::ui::create_agenda_widget_source()); + fail_allocation = false; + auto* source = platform::ui::create_agenda_widget_source(); + assert(source && allocations == 1 && opens == 0 && reads == 0); + const auto now = source->currentTime(); + assert(now.valid && opens == 0); + ui::agenda::AgendaRequest request; + request.day_start = now.calendar_seconds / 86400 * 86400; + ui::agenda::AgendaSnapshot snapshot; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::Ok && snapshot.page.count == 0 && handles == 0); + // Generate the fixture with the real store/service, without duplicating its codec. + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::string path = "agenda-widget-" + std::to_string(stamp) + ".dat", temporary = path + ".init"; + platform::esp::storage::AgendaFileStore store(path.c_str(), temporary.c_str()); + assert(store.begin() == StoreResult::Ok); + agenda::AgendaService service(store); + agenda::EventRecord event; + event.start_time = request.day_start + 3600; + std::strcpy(event.title, "Trail meeting"); + uint32_t id = 0; + assert(service.create(event, id) == agenda::AgendaResult::Ok); + { + std::ifstream file(path, std::ios::binary); + bytes.assign(std::istreambuf_iterator(file), {}); + } + assert(std::remove(path.c_str()) == 0); + const auto original = bytes; + present = true; + ++session; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::Ok && snapshot.page.count == 1); + assert(!std::strcmp(snapshot.page.rows[0].title, "Trail meeting")); + assert(handles == 0 && bytes == original); + agenda::EventRecord detail; + assert(source->detail(id, detail) == agenda::AgendaResult::Ok && detail.id == id); + ui::agenda::AgendaEditorModel draft; + assert(!source->newDraft(draft)); + external = true; + const auto before = opens; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::IoError && !snapshot.storage_ready && opens == before); + external = false; + ready = false; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::IoError && snapshot.page.count == 0 && opens == before); + ready = true; + fail_open = true; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::IoError); // An I/O failure must not appear empty. + fail_open = false; + bytes[0] ^= 1; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::Corrupt && handles == 0); + bytes = original; + change_session_at_read = reads + 2; // Header succeeds, card changes during slot scan. + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::IoError && snapshot.page.count == 0 && !snapshot.storage_ready && handles == 0); + change_session_at_read = 0; + source->snapshot(request, snapshot); + assert(snapshot.result == StoreResult::Ok && snapshot.page.count == 1 && bytes == original); + platform::ui::destroy_agenda_widget_source(source); + assert(allocations == 0 && handles == 0); + std::puts("P4 read-only Agenda: PSRAM lifetime, actual records, missing/corrupt files, USB ownership, removal and card changes passed."); +} diff --git a/tests/music/CMakeLists.txt b/tests/music/CMakeLists.txt new file mode 100644 index 00000000..2e3f8593 --- /dev/null +++ b/tests/music/CMakeLists.txt @@ -0,0 +1,66 @@ +cmake_minimum_required(VERSION 3.20) +project(music_core_tests LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +get_filename_component(REPO "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(LVGL_DIR "" CACHE PATH "Firmware LVGL source") +add_executable(music_tags test_tags.cpp + "${REPO}/modules/core_music/src/id3.cpp" + "${REPO}/modules/music_image/src/cover_decoder.cpp" + image_memory.cpp + "${LVGL_DIR}/src/libs/tjpgd/tjpgd.c" + "${LVGL_DIR}/src/libs/lodepng/lodepng.c") +target_include_directories(music_tags PRIVATE . "${LVGL_DIR}" "${REPO}/modules/core_music/include" "${REPO}/modules/music_image/include" "${REPO}/modules/core_sys/include") +target_compile_definitions(music_tags PRIVATE LV_CONF_INCLUDE_SIMPLE LODEPNG_NO_COMPILE_DISK LODEPNG_NO_COMPILE_ENCODER) +target_compile_options(music_tags PRIVATE -UNDEBUG -Wall -Wextra -fstack-usage) +enable_testing() +add_executable(media_volume test_media_volume.cpp "${REPO}/modules/core_sys/src/platform/audio/media_volume.cpp") +target_include_directories(media_volume PRIVATE "${REPO}/modules/core_sys/include") +target_compile_options(media_volume PRIVATE -UNDEBUG) +add_test(NAME media_volume COMMAND media_volume) +add_executable(radio_power test_radio_power.cpp "${REPO}/modules/core_sys/src/platform/radio/radio_power.cpp") +target_include_directories(radio_power PRIVATE "${REPO}/modules/core_sys/include") +target_compile_options(radio_power PRIVATE -UNDEBUG) +add_test(NAME radio_power COMMAND radio_power) +add_executable(device_controls test_controls.cpp "${REPO}/platform/esp/common/src/quick_controls_runtime.cpp" "${REPO}/modules/core_sys/src/platform/radio/radio_power.cpp") +target_include_directories(device_controls PRIVATE controls_fakes "${REPO}/modules/core_sys/include" "${REPO}/modules/core_music/include" "${REPO}/modules/core_gps/include" "${REPO}/modules/ui_presentation/include" "${REPO}/platform/esp/boards/include") +target_compile_definitions(device_controls PRIVATE TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4=1) +target_compile_options(device_controls PRIVATE -UNDEBUG) +add_test(NAME device_controls COMMAND device_controls) +add_test(NAME music_tags COMMAND music_tags "${CMAKE_CURRENT_SOURCE_DIR}/fixtures") +add_executable(music_focus test_focus.cpp "${REPO}/modules/core_sys/src/platform/audio/audio_focus.cpp") +target_include_directories(music_focus PRIVATE "${REPO}/modules/core_sys/include") +target_compile_options(music_focus PRIVATE -UNDEBUG) +add_test(NAME music_focus COMMAND music_focus) +add_executable(music_decoder test_decoder.cpp "${REPO}/modules/core_music/src/decoder.cpp" "${REPO}/modules/core_music/src/id3.cpp") +target_include_directories(music_decoder PRIVATE "${REPO}/modules/core_music/include") +target_compile_options(music_decoder PRIVATE -UNDEBUG -O2 -fstack-usage) +add_test(NAME music_decoder COMMAND music_decoder "${REPO}/docs/sound/call.mp3" "${REPO}/docs/sound/pager-notification.mp3") +add_executable(music_playback_policy test_playback_policy.cpp) +target_include_directories(music_playback_policy PRIVATE "${REPO}/modules/core_music/include") +target_compile_options(music_playback_policy PRIVATE -UNDEBUG) +add_test(NAME music_playback_policy COMMAND music_playback_policy) +add_executable(music_lyrics test_lyrics.cpp "${REPO}/modules/core_music/src/lyrics.cpp" "${REPO}/modules/core_music/src/id3.cpp") +target_include_directories(music_lyrics PRIVATE "${REPO}/modules/core_music/include") +target_compile_options(music_lyrics PRIVATE -UNDEBUG -O2 -fstack-usage) +add_test(NAME music_lyrics COMMAND music_lyrics) +add_executable(music_catalog test_catalog.cpp "${REPO}/modules/core_music/src/catalog.cpp" "${REPO}/modules/core_music/src/storage.cpp" "${REPO}/modules/core_music/src/id3.cpp") +target_include_directories(music_catalog PRIVATE "${REPO}/modules/core_music/include") +target_compile_options(music_catalog PRIVATE -UNDEBUG -O2 -fstack-usage) +add_test(NAME music_catalog COMMAND music_catalog) +add_executable(music_playlists test_playlists.cpp "${REPO}/modules/core_music/src/playlists.cpp" "${REPO}/modules/core_music/src/catalog.cpp" "${REPO}/modules/core_music/src/storage.cpp" "${REPO}/modules/core_music/src/id3.cpp") +target_include_directories(music_playlists PRIVATE "${REPO}/modules/core_music/include") +target_compile_options(music_playlists PRIVATE -UNDEBUG -O2 -fstack-usage) +add_test(NAME music_playlists COMMAND music_playlists "${REPO}/docs/sound/call.mp3") +add_executable(music_player test_player.cpp + "${REPO}/modules/core_music/src/player.cpp" "${REPO}/modules/core_music/src/playlists.cpp" + "${REPO}/modules/core_music/src/catalog.cpp" "${REPO}/modules/core_music/src/storage.cpp" + "${REPO}/modules/core_music/src/decoder.cpp" "${REPO}/modules/core_music/src/lyrics.cpp" "${REPO}/modules/core_music/src/id3.cpp") +target_include_directories(music_player PRIVATE "${REPO}/modules/core_music/include") +target_compile_options(music_player PRIVATE -UNDEBUG -O2 -fstack-usage) +add_test(NAME music_player COMMAND music_player "${REPO}/docs/sound/call.mp3") +foreach(TARGET music_tags music_focus music_player) + target_sources(${TARGET} PRIVATE "${REPO}/platform/linux/common/src/platform/resource_lock.cpp") +endforeach() +foreach(TARGET music_decoder music_playback_policy music_lyrics music_catalog music_playlists music_player) + target_include_directories(${TARGET} PRIVATE "${REPO}/modules/core_sys/include") +endforeach() diff --git a/tests/music/controls_fakes/app/app_facade_access.h b/tests/music/controls_fakes/app/app_facade_access.h new file mode 100644 index 00000000..db03c0aa --- /dev/null +++ b/tests/music/controls_fakes/app/app_facade_access.h @@ -0,0 +1,33 @@ +#pragma once +namespace app +{ +struct AppConfig +{ + bool gps_enabled = true; +}; +struct AppConfigChangeSet +{ + static int gps() { return 1; } +}; +inline AppConfig config; +inline bool edit_available = true; +inline unsigned edits = 0, commits = 0, projections = 0; +struct Edit +{ + explicit operator bool() const { return edit_available; } + AppConfig& config() { return app::config; } + void commit(int) { ++commits; } +}; +struct Facade +{ + Edit beginConfigEdit() + { + ++edits; + return {}; + } + void applyPositionConfig() { ++projections; } +}; +inline Facade facade; +inline bool hasAppFacade() { return true; } +inline Facade& configFacade() { return facade; } +} // namespace app diff --git a/tests/music/controls_fakes/board/LoraBoard.h b/tests/music/controls_fakes/board/LoraBoard.h new file mode 100644 index 00000000..7479bbf1 --- /dev/null +++ b/tests/music/controls_fakes/board/LoraBoard.h @@ -0,0 +1,7 @@ +#pragma once +class LoraBoard +{ + public: + virtual ~LoraBoard() = default; + virtual bool isRadioOnline() const = 0; +}; diff --git a/tests/music/controls_fakes/platform/esp/idf_common/sx126x_radio.h b/tests/music/controls_fakes/platform/esp/idf_common/sx126x_radio.h new file mode 100644 index 00000000..6a6cb4c9 --- /dev/null +++ b/tests/music/controls_fakes/platform/esp/idf_common/sx126x_radio.h @@ -0,0 +1,15 @@ +#pragma once +namespace platform::esp::idf_common +{ +class Sx126xRadio +{ + public: + static Sx126xRadio& instance() + { + static Sx126xRadio radio; + return radio; + } + bool online = true; + bool isOnline() const { return online; } +}; +} // namespace platform::esp::idf_common diff --git a/tests/music/fixtures/cover.jpg b/tests/music/fixtures/cover.jpg new file mode 100644 index 00000000..b107ea0d Binary files /dev/null and b/tests/music/fixtures/cover.jpg differ diff --git a/tests/music/fixtures/cover.png b/tests/music/fixtures/cover.png new file mode 100644 index 00000000..6ca815dd Binary files /dev/null and b/tests/music/fixtures/cover.png differ diff --git a/tests/music/fixtures/landscape.jpg b/tests/music/fixtures/landscape.jpg new file mode 100644 index 00000000..613d61ee Binary files /dev/null and b/tests/music/fixtures/landscape.jpg differ diff --git a/tests/music/fixtures/progressive.jpg b/tests/music/fixtures/progressive.jpg new file mode 100644 index 00000000..88edf1ba Binary files /dev/null and b/tests/music/fixtures/progressive.jpg differ diff --git a/tests/music/generate_cover_fixtures.py b/tests/music/generate_cover_fixtures.py new file mode 100644 index 00000000..993db73e --- /dev/null +++ b/tests/music/generate_cover_fixtures.py @@ -0,0 +1,25 @@ +"""Generate original, deterministic artwork for APIC decoder tests (requires Pillow).""" +from pathlib import Path +from PIL import Image + +folder = Path(__file__).with_name("fixtures") +folder.mkdir(exist_ok=True) +image = Image.new("RGB", (240, 240)) +for y in range(240): + for x in range(240): + color = (214-y//4, 219-y//5, 193-y//6) + if (x-173)**2+(y-62)**2 < 29**2: + color = (231,174,96) + if y > 113+abs(x-84)//3: + color = (119,151,142) + if y > 153-abs(x-168)//4: + color = (62,111,108) + if y > 193+abs(x-55)//5: + color = (35,78,80) + if y > 190 and abs(x-(109+(y-190)//3)) < 2: + color = (222,204,162) + image.putpixel((x,y),color) +image.save(folder/"cover.jpg",quality=90,progressive=False) +image.save(folder/"cover.png") +image.resize((320,160)).save(folder/"landscape.jpg",quality=90) +image.save(folder/"progressive.jpg",quality=90,progressive=True) diff --git a/tests/music/image_memory.cpp b/tests/music/image_memory.cpp new file mode 100644 index 00000000..6a804c77 --- /dev/null +++ b/tests/music/image_memory.cpp @@ -0,0 +1,47 @@ +#include "lvgl.h" +#include "platform/ui/psram_ui_lease.h" +#include "src/core/lv_global.h" +#include +#include +#include +// Codec-only host allocator: check that all PNG allocations hold the strict PSRAM lease. +extern "C" void* lv_malloc(size_t size) +{ + assert(platform::ui::psramUiRequired()); + return std::malloc(size); +} +extern "C" void* lv_realloc(void* data, size_t size) +{ + assert(platform::ui::psramUiRequired()); + return std::realloc(data, size); +} +extern "C" void lv_free(void* data) { std::free(data); } +extern "C" void* lv_memcpy(void* out, const void* in, size_t size) { return std::memcpy(out, in, size); } +extern "C" void lv_memset(void* out, uint8_t value, size_t size) { std::memset(out, value, size); } +lv_global_t lv_global{}; +extern "C" lv_draw_buf_t* lv_draw_buf_create_ex(const lv_draw_buf_handlers_t*, uint32_t width, uint32_t height, lv_color_format_t format, uint32_t stride) +{ + auto* buffer = static_cast(lv_malloc(sizeof(lv_draw_buf_t))); + if (!buffer) return nullptr; + *buffer = {}; + buffer->header.w = width; + buffer->header.h = height; + buffer->header.cf = format; + buffer->header.stride = stride; + buffer->data_size = height * stride; + buffer->data = static_cast(lv_malloc(buffer->data_size)); + if (!buffer->data) + { + lv_free(buffer); + return nullptr; + } + return buffer; +} +extern "C" void lv_draw_buf_destroy(lv_draw_buf_t* buffer) +{ + if (buffer) + { + lv_free(buffer->data); + lv_free(buffer); + } +} diff --git a/tests/music/lv_conf.h b/tests/music/lv_conf.h new file mode 100644 index 00000000..8477e0f8 --- /dev/null +++ b/tests/music/lv_conf.h @@ -0,0 +1,8 @@ +#pragma once +#define LV_CONF_H +#define LV_COLOR_DEPTH 16 +#define LV_USE_OS LV_OS_NONE +#define LV_USE_LOG 0 +#define LV_USE_TJPGD 1 +#define LV_USE_LODEPNG 1 +#define LV_USE_THORVG_INTERNAL 0 diff --git a/tests/music/memory_storage.h b/tests/music/memory_storage.h new file mode 100644 index 00000000..5d01adf7 --- /dev/null +++ b/tests/music/memory_storage.h @@ -0,0 +1,117 @@ +#pragma once +#include "music/storage.h" +#include +#include +#include +#include +#include +#include +#include +struct MemoryStorage : music::Storage +{ + using Bytes = std::vector; + std::map files; + std::set directories{"/", "/Music"}; + unsigned reads = 0, read_bytes = 0, opens = 0; + uint32_t media_session = 1; + bool online = true, fail_write = false; + music::StorageResult result = music::StorageResult::Ok; + struct Handle : music::File + { + MemoryStorage* store; + Bytes* bytes; + std::size_t at = 0; + bool writeable = false; + uint32_t size() const override { return bytes->size(); } + std::size_t read_at(uint32_t offset, void* output, std::size_t size) override + { + ++store->reads; + if (offset > bytes->size()) return 0; + size = std::min(size, bytes->size() - offset); + store->read_bytes += size; + std::memcpy(output, bytes->data() + offset, size); + return size; + } + std::size_t write(const void* input, std::size_t size) override + { + if (!writeable || store->fail_write) return 0; + if (at + size > bytes->size()) bytes->resize(at + size); + std::memcpy(bytes->data() + at, input, size); + at += size; + return size; + } + bool flush() override { return !store->fail_write; } + }; + bool ready() const override { return online; } + uint32_t session() const override { return media_session; } + music::File* open(const char* path, music::FileMode mode) override + { + ++opens; + if (!online) + { + result = music::StorageResult::Unavailable; + return nullptr; + } + if (mode == music::FileMode::Read && !files.count(path)) + { + result = music::StorageResult::Missing; + return nullptr; + } + auto& data = files[path]; + if (mode == music::FileMode::Write) data.clear(); + auto* file = new Handle; + file->store = this; + file->bytes = &data; + file->at = mode == music::FileMode::Append ? data.size() : 0; + file->writeable = mode != music::FileMode::Read; + result = music::StorageResult::Ok; + return file; + } + void close(music::File*& file) override + { + delete file; + file = nullptr; + } + music::StorageResult last_result() const override { return result; } + music::StorageResult next(const char* dir, uint64_t& cursor, music::Entry& out) override + { + if (!online) return music::StorageResult::Unavailable; + if (!directories.count(dir)) return music::StorageResult::Missing; + std::map entries; + const std::string prefix = std::string(dir) + "/"; + for (const auto& file : files) + if (file.first.rfind(prefix, 0) == 0) + { + const auto name = file.first.substr(prefix.size()); + const auto slash = name.find('/'); + entries[name.substr(0, slash)] = slash != std::string::npos; + } + auto index = cursor; + for (const auto& item : entries) + if (index-- == 0) + { + out = {}; + music::copy_text(out.name, sizeof(out.name), item.first.c_str()); + out.directory = item.second; + if (!item.second) out.size = files[prefix + item.first].size(); + ++cursor; + return music::StorageResult::Ok; + } + return music::StorageResult::End; + } + bool exists(const char* path) override { return files.count(path) || directories.count(path); } + bool mkdir(const char* path) override + { + directories.insert(path); + return true; + } + bool remove(const char* path) override { return files.erase(path) != 0; } + bool rename(const char* from, const char* to) override + { + auto found = files.find(from); + if (found == files.end() || files.count(to)) return false; + files[to] = std::move(found->second); + files.erase(found); + return true; + } +}; diff --git a/tests/music/test_catalog.cpp b/tests/music/test_catalog.cpp new file mode 100644 index 00000000..b5316568 --- /dev/null +++ b/tests/music/test_catalog.cpp @@ -0,0 +1,90 @@ +#include "memory_storage.h" +#include "music/catalog.h" +#include +#include +using Bytes = MemoryStorage::Bytes; +void frame(Bytes& frames, const char* id, const std::string& value) +{ + frames.insert(frames.end(), id, id + 4); + const auto length = value.size() + 1; + frames.insert(frames.end(), {uint8_t(length >> 24), uint8_t(length >> 16), uint8_t(length >> 8), uint8_t(length), 0, 0, 3}); + frames.insert(frames.end(), value.begin(), value.end()); +} +Bytes song(unsigned index) +{ + Bytes frames; + frame(frames, "TIT2", "Song " + std::to_string(index)); + frame(frames, "TPE1", "Artist " + std::to_string(index % 4)); + frame(frames, "TALB", "Album " + std::to_string(index % 8)); + const auto size = frames.size(); + Bytes out{'I', 'D', '3', 3, 0, 0, uint8_t((size >> 21) & 127), uint8_t((size >> 14) & 127), uint8_t((size >> 7) & 127), uint8_t(size & 127)}; + out.insert(out.end(), frames.begin(), frames.end()); + out.insert(out.end(), 128, 0x55); + return out; +} +void scan(music::Catalog& catalog) +{ + assert(catalog.begin_scan()); + unsigned steps = 0; + for (;;) + { + const auto result = catalog.scan_step(); + if (result == music::StorageResult::End) break; + assert(result == music::StorageResult::Ok && ++steps < 2000); + } +} +int main() +{ + MemoryStorage storage; + for (unsigned i = 0; i < 160; i++) + { + char path[80]; + std::snprintf(path, sizeof(path), "/Music/song-%03u.mp3", i); + storage.files[path] = song(i); + } + auto* catalog = music::make(storage); + auto* out = music::make(); + auto* track = music::make(); + assert(catalog && out && track); + assert(!catalog->load()); + scan(*catalog); + assert(catalog->count() == 160); + out->browse = music::Browse::Songs; + out->offset = 80; + storage.read_bytes = 0; + storage.opens = 0; + assert(catalog->browse(*out, 8) && out->row_count == 8 && out->total == 160); + assert(storage.read_bytes == 8 * 580 && storage.opens == 1); + assert(!std::strcmp(out->rows[0].title, "Song 80")); + const auto id = out->rows[3].id; + assert(catalog->find(id, *track) && !std::strcmp(track->title, "Song 83")); + out->browse = music::Browse::Artists; + out->offset = 0; + assert(catalog->browse(*out, 8) && out->total == 4 && out->rows[0].count == 40); + assert(catalog->queue_count(music::Browse::ArtistTracks, "Artist 0") == 40); + assert(catalog->queue_track(music::Browse::ArtistTracks, "Artist 0", 3, *track) && !std::strcmp(track->title, "Song 12")); + // An unfinished inactive lane is never published after a reboot. + assert(catalog->begin_scan()); + assert(catalog->scan_step() == music::StorageResult::Ok); + catalog->abort_scan(); + music::destroy(catalog); + catalog = music::make(storage); + assert(catalog->load() && catalog->count() == 160); + storage.files["/Music/new.mp3"] = song(999); + scan(*catalog); + assert(catalog->count() == 161); + // The newest manifest is corrupt: recover the previous complete lane. + storage.files["/.trailmate/music/manifest-1.idx"][20] ^= 1; + music::destroy(catalog); + catalog = music::make(storage); + assert(catalog->load() && catalog->count() == 160); + storage.files["/.trailmate/music/songs-0.idx"][70] ^= 1; + assert(!catalog->track(0, *track)); + ++storage.media_session; + assert(!catalog->track(80, *track)); + assert(!music::valid_music_path("/Music/../secret.mp3") && !music::valid_music_path("/Music/a\nb.mp3")); + music::destroy(catalog); + music::destroy(out); + music::destroy(track); + std::puts("Paged SD catalog, fixed records, stable ids, category counts, incomplete scan recovery, CRC and media session checks passed."); +} diff --git a/tests/music/test_controls.cpp b/tests/music/test_controls.cpp new file mode 100644 index 00000000..bc043f38 --- /dev/null +++ b/tests/music/test_controls.cpp @@ -0,0 +1,182 @@ +#include "app/app_facade_access.h" +#include "board/LoraBoard.h" +#include "music/service.h" +#include "platform/esp/boards/board_runtime.h" +#include "platform/esp/idf_common/sx126x_radio.h" +#include "platform/radio/radio_power.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/quick_controls_runtime.h" +#include "platform/ui/wifi_access_runtime.h" +#include "platform/ui/wifi_runtime.h" +#include "ui_presentation/controls/quick_controls.h" +#include +#include +#include +#include +namespace fake +{ +bool display_available = true, gps_enabled = true, call = false; +unsigned hardware_calls = 0, wifi_calls = 0, writes = 0; +uint8_t brightness = 7; +std::map settings; +platform::ui::wifi::Status wifi; +platform::ui::wifi_access::RuntimeStatus access; +class Radio : public LoraBoard +{ + bool isRadioOnline() const override { return true; } +} radio; +class Music : public music::IService +{ + public: + uint8_t level = 45; + uint8_t volume() const override { return level; } + void attach(uint8_t) override { assert(false); } + void detach() override { assert(false); } + bool snapshot(music::Snapshot&) override + { + assert(false); + return false; + } + bool send(const music::Request& request) override + { + assert(request.action == music::Action::Volume); + level = request.value; + return true; + } + void request_artwork(uint16_t) override { assert(false); } + music::Artwork* acquire_artwork() override + { + assert(false); + return nullptr; + } + void request_lyrics(bool) override { assert(false); } +} music; +} // namespace fake +namespace platform::esp::boards +{ +bool tryResolveAppContextInitHandles(AppContextInitHandles* out) +{ + out->lora_board = &fake::radio; + return true; +} +bool lockDisplay(uint32_t) { return fake::display_available; } +void unlockDisplay() {} +} // namespace platform::esp::boards +namespace platform::ui +{ +music::IService* music_service() { return &fake::music; } +namespace wifi +{ +Status status() { return fake::wifi; } +bool set_user_enabled(bool enabled) +{ + ++fake::wifi_calls; + ++fake::hardware_calls; + fake::wifi.enabled = enabled; + return true; +} +} // namespace wifi +namespace wifi_access +{ +RuntimeStatus status() { return fake::access; } +} // namespace wifi_access +namespace reticulum_call +{ +bool realtime_mode_active() { return fake::call; } +} // namespace reticulum_call +namespace gps +{ +bool is_enabled() { return fake::gps_enabled; } +void set_enabled(bool enabled) +{ + ++fake::hardware_calls; + fake::gps_enabled = enabled; +} +} // namespace gps +namespace device +{ +bool gps_supported() { return true; } +bool supports_screen_brightness() { return true; } +uint8_t screen_brightness_max() { return 10; } +uint8_t screen_brightness() { return fake::brightness; } +void set_screen_brightness(uint8_t level) +{ + ++fake::hardware_calls; + fake::brightness = level; +} +} // namespace device +namespace settings_store +{ +void put_int(const char*, const char* key, int value) +{ + ++fake::writes; + fake::settings[key] = value; +} +int get_int(const char*, const char* key, int fallback) +{ + const auto found = fake::settings.find(key); + return found == fake::settings.end() ? fallback : found->second; +} +void put_bool(const char* ns, const char* key, bool value) { put_int(ns, key, value); } +bool get_bool(const char* ns, const char* key, bool fallback) { return get_int(ns, key, fallback) != 0; } +} // namespace settings_store +} // namespace platform::ui +int main() +{ + using namespace ui::controls; + auto* source = platform::ui::quick_controls_source(); + Snapshot out; + fake::wifi.supported = true; + for (unsigned i = 0; i < 100; i++) assert(source->snapshot(out)); + assert(fake::hardware_calls == 0 && fake::writes == 0 && app::edits == 0); + assert(out.lora == SwitchState::On && out.volume == 45); + fake::access.ota_active = true; + assert(source->set(Control::Wifi, 0)); + source->snapshot(out); + assert(out.wifi == SwitchState::Changing); + platform::ui::service_quick_controls(); + assert(fake::wifi_calls == 0); + source->snapshot(out); + assert(std::strstr(out.message, "in use")); + fake::access.ota_active = false; + assert(source->set(Control::Wifi, 1)); + platform::ui::service_quick_controls(); + assert(fake::wifi_calls == 1 && fake::wifi.enabled); + auto& radio = platform::radio::power_policy(); + assert(radio.begin_exclusive()); + source->set(Control::Lora, 0); + platform::ui::service_quick_controls(); + assert(radio.enabled() && !fake::settings.count("radio_enabled")); + radio.end_exclusive(); + source->set(Control::Lora, 0); + platform::ui::service_quick_controls(); + source->snapshot(out); + assert(out.lora == SwitchState::Changing && out.lora_enabled); + radio.acknowledge(false); + // A disabled radio may be uninitialized after reboot. Enabling uses capability, + // rather than requiring the very hardware initialization it is meant to start. + platform::esp::idf_common::Sx126xRadio::instance().online = false; + source->snapshot(out); + assert(out.lora == SwitchState::Off); + source->set(Control::Lora, 1); + platform::ui::service_quick_controls(); + assert(radio.enabled()); + fake::display_available = false; + source->set(Control::Gps, 0); + platform::ui::service_quick_controls(); + assert(fake::gps_enabled && app::edits == 0); + fake::display_available = true; + source->set(Control::Gps, 0); + platform::ui::service_quick_controls(); + assert(!fake::gps_enabled && !app::config.gps_enabled && app::commits == 1 && app::projections == 1); + const auto writes = fake::writes; + source->set(Control::Brightness, 20); + source->set(Control::Brightness, 50); + source->set(Control::Brightness, 90); + platform::ui::service_quick_controls(); + assert(fake::brightness == 9 && fake::writes == writes + 1); + source->set(Control::Volume, 27); + platform::ui::service_quick_controls(); + assert(fake::music.level == 27); + assert(!source->set(Control::Brightness, 0) && !source->set(Control::Gps, 2)); +} diff --git a/tests/music/test_decoder.cpp b/tests/music/test_decoder.cpp new file mode 100644 index 00000000..9c6b7c88 --- /dev/null +++ b/tests/music/test_decoder.cpp @@ -0,0 +1,56 @@ +#include "music/decoder.h" +#include "music/id3.h" +#include +#include +#include +#include +struct File : music::Reader +{ + FILE* handle = nullptr; + uint32_t length = 0; + explicit File(const char* path) + { + handle = std::fopen(path, "rb"); + assert(handle); + std::fseek(handle, 0, SEEK_END); + length = std::ftell(handle); + } + ~File() { std::fclose(handle); } + uint32_t size() const override { return length; } + std::size_t read_at(uint32_t offset, void* out, std::size_t bytes) override { return std::fseek(handle, offset, SEEK_SET) == 0 ? std::fread(out, 1, bytes, handle) : 0; } +}; +int main(int argc, char** argv) +{ + assert(argc > 1); + for (int n = 1; n < argc; n++) + { + File input(argv[n]); + auto* track = music::make(); + auto* parser = music::make(); + assert(track && parser); + std::strcpy(track->path, "/Music/test.mp3"); + assert(parser->read(input, *track)); + auto* decoder = music::make(); + assert(decoder && decoder->ready() && decoder->open(input, *track)); + unsigned frames = 0, peak = 0; + music::PcmFrame frame; + for (;;) + { + const auto result = decoder->next(frame); + if (result == music::DecodeResult::End) break; + assert(result == music::DecodeResult::Frame && frame.samples && frame.count <= 1152 && frame.rate >= 8000 && frame.rate <= 48000); + for (unsigned i = 0; i < frame.count; i++) peak = std::max(peak, static_cast(std::abs(static_cast(frame.samples[i])))); + assert(++frames < 100000); + } + std::printf("Decoded %s: frames=%u peak=%u position=%lu duration=%lu\n", argv[n], frames, peak, static_cast(decoder->position_ms()), static_cast(track->duration_ms)); + std::fflush(stdout); + assert(frames > 10 && peak > 100 && decoder->position_ms() > 250 && track->duration_ms > 250); + assert(decoder->seek(track->duration_ms / 2)); + assert(decoder->next(frame) == music::DecodeResult::Frame); + assert(decoder->position_ms() >= track->duration_ms / 2); + std::printf("MP3 %s: frames=%u peak=%u duration=%lu ms workspace=%u bytes.\n", argv[n], frames, peak, static_cast(track->duration_ms), static_cast(music::Decoder::workspace_bytes())); + music::destroy(decoder); + music::destroy(parser); + music::destroy(track); + } +} diff --git a/tests/music/test_focus.cpp b/tests/music/test_focus.cpp new file mode 100644 index 00000000..b20ac5c7 --- /dev/null +++ b/tests/music/test_focus.cpp @@ -0,0 +1,63 @@ +#include "platform/audio/audio_focus.h" +#include +using namespace platform::audio; +struct Client +{ + bool wanted_play = true, playing = true, ack = true; + unsigned suspends = 0, resumes = 0; + static bool suspend(void* p) + { + auto& self = *static_cast(p); + ++self.suspends; + assert(music_blocked()); + if (self.ack) self.playing = false; + return self.ack; + } + static void resume(void* p) + { + auto& self = *static_cast(p); + ++self.resumes; + self.playing = self.wanted_play && !music_blocked(); + } +}; +int main() +{ + Client client; + register_music_focus_client({&client, Client::suspend, Client::resume}); + auto voice = request_focus(FocusOwner::VoiceMessage); + assert(voice && music_blocked() && !client.playing); + auto walkie = request_focus(FocusOwner::Walkie); + assert(walkie); + release_focus(voice); + assert(music_blocked() && !client.playing && client.resumes == 0); + release_focus(voice); + assert(client.resumes == 0); + release_focus(walkie); + assert(!music_blocked() && client.playing && client.resumes == 1); + auto newer = request_focus(FocusOwner::Call); + assert(newer); + release_focus(voice); + assert(music_blocked()); + client.wanted_play = false; + release_focus(newer); + assert(!client.playing); + client.ack = false; + auto denied = request_focus(FocusOwner::Sstv); + assert(!denied && !music_blocked()); + client.ack = true; + FocusToken all[12]; + for (auto& token : all) + { + token = request_focus(FocusOwner::Notification); + assert(token); + } + assert(!request_focus(FocusOwner::Notification)); + for (unsigned i = 0; i < 11; i++) + { + release_focus(all[i]); + assert(music_blocked()); + } + release_focus(all[11]); + assert(!music_blocked() && !client.playing); + register_music_focus_client({}); +} diff --git a/tests/music/test_lyrics.cpp b/tests/music/test_lyrics.cpp new file mode 100644 index 00000000..0789cc14 --- /dev/null +++ b/tests/music/test_lyrics.cpp @@ -0,0 +1,51 @@ +#include "music/lyrics.h" +#include +#include +#include +struct Text : music::Reader +{ + std::string content; + uint32_t size() const override { return content.size(); } + std::size_t read_at(uint32_t at, void* out, std::size_t count) override + { + count = std::min(count, content.size() - at); + std::memcpy(out, content.data() + at, count); + return count; + } +}; +int main() +{ + Text file; + file.content = "[ar:Aster]\n[offset:200]\n[00:01.00]First line\n[00:02.50][00:04.00]Second line\n[00:03.00]Third line\n"; + auto* lyrics = music::make(); + auto* snapshot = music::make(); + assert(lyrics && snapshot && lyrics->open(file, true)); + lyrics->update(1210, *snapshot); + assert(!std::strcmp(snapshot->lyric_current, "First line")); + lyrics->update(2800, *snapshot); + assert(!std::strcmp(snapshot->lyric_current, "Second line") && !std::strcmp(snapshot->lyric_next, "Third line")); + lyrics->update(4400, *snapshot); + assert(!std::strcmp(snapshot->lyric_current, "Second line") && !snapshot->lyric_next[0]); + assert(!lyrics->update(4410, *snapshot)); + assert(lyrics->open(file, true)); + lyrics->update(0, *snapshot); + assert(!snapshot->lyric_current[0] && !snapshot->lyric_previous[0] && !std::strcmp(snapshot->lyric_next, "First line")); + file.content = "Plain one\nPlain two\nPlain three"; + assert(lyrics->open(file, false)); + lyrics->update(9000, *snapshot); + assert(!snapshot->synchronized_lyrics && !std::strcmp(snapshot->lyric_current, "Plain one")); + lyrics->scroll(1); + lyrics->update(9000, *snapshot); + assert(!std::strcmp(snapshot->lyric_current, "Plain two")); + file.content = "\xff\xfeP\0l\0a\0i\0n\0"; // explicit-size UTF16 fixture follows. + const unsigned char unicode[] = {0xff, 0xfe, 0x2d, 0x4e, 0x87, 0x65, 10, 0, 0x4c, 0x6b, 0xcd, 0x8b}; + file.content.assign(reinterpret_cast(unicode), sizeof(unicode)); + assert(lyrics->open(file, false)); + lyrics->update(0, *snapshot); + assert(!std::strcmp(snapshot->lyric_current, "\xe4\xb8\xad\xe6\x96\x87")); + lyrics->clear(); + lyrics->update(0, *snapshot); + assert(!snapshot->has_lyrics); + music::destroy(lyrics); + music::destroy(snapshot); +} diff --git a/tests/music/test_media_volume.cpp b/tests/music/test_media_volume.cpp new file mode 100644 index 00000000..0191cc4c --- /dev/null +++ b/tests/music/test_media_volume.cpp @@ -0,0 +1,18 @@ +#include "platform/audio/media_volume.h" +#include +int main() +{ + using namespace platform::audio; + assert(media_volume(45) == 45); + publish_media_volume(27); + unsigned observed = 0; + observe_media_volume([](void* context, uint8_t value) + { *static_cast(context) = value; }, + &observed); + assert(observed == 27 && media_volume(45) == 27); + publish_media_volume(80); + assert(observed == 80 && media_volume(45) == 80); + publish_media_volume(255); + assert(observed == 100 && media_volume(45) == 100); + observe_media_volume(nullptr, nullptr); +} diff --git a/tests/music/test_playback_policy.cpp b/tests/music/test_playback_policy.cpp new file mode 100644 index 00000000..c9d473b8 --- /dev/null +++ b/tests/music/test_playback_policy.cpp @@ -0,0 +1,28 @@ +#include "music/playback_policy.h" +#include +int main() +{ + music::PlaybackIntent intent; + intent.play(); + assert(intent.may_open_audio(false) && !intent.may_open_audio(true)); + assert(intent.state(true) == music::Playback::Interrupted); + intent.pause(); + assert(!intent.may_open_audio(false)); + music::QueueCursor queue; + queue.reset(3, 2, 17); + assert(queue.next(music::PlayMode::RepeatAll, true) == 0); + assert(queue.next(music::PlayMode::RepeatOne, true) == 0); + assert(queue.next(music::PlayMode::RepeatOne, false) == 1); + assert(queue.previous(music::PlayMode::RepeatAll) == 0); + for (unsigned n = 0; n < 100; n++) + { + const auto old = queue.current(); + const auto next = queue.next(music::PlayMode::Shuffle, true); + assert(next != old && next < 3); + assert(queue.previous(music::PlayMode::Shuffle) == old); + } + queue.reset(1, 0, 1); + assert(queue.next(music::PlayMode::Shuffle, true) == 0); + queue.reset(0, 0, 1); + assert(queue.next(music::PlayMode::RepeatAll, true) == 0 && queue.previous(music::PlayMode::Shuffle) == 0); +} diff --git a/tests/music/test_player.cpp b/tests/music/test_player.cpp new file mode 100644 index 00000000..607ad72d --- /dev/null +++ b/tests/music/test_player.cpp @@ -0,0 +1,149 @@ +#include "memory_storage.h" +#include "music/player.h" +#include +#include +#include +struct Output : music::AudioOutput +{ + bool occupied = false, opened = false, interrupt_on_write = false; + unsigned frames = 0, begins = 0, ends = 0; + bool supported() const override { return true; } + bool blocked() const override { return occupied; } + bool begin(uint32_t rate) override + { + assert(!occupied && rate >= 8000); + opened = true; + ++begins; + return true; + } + bool write(const int16_t* pcm, std::size_t count) override + { + assert(opened && pcm && count); + if (interrupt_on_write) + { + occupied = true; + return false; + } + ++frames; + return true; + } + void end() override + { + assert(opened); + opened = false; + ++ends; + } +}; +struct Images : music::ImageLoader +{ + unsigned decodes = 0; + music::Artwork* decode(music::Reader&, const music::Track&, uint16_t, bool (*)(void*), void*, music::CoverResult& result) override + { + ++decodes; + result = music::CoverResult::Missing; + return nullptr; + } +}; +int main(int argc, char** argv) +{ + assert(argc == 2); + MemoryStorage storage; + std::ifstream file(argv[1], std::ios::binary); + assert(file); + MemoryStorage::Bytes bytes(std::istreambuf_iterator(file), {}); + storage.files["/Music/first.mp3"] = bytes; + storage.files["/Music/second.mp3"] = bytes; + Output output; + Images images; + auto* player = music::make(storage, output, images); + assert(player); + player->attach(0); + for (unsigned i = 0; i < 3; i++) player->tick(); + assert(storage.opens == 0 && !output.opened && !player->active()); + player->attach(3); + player->tick(); + assert(player->active() && player->state.busy); + player->attach(0); + assert(!player->active() && !player->state.busy); // Hidden browsing cannot keep scanning SD. + player->attach(3); + for (unsigned i = 0; i < 12; i++) player->tick(); + assert(player->state.total == 2 && !player->state.busy); + music::Request request; + request.action = music::Action::Play; + request.id = player->state.rows[0].id; + assert(player->command(request)); + player->tick(); + assert(output.opened && player->state.playback == music::Playback::Playing && output.frames > 0 && images.decodes == 0); + output.interrupt_on_write = true; + player->tick(); + assert(!output.opened && player->active() && player->state.playback == music::Playback::Interrupted); + output.interrupt_on_write = false; + const auto before_focus = storage.opens; + player->request_artwork(96); + player->tick(); + assert(storage.opens == before_focus && images.decodes == 0); + player->request_artwork(0); + output.occupied = true; + player->tick(); + assert(!output.opened && player->state.playback == music::Playback::Interrupted); + output.occupied = false; + player->tick(); + assert(output.opened && player->state.playback == music::Playback::Playing); + output.occupied = true; + player->tick(); + request.action = music::Action::TogglePause; + assert(player->command(request)); + output.occupied = false; + player->tick(); + assert(!output.opened && player->state.playback == music::Playback::Paused); + request.action = music::Action::TogglePause; + assert(player->command(request)); + player->tick(); + assert(output.opened); + request.action = music::Action::SetPlayMode; + request.value = 1; + assert(player->command(request)); + const auto first = player->state.current.id; + for (unsigned i = 0; i < 40; i++) player->tick(); + assert(player->state.current.id == first && player->active()); + request.action = music::Action::Next; + assert(player->command(request)); + player->tick(); + assert(player->state.current.id != first); + player->request_artwork(96); + player->tick(); + assert(images.decodes == 1); + player->request_artwork(0); + for (unsigned i = 0; i < 4; i++) player->tick(); + assert(images.decodes == 1 && "hidden cover is not decoded"); + request.action = music::Action::TogglePause; + assert(player->command(request)); + player->tick(); + assert(!output.opened); + const auto paused_track = player->state.current.id; + request.action = music::Action::Next; + assert(player->command(request)); + player->tick(); + assert(!output.opened && player->state.playback == music::Playback::Paused && player->state.current.id != paused_track); + auto* saved = music::make(); + player->save_resume(*saved); + auto* reopened = music::make(storage, output, images); + reopened->restore_resume(*saved); + assert(reopened->state.current.id == saved->current.id && reopened->state.playback == music::Playback::Paused); + music::destroy(reopened); + ++storage.media_session; + reopened = music::make(storage, output, images); + reopened->restore_resume(*saved); + assert(reopened->state.current.id == 0 && reopened->state.playback == music::Playback::Stopped); + music::destroy(reopened); + music::destroy(saved); + request.action = music::Action::Stop; + assert(player->command(request)); + assert(!output.opened && !player->active()); + player->attach(0); + const auto opens = storage.opens; + for (unsigned i = 0; i < 4; i++) player->tick(); + assert(storage.opens == opens); + music::destroy(player); + assert(!output.opened && output.begins == output.ends); +} diff --git a/tests/music/test_playlists.cpp b/tests/music/test_playlists.cpp new file mode 100644 index 00000000..c9304769 --- /dev/null +++ b/tests/music/test_playlists.cpp @@ -0,0 +1,64 @@ +#include "memory_storage.h" +#include "music/playlists.h" +#include +#include +#include +#include +int main(int argc, char** argv) +{ + assert(argc == 2); + std::ifstream mp3(argv[1], std::ios::binary); + assert(mp3); + MemoryStorage storage; + const MemoryStorage::Bytes sound(std::istreambuf_iterator(mp3), {}); + storage.files["/Music/first.mp3"] = sound; + storage.files["/Music/second.mp3"] = sound; + auto* catalog = music::make(storage); + assert(catalog->begin_scan()); + while (catalog->scan_step() == music::StorageResult::Ok) + { + } + assert(catalog->count() == 2); + auto* lists = music::make(storage, *catalog); + auto* out = music::make(); + auto* track = music::make(); + uint64_t id = 0; + const auto first = music::path_id("/Music/first.mp3"), second = music::path_id("/Music/second.mp3"); + assert(lists->create("Evening trail", first, id) && id && lists->count(id) == 1); + assert(lists->add(id, first) && lists->count(id) == 1); + assert(lists->add(id, second) && lists->count(id) == 2); + assert(lists->track(id, 1, *track) && track->id == second); + assert(lists->rename(id, "Ridge walk")); + out->browse = music::Browse::Playlists; + assert(lists->browse(*out, 8) && out->total == 1 && !std::strcmp(out->rows[0].title, "Ridge walk")); + assert(lists->remove(id, first) && lists->count(id) == 1); + out->browse = music::Browse::PlaylistTracks; + out->playlist_id = id; + assert(lists->browse(*out, 8) && out->total == 1 && out->rows[0].id == second); + storage.fail_write = true; + assert(!lists->add(id, first)); + storage.fail_write = false; + assert(lists->count(id) == 1); + unsigned yields = 0; + catalog->set_yield([](void* context) + { ++*static_cast(context); return false; }, + &yields); + assert(!lists->add(id, first) && yields > 0); + catalog->set_yield(nullptr, nullptr); + assert(lists->count(id) == 1); // Cancellation cannot commit a truncated rewrite. + uint64_t invalid = 0; + assert(!lists->create("Name\nInjected", 0, invalid)); + assert(!lists->create(" ", 0, invalid)); + char path[128], backup[144]; + std::snprintf(path, sizeof(path), "/.trailmate/music/playlists/%016llx.m3u", static_cast(id)); + std::snprintf(backup, sizeof(backup), "%s.bak", path); + assert(storage.rename(path, backup)); + assert(lists->count(id) == 1 && storage.exists(path)); + assert(lists->erase(id) && !storage.exists(path)); + assert(storage.files.count("/Music/first.mp3") && storage.files.count("/Music/second.mp3")); + music::destroy(lists); + music::destroy(catalog); + music::destroy(out); + music::destroy(track); + std::puts("Persistent M3U8 creation/add/dedup/remove/rename/delete, failed write rollback and backup recovery passed."); +} diff --git a/tests/music/test_radio_power.cpp b/tests/music/test_radio_power.cpp new file mode 100644 index 00000000..97b40d89 --- /dev/null +++ b/tests/music/test_radio_power.cpp @@ -0,0 +1,35 @@ +#include "platform/radio/radio_power.h" +#include +int main() +{ + platform::radio::PowerPolicy policy; + assert(policy.enabled() && policy.status().applied); + assert(policy.begin_exclusive()); + assert(!policy.request(false)); // A walkie/USB/probe owns the transition boundary. + assert(!policy.begin_exclusive()); + assert(policy.status().exclusive); + policy.end_exclusive(); + assert(policy.request(false)); + assert(!policy.enabled() && policy.status().applied); // No optimistic physical ACK. + policy.fail(); + assert(policy.status().failed && policy.status().applied); + assert(policy.request(false)); + assert(!policy.status().failed); + policy.acknowledge(false); + assert(!policy.status().applied); + assert(policy.begin_exclusive()); // USB storage works with RF disabled. + assert(!policy.request(true)); + policy.end_exclusive(); + assert(policy.request(true)); + policy.acknowledge(false); // A stale completion must not change a newer preference. + assert(policy.enabled() && !policy.status().applied); + policy.acknowledge(true); + assert(policy.status().applied); + for (unsigned i = 0; i < 1000; ++i) + { + assert(policy.request(false)); + assert(policy.request(true)); + policy.acknowledge(false); + assert(policy.status().applied && policy.enabled()); + } +} diff --git a/tests/music/test_tags.cpp b/tests/music/test_tags.cpp new file mode 100644 index 00000000..616b5476 --- /dev/null +++ b/tests/music/test_tags.cpp @@ -0,0 +1,196 @@ +#include "music/cover_decoder.h" +#include "music/id3.h" +#include +#include +#include +#include +#include +#include +#include +namespace +{ +using Bytes = std::vector; +struct MemoryReader : music::Reader +{ + Bytes bytes; + unsigned read_bytes = 0; + uint32_t size() const override { return bytes.size(); } + std::size_t read_at(uint32_t at, void* output, std::size_t size) override + { + assert(at <= bytes.size()); + size = std::min(size, bytes.size() - at); + std::memcpy(output, bytes.data() + at, size); + read_bytes += size; + return size; + } +}; +void append(Bytes& out, const Bytes& data) { out.insert(out.end(), data.begin(), data.end()); } +Bytes text(const char* value, uint8_t encoding = 3) +{ + Bytes out{encoding}; + out.insert(out.end(), value, value + std::strlen(value)); + return out; +} +Bytes length(uint32_t value, bool sync) +{ + return sync ? Bytes{uint8_t((value >> 21) & 127), uint8_t((value >> 14) & 127), uint8_t((value >> 7) & 127), uint8_t(value & 127)} : Bytes{uint8_t(value >> 24), uint8_t(value >> 16), uint8_t(value >> 8), uint8_t(value)}; +} +void frame(Bytes& out, const char* id, const Bytes& data, int version = 3, uint8_t flags = 0) +{ + out.insert(out.end(), id, id + std::strlen(id)); + auto size = length(data.size(), version == 4); + if (version == 2) out.insert(out.end(), size.begin() + 1, size.end()); + else + { + append(out, size); + out.push_back(0); + out.push_back(flags); + } + append(out, data); +} +Bytes tag(const Bytes& payload, int version = 3, bool footer = false) +{ + Bytes out{'I', 'D', '3', uint8_t(version), 0, uint8_t(footer ? 16 : 0)}; + append(out, length(payload.size(), true)); + append(out, payload); + if (footer) + { + Bytes foot{'3', 'D', 'I', 4, 0, 16}; + append(foot, length(payload.size(), true)); + append(out, foot); + } + out.insert(out.end(), 512, 0x55); + return out; +} +Bytes file(const std::string& path) +{ + std::ifstream input(path, std::ios::binary); + assert(input); + return Bytes(std::istreambuf_iterator(input), {}); +} +Bytes picture(const Bytes& image, uint8_t type = 3, const char* mime = "image/jpeg") +{ + Bytes out{0}; + out.insert(out.end(), mime, mime + std::strlen(mime) + 1); + out.push_back(type); + out.push_back(0); + append(out, image); + return out; +} +} // namespace +int main(int argc, char** argv) +{ + assert(argc == 2); + auto* parser = music::make(); + auto* track = music::make(); + auto* page = music::make(); + assert(parser && track && page); + track->id = 42; + std::strcpy(track->path, "/Music/fallback.mp3"); + Bytes frames; + frame(frames, "TIT2", text("After the rain")); + frame(frames, "TPE1", text("Aster")); + frame(frames, "TALB", text("Small horizons")); + frame(frames, "TPE2", text("Album artist")); + frame(frames, "TCOM", text("Composer")); + frame(frames, "TRCK", text("1/12")); + frame(frames, "TPOS", text("1/1")); + frame(frames, "TCOP", text("Original fixture")); + frame(frames, "TENC", text("Test encoder")); + frame(frames, "APIC", picture(file(std::string(argv[1]) + "/cover.jpg"), 4)); + frame(frames, "APIC", picture(file(std::string(argv[1]) + "/cover.jpg"), 3)); + MemoryReader input; + input.bytes = tag(frames); + assert(parser->read(input, *track, page)); + assert(!std::strcmp(track->title, "After the rain") && !std::strcmp(track->album, "Small horizons")); + assert(track->picture_type == 3 && track->picture_format == music::PictureFormat::Jpeg && page->total == 12 && page->count == 6); + assert(input.read_bytes < 4096); + assert(parser->read(input, *track, page, 6)); + assert(!std::strcmp(page->fields[0].id, "TPOS")); + music::CoverResult result; + auto* art = music::decode_cover(input, *track, 96, result); + assert(art && result == music::CoverResult::Ready && art->width == 96 && art->height == 96); + assert(art->pixels[10 * 96 + 10] != art->pixels[90 * 96 + 10]); + art->retain(); + art->drop(); + assert(art->pixels[0]); + art->drop(); + unsigned pumps = 0; + art = music::decode_cover( + input, *track, 96, result, [](void* context) + { + ++*static_cast(context); + return false; }, + &pumps); + assert(!art && result == music::CoverResult::Cancelled && pumps == 1); + // Cancellation must release the decoder and thumbnail so a retry succeeds. + art = music::decode_cover(input, *track, 96, result); + assert(art && result == music::CoverResult::Ready); + art->drop(); + for (const auto* name : {"cover.png", "landscape.jpg", "progressive.jpg"}) + { + frames.clear(); + frame(frames, "APIC", picture(file(std::string(argv[1]) + "/" + name), 3, std::strstr(name, "png") ? "image/png" : "image/jpeg")); + input.bytes = tag(frames); + assert(parser->read(input, *track, page)); + art = music::decode_cover(input, *track, 96, result); + if (std::strstr(name, "progressive")) + { + assert(!art && result == music::CoverResult::Unsupported); + continue; + } + assert(art && result == music::CoverResult::Ready && art->width == 96); + if (std::strstr(name, "landscape")) assert(art->height == 48); + art->drop(); + } + frames.clear(); + frame(frames, "TIT2", Bytes{1, 0xff, 0xfe, 0x2d, 0x4e, 0x87, 0x65, 0, 0}); + input.bytes = tag(frames, 3); + assert(parser->read(input, *track, page)); + assert(!std::strcmp(track->title, "\xe4\xb8\xad\xe6\x96\x87")); + frames.clear(); + Bytes website{1, 0xfe, 0xff, 0x4e, 0x2d, 0x65, 0x87, 0, 0}; + const char* url = "https://example.test/music"; + website.insert(website.end(), url, url + std::strlen(url)); + frame(frames, "WXXX", website); + frame(frames, "USLT", Bytes{1, 'e', 'n', 'g', 0xfe, 0xff, 0, 0, 0x4e, 0x2d, 0x65, 0x87}); + input.bytes = tag(frames); + assert(parser->read(input, *track, page)); + assert(!std::strcmp(page->fields[0].value, "\xe4\xb8\xad\xe6\x96\x87 / https://example.test/music")); + assert(track->lyric_encoding == 2); + char lyrics[32]; + parser->read_text(input, track->lyric_offset, track->lyric_offset + track->lyric_size, track->lyric_encoding, lyrics, sizeof(lyrics)); + assert(!std::strcmp(lyrics, "\xe4\xb8\xad\xe6\x96\x87")); + frames.clear(); + frame(frames, "TIT2", text("Footer song"), 4); + input.bytes = tag(frames, 4, true); + assert(parser->read(input, *track, page) && track->audio_offset == frames.size() + 20); + assert(!std::strcmp(track->title, "Footer song")); + frames.clear(); + frame(frames, "TT2", text("Legacy", 0), 2); + auto pic = Bytes{0, 'J', 'P', 'G', 3, 0}; + append(pic, file(std::string(argv[1]) + "/cover.jpg")); + frame(frames, "PIC", pic, 2); + input.bytes = tag(frames, 2); + assert(parser->read(input, *track, page) && !std::strcmp(track->title, "Legacy") && track->picture_size); + // 1MB artwork remains on SD during metadata scan, then is capped before decoding PNG. + frames.clear(); + frame(frames, "APIC", picture(Bytes(1024 * 1024, 0), 3, "image/png")); + input.bytes = tag(frames); + input.read_bytes = 0; + assert(parser->read(input, *track, page) && input.read_bytes < 4096); + assert(!music::decode_cover(input, *track, 96, result) && result == music::CoverResult::Invalid); + track->picture_size = 3 * 1024 * 1024; + assert(!music::decode_cover(input, *track, 96, result) && result == music::CoverResult::TooLarge); + frames.clear(); + frame(frames, "TIT2", text("Broken")); + input.bytes = tag(frames); + input.bytes[14] = 0x7f; + assert(!parser->read(input, *track, page)); + input.bytes = Bytes{'I', 'D', '3', 4, 0, 0, 0x80, 0, 0, 0}; + assert(!parser->read(input, *track, page)); + std::printf("ID3 2.2/2.3/2.4, UTF16, metadata paging, APIC front-cover selection, JPEG/PNG decoding, aspect ratio, malformed bounds and bounded reads passed.\n"); + music::destroy(parser); + music::destroy(track); + music::destroy(page); +} diff --git a/tests/p4_hardware/CMakeLists.txt b/tests/p4_hardware/CMakeLists.txt index d694a018..5f45ab47 100644 --- a/tests/p4_hardware/CMakeLists.txt +++ b/tests/p4_hardware/CMakeLists.txt @@ -26,6 +26,8 @@ foreach(TEST power heading audio rtc haptic display) endif() add_test(NAME p4_${TEST} COMMAND p4_${TEST}_test) endforeach() +target_sources(p4_audio_test PRIVATE "${REPO}/modules/core_sys/src/platform/audio/audio_focus.cpp") +target_sources(p4_audio_test PRIVATE "${REPO}/platform/linux/common/src/platform/resource_lock.cpp") add_executable(p4_keyboard_availability_test test_keyboard_availability.cpp) target_include_directories(p4_keyboard_availability_test PRIVATE keyboard_fakes "${REPO}/platform/esp/idf_components/t_display_p4/include") diff --git a/tests/p4_hardware/fakes/hardware.h b/tests/p4_hardware/fakes/hardware.h index 42d69593..d4eb4969 100644 --- a/tests/p4_hardware/fakes/hardware.h +++ b/tests/p4_hardware/fakes/hardware.h @@ -21,9 +21,11 @@ inline int64_t now_us = 0; inline bool power = true; inline int step = 0, fail_step = -1, live = 0, channel_live = 0; inline int sample_reads = 0; +inline int heading_devices = 0, deleted_tasks = 0; inline size_t last_sample_size = 0; inline std::array sample{1, 100, 0, 0, 0, 0, 0, 8, 0}; inline float gain = 0; +inline int output_volume = 0; inline bool gain_ok = true; inline bool i2c_ok = true; inline int resets = 0; @@ -49,6 +51,7 @@ using TaskHandle_t = void*; constexpr int pdPASS = 1, pdTRUE = 1; inline int pdMS_TO_TICKS(int value) { return value; } inline void vTaskDelay(int ms) { fake::now_us += static_cast(ms) * 1000; } +inline void vTaskDelete(void*) { ++fake::deleted_tasks; } inline int xTaskCreate(void (*)(void*), const char*, int, void*, int, TaskHandle_t*) { return pdPASS; } struct StaticSemaphore_t { @@ -69,6 +72,12 @@ struct i2c_device_config_t inline int i2c_master_bus_add_device(void*, const i2c_device_config_t* config, void** handle) { *handle = reinterpret_cast(static_cast(config->device_address)); + ++fake::heading_devices; + return 0; +} +inline int i2c_master_bus_rm_device(void*) +{ + --fake::heading_devices; return 0; } inline int i2c_master_probe(void*, uint16_t, int) { return fake::i2c_ok ? 0 : -1; } @@ -205,7 +214,11 @@ inline int esp_codec_dev_set_in_gain(codec_device*, float gain) fake::gain = gain; return fake::gain_ok ? 0 : -1; } -inline int esp_codec_dev_set_out_vol(codec_device*, int) { return 0; } +inline int esp_codec_dev_set_out_vol(codec_device*, int value) +{ + fake::output_volume = value; + return 0; +} inline int esp_codec_dev_set_out_mute(codec_device*, bool) { return 0; } inline int esp_codec_dev_read(codec_device*, void* out, int bytes) { diff --git a/tests/p4_hardware/test_audio.cpp b/tests/p4_hardware/test_audio.cpp index 9607f8b8..50410ae8 100644 --- a/tests/p4_hardware/test_audio.cpp +++ b/tests/p4_hardware/test_audio.cpp @@ -43,6 +43,22 @@ int main() assert(trail_mate_t_display_p4_audio_read_mono(walkie, pcm.data(), pcm.size())); for (auto sample : pcm) assert(sample == 100); codec.close(); + constexpr auto music = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_MUSIC; + assert(trail_mate_t_display_p4_audio_begin(music, 44100) && fake::output_volume == 100); + trail_mate_t_display_p4_audio_set_volume_percent(27); + assert(fake::output_volume == 100 && s_audio.volume_percent == 27); + trail_mate_t_display_p4_audio_end(music); + assert(trail_mate_t_display_p4_audio_begin(walkie, 16000) && fake::output_volume == 27); + trail_mate_t_display_p4_audio_end(walkie); + constexpr auto call = TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_RETICULUM_CALL; + assert(trail_mate_t_display_p4_audio_begin(call, 16000)); + assert(trail_mate_t_display_p4_audio_set_session_volume(call, 100)); + trail_mate_t_display_p4_audio_set_volume_percent(23); + assert(fake::output_volume == 100 && s_audio.volume_percent == 23); + assert(!trail_mate_t_display_p4_audio_set_session_volume(walkie, 50)); + trail_mate_t_display_p4_audio_end(call); + assert(trail_mate_t_display_p4_audio_begin(walkie, 16000) && fake::output_volume == 23); + trail_mate_t_display_p4_audio_end(walkie); fake::gain_ok = false; assert(!trail_mate_t_display_p4_audio_begin(walkie, 16000)); assert(s_audio.owner == TRAIL_MATE_T_DISPLAY_P4_AUDIO_OWNER_NONE); diff --git a/tests/p4_hardware/test_heading.cpp b/tests/p4_hardware/test_heading.cpp index b9019d18..9d7807a4 100644 --- a/tests/p4_hardware/test_heading.cpp +++ b/tests/p4_hardware/test_heading.cpp @@ -24,5 +24,15 @@ int main() fake::sample[8] = 0; assert(ensure_sensor_ready() && sample_once()); assert(get_data().available); + assert(acquire() && acquire()); + assert(g_runtime.consumers == 2 && g_runtime.started); + release(); + assert(g_runtime.consumers == 1 && get_data().available); + release(); + const auto reads = fake::sample_reads; + task_main(nullptr); // Drive the final-consumer cleanup through production code. + assert(!g_runtime.started && !g_runtime.task && !get_data().available); + assert(!g_runtime.icm && !g_runtime.magnetometer && fake::heading_devices == 0); + assert(fake::sample_reads == reads && fake::deleted_tasks == 1 && fake::power); std::cout << "P4 magnetometer ST2, overflow and stale recovery passed\n"; } diff --git a/tests/site/menu-widgets-native.test.mjs b/tests/site/menu-widgets-native.test.mjs index 053f3286..5577f95d 100644 --- a/tests/site/menu-widgets-native.test.mjs +++ b/tests/site/menu-widgets-native.test.mjs @@ -102,7 +102,7 @@ for (const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_ key('s');runtime._native_encoder(1);tick(); assert.equal(runtime._native_widget_page(),0,'modal help owns keyboard and encoder input'); key('h'); - for(let page=0;page<5;page++){ + for(let page=0;page<7;page++){ assert.equal(runtime._native_widget_page(),page); assert.equal(runtime._native_widget_count(),1,'one widget at a time'); assert.equal(runtime._native_widget_leases(),1,'one independent PSRAM owner'); @@ -113,13 +113,16 @@ for (const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_ assert.ok(!snapshot().some(item=>item.text.includes('warming up')),'absent sensor is not described as warming up'); } await capture(`widget-${page}`); - if(page<4){if(device==='pager'){runtime._native_encoder(1);tick();}else swipe(width/2,height-65,0,-70);} + if(page===4) { + assert.ok(snapshot().some(item=>item.text.includes('Trail check-in')),'Agenda reads the provided service port'); + checkAgenda(); + } + if(page<6){if(device==='pager'){runtime._native_encoder(1);tick();}else swipe(width/2,height-65,0,-70);} } - assert.ok(snapshot().some(item=>item.text.includes('Trail check-in')),'Agenda reads the provided service port'); - checkAgenda(); - key('s');assert.equal(runtime._native_widget_page(),4,'bounded at final widget'); - runtime._native_encoder(-1);tick();assert.equal(runtime._native_widget_page(),3); - key('w');assert.equal(runtime._native_widget_page(),2); + key('s');assert.equal(runtime._native_widget_page(),6,'bounded at final widget'); + runtime._native_encoder(-1);tick();assert.equal(runtime._native_widget_page(),5); + key('w');assert.equal(runtime._native_widget_page(),4); + key('w');assert.equal(runtime._native_widget_page(),3); const before=runtime._native_preview_agenda_queries(); tick(35000);assert.equal(runtime._native_preview_agenda_queries(),before,'hidden Agenda is never queried'); if(device==='pager')key('d');else swipe(width-30,80,-100,0); diff --git a/tests/site/music-native-ui.test.mjs b/tests/site/music-native-ui.test.mjs new file mode 100644 index 00000000..1eed9ec8 --- /dev/null +++ b/tests/site/music-native-ui.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import {readFile,writeFile,mkdir} from 'node:fs/promises'; +import {join} from 'node:path'; +import {framePng} from './native-frame-png.mjs'; +const boards=[['pager',480,222],['tdeck',320,240],['wio-tracker-l2',320,240],['t-display-p4-tft',1168,540],['t-display-p4-amoled',1232,568]]; +for(const [device,width,height] of boards.filter(([name])=>!process.env.MUSIC_UI_DEVICES||process.env.MUSIC_UI_DEVICES.split(',').includes(name))){ + const base=new URL(`../../site/assets/native/${device}`,import.meta.url); + const {default:create}=await import(`${base}.js`); + const data=await readFile(new URL(`${base}.data`));let runtime,frame; + runtime=await create({wasmBinary:await readFile(new URL(`${base}.wasm`)),getPreloadedPackage(){return data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength);}, + onFrame(ptr,w,h){assert.equal(w,width);assert.equal(h,height);frame=Buffer.from(runtime.HEAPU8.subarray(ptr,ptr+w*h*2));},print(){},printErr:console.error}); + const tick=()=>{for(let i=0;i<5;i++)runtime._native_tick(50);}; + const snapshot=()=>JSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); + const click=text=>{const item=snapshot().find(item=>item.button&&item.text===text);assert.ok(item,`${device}: ${text}`);runtime._native_activate(item.id);tick();}; + const capture=async name=>{if(!process.env.MUSIC_UI_ARTIFACT_DIR)return;const dir=join(process.env.MUSIC_UI_ARTIFACT_DIR,device);await mkdir(dir,{recursive:true});await writeFile(join(dir,`${name}.png`),framePng(frame,width,height));await writeFile(join(dir,`${name}.json`),JSON.stringify(snapshot(),null,2));}; + runtime._native_init(device==='pager'?0:1);runtime._native_preview_music_playing(0);runtime._native_open_page(19);tick(); + assert.ok(snapshot().some(item=>item.text==='After the rain'),'idle entry opens library');await capture('library'); + click('After the rain');assert.ok(snapshot().some(item=>item.text==='Pause')); + assert.equal(runtime._native_music_geometry(),0,`${device}: artwork, progress knob and top/footer bars have separate bounds`); + click('Repeat all');assert.ok(snapshot().some(item=>item.text==='Repeat one')); + click('Repeat one');assert.ok(snapshot().some(item=>item.text==='Shuffle')); + click('Shuffle');assert.ok(snapshot().some(item=>item.text==='Repeat all')); + await capture('now-playing'); + click('Lyrics');assert.ok(snapshot().some(item=>item.text==='We take the quiet way home'));await capture('lyrics'); + click('\uf053');click('Menu');click('Track info');await capture('metadata');assert.ok(snapshot().some(item=>item.text==='Aster')); + click('\uf053');click('Library');click('Browse');click('Playlists');await capture('playlists'); + click('Browse');click('New playlist');await capture('new-playlist'); + const editor=snapshot().find(item=>item.input);assert.ok(editor); + runtime.ccall('native_set_text',null,['number','string'],[editor.id,'Ridge walk']);click('Create'); + assert.ok(snapshot().some(item=>item.text==='Ridge walk'),'user playlist creation'); + runtime._native_open_page(11);runtime._native_open_page(19);tick(); + assert.ok(snapshot().some(item=>item.text==='Now Playing'),'active session re-entry opens now playing'); + console.log(`${device}: music entry routing, library, playback, lyrics, metadata and playlist creation passed.`); +} diff --git a/tests/site/quick-controls-native.test.mjs b/tests/site/quick-controls-native.test.mjs new file mode 100644 index 00000000..f8e2016e --- /dev/null +++ b/tests/site/quick-controls-native.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import {readFile,writeFile,mkdir} from 'node:fs/promises'; +import {join} from 'node:path'; +import {framePng} from './native-frame-png.mjs'; +const boards=[['pager',480,222],['tdeck',320,240],['wio-tracker-l2',320,240],['t-display-p4-tft',1168,540],['t-display-p4-amoled',1232,568]]; +for(const [device,width,height] of boards.filter(([name])=>!process.env.WIDGET_UI_DEVICES||process.env.WIDGET_UI_DEVICES.split(',').includes(name))){ + const base=new URL(`../../site/assets/native/${device}`,import.meta.url); + const {default:create}=await import(`${base}.js`);const data=await readFile(new URL(`${base}.data`));let runtime,frame; + runtime=await create({wasmBinary:await readFile(new URL(`${base}.wasm`)),getPreloadedPackage(){return data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength);}, + onFrame(ptr,w,h){assert.equal(w,width);assert.equal(h,height);frame=Buffer.from(runtime.HEAPU8.subarray(ptr,ptr+w*h*2));},print(){},printErr:console.error}); + const tick=()=>{for(let i=0;i<6;i++)runtime._native_tick(50);}; + const snapshot=()=>JSON.parse(runtime.UTF8ToString(runtime._native_snapshot())); + const key=k=>{runtime._native_key(typeof k==='string'?k.charCodeAt(0):k);tick();}; + const click=text=>{const item=snapshot().find(item=>item.button&&item.text===text);assert.ok(item,`${device}: ${text}`);runtime._native_activate(item.id);tick();}; + const capture=async name=>{if(!process.env.WIDGET_UI_ARTIFACT_DIR)return;const dir=join(process.env.WIDGET_UI_ARTIFACT_DIR,device);await mkdir(dir,{recursive:true});await writeFile(join(dir,`${name}.png`),framePng(frame,width,height));await writeFile(join(dir,`${name}.json`),JSON.stringify(snapshot(),null,2));}; + runtime._native_init(device==='pager'?0:1);runtime._native_preview_music_playing(1);runtime._native_open_page(10);tick(); + const p4=device.startsWith('t-display'); + if(p4){click('\uf054');click('\uf054');}else{ + const map=snapshot().find(item=>item.button&&item.text==='Map');runtime._native_focus(map.id);key('a'); + for(let i=0;i<5;i++){runtime._native_encoder(1);tick();} + } + assert.ok(snapshot().some(item=>item.text==='Music'));assert.ok(snapshot().some(item=>item.text==='After the rain')); + await capture('music-widget'); + click('Pause');assert.ok(snapshot().some(item=>item.text==='Play'));click('Play'); + click('Repeat all');assert.ok(snapshot().some(item=>item.text==='Repeat one')); + click('\uf051');assert.ok(snapshot().some(item=>item.text==='Pines at dusk')); + if(p4)click('\uf054');else{runtime._native_encoder(1);tick();} + assert.ok(snapshot().some(item=>item.text==='Quick controls')); + for(const text of ['Wi-Fi','LoRa','GPS','Volume','Brightness','35%','70%'])assert.ok(snapshot().some(item=>item.text===text),`${device}: ${text}`); + assert.equal(runtime._native_dashboard_geometry(),0); + await capture('quick-controls'); + if(device==='pager'){ + key('1');key('2');key('3');key('+');key(']'); + assert.ok(snapshot().some(item=>item.text==='40%'));assert.ok(snapshot().some(item=>item.text==='75%')); + assert.equal(runtime._native_widget_page(),6,'quick keys do not change page'); + }else{ + click('Wi-Fi');click('GPS'); + } + await capture('quick-controls-changed'); + const pageBefore=runtime._native_widget_page(); + const slider=snapshot().filter(item=>item.slider)[0];assert.ok(slider,'visible volume slider'); + const drag=(dx,dy)=>{ + const current=snapshot().filter(item=>item.slider)[0]; + const x=current.x+Math.round((current.w-1)*current.value/100),y=current.y+current.h/2; + runtime._native_pointer(x,y,1);runtime._native_tick(50); + for(let step=1;step<=8;step++){runtime._native_pointer(Math.round(x+dx*step/8),Math.round(y+dy*step/8),1);runtime._native_tick(40);} + runtime._native_pointer(Math.round(x+dx),Math.round(y+dy),0);tick(); + }; + drag(Math.min(100,slider.w/3),0); + assert.equal(runtime._native_widget_page(),pageBefore,'horizontal slider drag retains the current widget'); + drag(0,-80); + assert.equal(runtime._native_widget_page(),pageBefore,'vertical slider drag retains the current widget'); + if(device==='pager'){ + runtime._native_encoder(-1);tick();key(10); // LV_KEY_ENTER is '\n'. + assert.ok(snapshot().some(item=>item.text==='Now Playing'),'Enter on music widget opens the full player'); + assert.equal(runtime._native_widget_count(),0,'opening music releases the widget'); + assert.equal(runtime._native_widget_leases(),1,'only the full music page retains a PSRAM UI lease'); + } + runtime._native_open_page(11);tick();assert.equal(runtime._native_widget_leases(),0,'leaving releases widget PSRAM state and artwork'); + console.log(`${device}: quick controls, playback widget, mode and PSRAM lifetime passed.`); +} diff --git a/tests/site/track-ground-overlays.browser.mjs b/tests/site/track-ground-overlays.browser.mjs index 0991bccd..86264a00 100644 --- a/tests/site/track-ground-overlays.browser.mjs +++ b/tests/site/track-ground-overlays.browser.mjs @@ -28,6 +28,9 @@ try{ const quad='120,30 120.025,30.005 120.02,30.025 119.995,30.02';await show(wrap(ground('quad','',quad)));await page.locator('[data-overlay-id="quad"] img').waitFor(); // getComputedStyle serializes matrix coefficients with less precision than // the mathematical homography; require agreement within 1/100 screen pixel. + // The raster can enter the DOM before fitBounds finishes its pan/zoom. Its + // projection is refreshed on moveend/zoomend, so compare settled coordinates. + await page.waitForFunction(()=>{const map=groundMaps.at(-1);return !map._animatingZoom&&!map._panAnim?._inProgress&&!document.querySelector('.leaflet-zoom-anim,.leaflet-pan-anim');}); const cornerError=await page.evaluate(()=>{const map=groundMaps.at(-1),layer=Object.values(map._layers).find(layer=>layer.overlay?.id==='quad'),matrix=new DOMMatrix(getComputedStyle(layer.raster).transform),corners=layer.getCorners().map(point=>map.latLngToLayerPoint(point)),uv=[[0,0],[layer.width,0],[layer.width,layer.height],[0,layer.height]],left=parseFloat(layer.element.style.left),top=parseFloat(layer.element.style.top);return uv.map(([x,y],i)=>{const p=new DOMPoint(x,y).matrixTransform(matrix);return Math.hypot(left+p.x/p.w-corners[i].x,top+p.y/p.w-corners[i].y);});});assert.ok(cornerError.every(value=>value<.01),'Rendered corner error (pixels): '+JSON.stringify(cornerError)); await page.evaluate(()=>{const map=groundMaps.at(-1);map.setZoom(map.getZoom()+1,{animate:false});map.panBy([40,20],{animate:false});});await page.locator('[data-overlay-id="quad"]').waitFor({state:'visible'});assert.equal(await page.locator('[data-overlay-id="quad"]').isVisible(),true);await page.screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/track-ground-overlay-shared.png'}); const dateBox='1210-179179';await show(wrap(ground('date-line','',dateBox)));await page.locator('[data-overlay-id="date-line"] img').waitFor();assert.equal(await page.evaluate(()=>{const map=groundMaps.at(-1),layer=Object.values(map._layers).find(layer=>layer.overlay?.id==='date-line');return layer.getBounds().getEast()-layer.getBounds().getWest();}),2); diff --git a/third_party/minimp3/LICENSE b/third_party/minimp3/LICENSE new file mode 100644 index 00000000..2c4afabd --- /dev/null +++ b/third_party/minimp3/LICENSE @@ -0,0 +1,117 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + + diff --git a/third_party/minimp3/README.trail-mate.md b/third_party/minimp3/README.trail-mate.md new file mode 100644 index 00000000..520a6fd0 --- /dev/null +++ b/third_party/minimp3/README.trail-mate.md @@ -0,0 +1,14 @@ +# minimp3 provenance + +Upstream: https://github.com/lieff/minimp3 + +Pinned revision: `ea99364f61c14656440e8d77e9c233ccf3124633`. +The upstream CC0 dedication is retained in `LICENSE` and the header. + +Trail Mate adds the optional `MINIMP3_EXTERNAL_SCRATCH` parameter to +`mp3dec_decode_frame`. With the flag, its scratch object is caller-owned; +without it, the upstream API and storage behavior remain intact. The music +adapter allocates the decoder, scratch, input window and PCM in strict PSRAM. +The feature builds only Layer III and disables SIMD. No minimp3_ex file, +full-song buffer, mixer, cover cache, network client or extra decoder task is +included. diff --git a/third_party/minimp3/minimp3.h b/third_party/minimp3/minimp3.h new file mode 100644 index 00000000..f0bfd068 --- /dev/null +++ b/third_party/minimp3/minimp3.h @@ -0,0 +1,1879 @@ +#ifndef MINIMP3_H +#define MINIMP3_H +/* + https://github.com/lieff/minimp3 + To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. + This software is distributed without any warranty. + See . +*/ +#include + +#define MINIMP3_MAX_SAMPLES_PER_FRAME (1152*2) + +typedef struct +{ + int frame_bytes, frame_offset, channels, hz, layer, bitrate_kbps; +} mp3dec_frame_info_t; + +typedef struct +{ + float mdct_overlap[2][9*32], qmf_state[15*2*32]; + int reserv, free_format_bytes; + unsigned char header[4], reserv_buf[511]; +} mp3dec_t; + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +void mp3dec_init(mp3dec_t *dec); +#ifndef MINIMP3_FLOAT_OUTPUT +typedef int16_t mp3d_sample_t; +#else /* MINIMP3_FLOAT_OUTPUT */ +typedef float mp3d_sample_t; +void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples); +#endif /* MINIMP3_FLOAT_OUTPUT */ +int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info +#ifdef MINIMP3_EXTERNAL_SCRATCH + , void *workspace +#endif +); + +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#endif /* MINIMP3_H */ +#if defined(MINIMP3_IMPLEMENTATION) && !defined(_MINIMP3_IMPLEMENTATION_GUARD) +#define _MINIMP3_IMPLEMENTATION_GUARD + +#include +#include + +#define MAX_FREE_FORMAT_FRAME_SIZE 2304 /* more than ISO spec's */ +#ifndef MAX_FRAME_SYNC_MATCHES +#define MAX_FRAME_SYNC_MATCHES 10 +#endif /* MAX_FRAME_SYNC_MATCHES */ + +#define MAX_L3_FRAME_PAYLOAD_BYTES MAX_FREE_FORMAT_FRAME_SIZE /* MUST be >= 320000/8/32000*1152 = 1440 */ + +#define MAX_BITRESERVOIR_BYTES 511 +#define SHORT_BLOCK_TYPE 2 +#define STOP_BLOCK_TYPE 3 +#define MODE_MONO 3 +#define MODE_JOINT_STEREO 1 +#define HDR_SIZE 4 +#define HDR_IS_MONO(h) (((h[3]) & 0xC0) == 0xC0) +#define HDR_IS_MS_STEREO(h) (((h[3]) & 0xE0) == 0x60) +#define HDR_IS_FREE_FORMAT(h) (((h[2]) & 0xF0) == 0) +#define HDR_IS_CRC(h) (!((h[1]) & 1)) +#define HDR_TEST_PADDING(h) ((h[2]) & 0x2) +#define HDR_TEST_MPEG1(h) ((h[1]) & 0x8) +#define HDR_TEST_NOT_MPEG25(h) ((h[1]) & 0x10) +#define HDR_TEST_I_STEREO(h) ((h[3]) & 0x10) +#define HDR_TEST_MS_STEREO(h) ((h[3]) & 0x20) +#define HDR_GET_STEREO_MODE(h) (((h[3]) >> 6) & 3) +#define HDR_GET_STEREO_MODE_EXT(h) (((h[3]) >> 4) & 3) +#define HDR_GET_LAYER(h) (((h[1]) >> 1) & 3) +#define HDR_GET_BITRATE(h) ((h[2]) >> 4) +#define HDR_GET_SAMPLE_RATE(h) (((h[2]) >> 2) & 3) +#define HDR_GET_MY_SAMPLE_RATE(h) (HDR_GET_SAMPLE_RATE(h) + (((h[1] >> 3) & 1) + ((h[1] >> 4) & 1))*3) +#define HDR_IS_FRAME_576(h) ((h[1] & 14) == 2) +#define HDR_IS_LAYER_1(h) ((h[1] & 6) == 6) + +#define BITS_DEQUANTIZER_OUT -1 +#define MAX_SCF (255 + BITS_DEQUANTIZER_OUT*4 - 210) +#define MAX_SCFI ((MAX_SCF + 3) & ~3) + +#define MINIMP3_MIN(a, b) ((a) > (b) ? (b) : (a)) +#define MINIMP3_MAX(a, b) ((a) < (b) ? (b) : (a)) + +#if !defined(MINIMP3_NO_SIMD) + +#if !defined(MINIMP3_ONLY_SIMD) && (defined(_M_X64) || defined(__x86_64__) || defined(__aarch64__) || defined(_M_ARM64)) +/* x64 always have SSE2, arm64 always have neon, no need for generic code */ +#define MINIMP3_ONLY_SIMD +#endif /* SIMD checks... */ + +#if (defined(_MSC_VER) && (defined(_M_IX86) || defined(_M_X64))) || ((defined(__i386__) || defined(__x86_64__)) && defined(__SSE2__)) +#if defined(_MSC_VER) +#include +#endif /* defined(_MSC_VER) */ +#include +#define HAVE_SSE 1 +#define HAVE_SIMD 1 +#define VSTORE _mm_storeu_ps +#define VLD _mm_loadu_ps +#define VSET _mm_set1_ps +#define VADD _mm_add_ps +#define VSUB _mm_sub_ps +#define VMUL _mm_mul_ps +#define VMAC(a, x, y) _mm_add_ps(a, _mm_mul_ps(x, y)) +#define VMSB(a, x, y) _mm_sub_ps(a, _mm_mul_ps(x, y)) +#define VMUL_S(x, s) _mm_mul_ps(x, _mm_set1_ps(s)) +#define VREV(x) _mm_shuffle_ps(x, x, _MM_SHUFFLE(0, 1, 2, 3)) +typedef __m128 f4; +#if defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) +#define minimp3_cpuid __cpuid +#else /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */ +static __inline__ __attribute__((always_inline)) void minimp3_cpuid(int CPUInfo[], const int InfoType) +{ +#if defined(__PIC__) + __asm__ __volatile__( +#if defined(__x86_64__) + "push %%rbx\n" + "cpuid\n" + "xchgl %%ebx, %1\n" + "pop %%rbx\n" +#else /* defined(__x86_64__) */ + "xchgl %%ebx, %1\n" + "cpuid\n" + "xchgl %%ebx, %1\n" +#endif /* defined(__x86_64__) */ + : "=a" (CPUInfo[0]), "=r" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3]) + : "a" (InfoType)); +#else /* defined(__PIC__) */ + __asm__ __volatile__( + "cpuid" + : "=a" (CPUInfo[0]), "=b" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3]) + : "a" (InfoType)); +#endif /* defined(__PIC__)*/ +} +#endif /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */ +static int have_simd(void) +{ +#ifdef MINIMP3_ONLY_SIMD + return 1; +#else /* MINIMP3_ONLY_SIMD */ + static int g_have_simd; + int CPUInfo[4]; +#ifdef MINIMP3_TEST + static int g_counter; + if (g_counter++ > 100) + return 0; +#endif /* MINIMP3_TEST */ + if (g_have_simd) + goto end; + minimp3_cpuid(CPUInfo, 0); + g_have_simd = 1; + if (CPUInfo[0] > 0) + { + minimp3_cpuid(CPUInfo, 1); + g_have_simd = (CPUInfo[3] & (1 << 26)) + 1; /* SSE2 */ + } +end: + return g_have_simd - 1; +#endif /* MINIMP3_ONLY_SIMD */ +} +#elif defined(__ARM_NEON) || defined(__aarch64__) || defined(_M_ARM64) +#include +#define HAVE_SSE 0 +#define HAVE_SIMD 1 +#define VSTORE vst1q_f32 +#define VLD vld1q_f32 +#define VSET vmovq_n_f32 +#define VADD vaddq_f32 +#define VSUB vsubq_f32 +#define VMUL vmulq_f32 +#define VMAC(a, x, y) vmlaq_f32(a, x, y) +#define VMSB(a, x, y) vmlsq_f32(a, x, y) +#define VMUL_S(x, s) vmulq_f32(x, vmovq_n_f32(s)) +#define VREV(x) vcombine_f32(vget_high_f32(vrev64q_f32(x)), vget_low_f32(vrev64q_f32(x))) +typedef float32x4_t f4; +static int have_simd() +{ /* TODO: detect neon for !MINIMP3_ONLY_SIMD */ + return 1; +} +#else /* SIMD checks... */ +#define HAVE_SSE 0 +#define HAVE_SIMD 0 +#ifdef MINIMP3_ONLY_SIMD +#error MINIMP3_ONLY_SIMD used, but SSE/NEON not enabled +#endif /* MINIMP3_ONLY_SIMD */ +#endif /* SIMD checks... */ +#else /* !defined(MINIMP3_NO_SIMD) */ +#define HAVE_SIMD 0 +#endif /* !defined(MINIMP3_NO_SIMD) */ + +#if defined(__ARM_ARCH) && (__ARM_ARCH >= 6) && !defined(__aarch64__) && !defined(_M_ARM64) && !defined(__ARM_ARCH_6M__) +#define HAVE_ARMV6 1 +static __inline__ __attribute__((always_inline)) int32_t minimp3_clip_int16_arm(int32_t a) +{ + int32_t x = 0; + __asm__ ("ssat %0, #16, %1" : "=r"(x) : "r"(a)); + return x; +} +#else +#define HAVE_ARMV6 0 +#endif + +typedef struct +{ + const uint8_t *buf; + int pos, limit; +} bs_t; + +typedef struct +{ + float scf[3*64]; + uint8_t total_bands, stereo_bands, bitalloc[64], scfcod[64]; +} L12_scale_info; + +typedef struct +{ + uint8_t tab_offset, code_tab_width, band_count; +} L12_subband_alloc_t; + +typedef struct +{ + const uint8_t *sfbtab; + uint16_t part_23_length, big_values, scalefac_compress; + uint8_t global_gain, block_type, mixed_block_flag, n_long_sfb, n_short_sfb; + uint8_t table_select[3], region_count[3], subblock_gain[3]; + uint8_t preflag, scalefac_scale, count1_table, scfsi; +} L3_gr_info_t; + +typedef struct +{ + bs_t bs; + uint8_t maindata[MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES]; + L3_gr_info_t gr_info[4]; + float grbuf[2][576], scf[40], syn[18 + 15][2*32]; + uint8_t ist_pos[2][39]; +} mp3dec_scratch_t; + +static void bs_init(bs_t *bs, const uint8_t *data, int bytes) +{ + bs->buf = data; + bs->pos = 0; + bs->limit = bytes*8; +} + +static uint32_t get_bits(bs_t *bs, int n) +{ + uint32_t next, cache = 0, s = bs->pos & 7; + int shl = n + s; + const uint8_t *p = bs->buf + (bs->pos >> 3); + if ((bs->pos += n) > bs->limit) + return 0; + next = *p++ & (255 >> s); + while ((shl -= 8) > 0) + { + cache |= next << shl; + next = *p++; + } + return cache | (next >> -shl); +} + +static int hdr_valid(const uint8_t *h) +{ + return h[0] == 0xff && + ((h[1] & 0xF0) == 0xf0 || (h[1] & 0xFE) == 0xe2) && + (HDR_GET_LAYER(h) != 0) && + (HDR_GET_BITRATE(h) != 15) && + (HDR_GET_SAMPLE_RATE(h) != 3); +} + +static int hdr_compare(const uint8_t *h1, const uint8_t *h2) +{ + return hdr_valid(h2) && + ((h1[1] ^ h2[1]) & 0xFE) == 0 && + ((h1[2] ^ h2[2]) & 0x0C) == 0 && + !(HDR_IS_FREE_FORMAT(h1) ^ HDR_IS_FREE_FORMAT(h2)); +} + +static unsigned hdr_bitrate_kbps(const uint8_t *h) +{ + static const uint8_t halfrate[2][3][15] = { + { { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,16,24,28,32,40,48,56,64,72,80,88,96,112,128 } }, + { { 0,16,20,24,28,32,40,48,56,64,80,96,112,128,160 }, { 0,16,24,28,32,40,48,56,64,80,96,112,128,160,192 }, { 0,16,32,48,64,80,96,112,128,144,160,176,192,208,224 } }, + }; + return 2*halfrate[!!HDR_TEST_MPEG1(h)][HDR_GET_LAYER(h) - 1][HDR_GET_BITRATE(h)]; +} + +static unsigned hdr_sample_rate_hz(const uint8_t *h) +{ + static const unsigned g_hz[3] = { 44100, 48000, 32000 }; + return g_hz[HDR_GET_SAMPLE_RATE(h)] >> (int)!HDR_TEST_MPEG1(h) >> (int)!HDR_TEST_NOT_MPEG25(h); +} + +static unsigned hdr_frame_samples(const uint8_t *h) +{ + return HDR_IS_LAYER_1(h) ? 384 : (1152 >> (int)HDR_IS_FRAME_576(h)); +} + +static int hdr_frame_bytes(const uint8_t *h, int free_format_size) +{ + int frame_bytes = hdr_frame_samples(h)*hdr_bitrate_kbps(h)*125/hdr_sample_rate_hz(h); + if (HDR_IS_LAYER_1(h)) + { + frame_bytes &= ~3; /* slot align */ + } + return frame_bytes ? frame_bytes : free_format_size; +} + +static int hdr_padding(const uint8_t *h) +{ + return HDR_TEST_PADDING(h) ? (HDR_IS_LAYER_1(h) ? 4 : 1) : 0; +} + +#ifndef MINIMP3_ONLY_MP3 +static const L12_subband_alloc_t *L12_subband_alloc_table(const uint8_t *hdr, L12_scale_info *sci) +{ + const L12_subband_alloc_t *alloc; + int mode = HDR_GET_STEREO_MODE(hdr); + int nbands, stereo_bands = (mode == MODE_MONO) ? 0 : (mode == MODE_JOINT_STEREO) ? (HDR_GET_STEREO_MODE_EXT(hdr) << 2) + 4 : 32; + + if (HDR_IS_LAYER_1(hdr)) + { + static const L12_subband_alloc_t g_alloc_L1[] = { { 76, 4, 32 } }; + alloc = g_alloc_L1; + nbands = 32; + } else if (!HDR_TEST_MPEG1(hdr)) + { + static const L12_subband_alloc_t g_alloc_L2M2[] = { { 60, 4, 4 }, { 44, 3, 7 }, { 44, 2, 19 } }; + alloc = g_alloc_L2M2; + nbands = 30; + } else + { + static const L12_subband_alloc_t g_alloc_L2M1[] = { { 0, 4, 3 }, { 16, 4, 8 }, { 32, 3, 12 }, { 40, 2, 7 } }; + int sample_rate_idx = HDR_GET_SAMPLE_RATE(hdr); + unsigned kbps = hdr_bitrate_kbps(hdr) >> (int)(mode != MODE_MONO); + if (!kbps) /* free-format */ + { + kbps = 192; + } + + alloc = g_alloc_L2M1; + nbands = 27; + if (kbps < 56) + { + static const L12_subband_alloc_t g_alloc_L2M1_lowrate[] = { { 44, 4, 2 }, { 44, 3, 10 } }; + alloc = g_alloc_L2M1_lowrate; + nbands = sample_rate_idx == 2 ? 12 : 8; + } else if (kbps >= 96 && sample_rate_idx != 1) + { + nbands = 30; + } + } + + sci->total_bands = (uint8_t)nbands; + sci->stereo_bands = (uint8_t)MINIMP3_MIN(stereo_bands, nbands); + + return alloc; +} + +static void L12_read_scalefactors(bs_t *bs, uint8_t *pba, uint8_t *scfcod, int bands, float *scf) +{ + static const float g_deq_L12[18*3] = { +#define DQ(x) 9.53674316e-07f/x, 7.56931807e-07f/x, 6.00777173e-07f/x + DQ(3),DQ(7),DQ(15),DQ(31),DQ(63),DQ(127),DQ(255),DQ(511),DQ(1023),DQ(2047),DQ(4095),DQ(8191),DQ(16383),DQ(32767),DQ(65535),DQ(3),DQ(5),DQ(9) + }; + int i, m; + for (i = 0; i < bands; i++) + { + float s = 0; + int ba = *pba++; + int mask = ba ? 4 + ((19 >> scfcod[i]) & 3) : 0; + for (m = 4; m; m >>= 1) + { + if (mask & m) + { + int b = get_bits(bs, 6); + s = g_deq_L12[ba*3 - 6 + b % 3]*(1 << 21 >> b/3); + } + *scf++ = s; + } + } +} + +static void L12_read_scale_info(const uint8_t *hdr, bs_t *bs, L12_scale_info *sci) +{ + static const uint8_t g_bitalloc_code_tab[] = { + 0,17, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,16, + 0,17,18, 3,19,4,5,16, + 0,17,18,16, + 0,17,18,19, 4,5,6, 7,8, 9,10,11,12,13,14,15, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,14, + 0, 2, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16 + }; + const L12_subband_alloc_t *subband_alloc = L12_subband_alloc_table(hdr, sci); + + int i, k = 0, ba_bits = 0; + const uint8_t *ba_code_tab = g_bitalloc_code_tab; + + for (i = 0; i < sci->total_bands; i++) + { + uint8_t ba; + if (i == k) + { + k += subband_alloc->band_count; + ba_bits = subband_alloc->code_tab_width; + ba_code_tab = g_bitalloc_code_tab + subband_alloc->tab_offset; + subband_alloc++; + } + ba = ba_code_tab[get_bits(bs, ba_bits)]; + sci->bitalloc[2*i] = ba; + if (i < sci->stereo_bands) + { + ba = ba_code_tab[get_bits(bs, ba_bits)]; + } + sci->bitalloc[2*i + 1] = sci->stereo_bands ? ba : 0; + } + + for (i = 0; i < 2*sci->total_bands; i++) + { + sci->scfcod[i] = sci->bitalloc[i] ? HDR_IS_LAYER_1(hdr) ? 2 : get_bits(bs, 2) : 6; + } + + L12_read_scalefactors(bs, sci->bitalloc, sci->scfcod, sci->total_bands*2, sci->scf); + + for (i = sci->stereo_bands; i < sci->total_bands; i++) + { + sci->bitalloc[2*i + 1] = 0; + } +} + +static int L12_dequantize_granule(float *grbuf, bs_t *bs, L12_scale_info *sci, int group_size) +{ + int i, j, k, choff = 576; + for (j = 0; j < 4; j++) + { + float *dst = grbuf + group_size*j; + for (i = 0; i < 2*sci->total_bands; i++) + { + int ba = sci->bitalloc[i]; + if (ba != 0) + { + if (ba < 17) + { + int half = (1 << (ba - 1)) - 1; + for (k = 0; k < group_size; k++) + { + dst[k] = (float)((int)get_bits(bs, ba) - half); + } + } else + { + unsigned mod = (2 << (ba - 17)) + 1; /* 3, 5, 9 */ + unsigned code = get_bits(bs, mod + 2 - (mod >> 3)); /* 5, 7, 10 */ + for (k = 0; k < group_size; k++, code /= mod) + { + dst[k] = (float)((int)(code % mod - mod/2)); + } + } + } + dst += choff; + choff = 18 - choff; + } + } + return group_size*4; +} + +static void L12_apply_scf_384(L12_scale_info *sci, const float *scf, float *dst) +{ + int i, k; + memcpy(dst + 576 + sci->stereo_bands*18, dst + sci->stereo_bands*18, (sci->total_bands - sci->stereo_bands)*18*sizeof(float)); + for (i = 0; i < sci->total_bands; i++, dst += 18, scf += 6) + { + for (k = 0; k < 12; k++) + { + dst[k + 0] *= scf[0]; + dst[k + 576] *= scf[3]; + } + } +} +#endif /* MINIMP3_ONLY_MP3 */ + +static int L3_read_side_info(bs_t *bs, L3_gr_info_t *gr, const uint8_t *hdr) +{ + static const uint8_t g_scf_long[8][23] = { + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 12,12,12,12,12,12,16,20,24,28,32,40,48,56,64,76,90,2,2,2,2,2,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,18,22,26,32,38,46,54,62,70,76,36,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 4,4,4,4,4,4,6,6,8,8,10,12,16,20,24,28,34,42,50,54,76,158,0 }, + { 4,4,4,4,4,4,6,6,6,8,10,12,16,18,22,28,34,40,46,54,54,192,0 }, + { 4,4,4,4,4,4,6,6,8,10,12,16,20,24,30,38,46,56,68,84,102,26,0 } + }; + static const uint8_t g_scf_short[8][40] = { + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 8,8,8,8,8,8,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 } + }; + static const uint8_t g_scf_mixed[8][40] = { + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 12,12,12,4,4,4,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 }, + { 6,6,6,6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 }, + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 }, + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 } + }; + + unsigned tables, scfsi = 0; + int main_data_begin, part_23_sum = 0; + int sr_idx = HDR_GET_MY_SAMPLE_RATE(hdr); sr_idx -= (sr_idx != 0); + int gr_count = HDR_IS_MONO(hdr) ? 1 : 2; + + if (HDR_TEST_MPEG1(hdr)) + { + gr_count *= 2; + main_data_begin = get_bits(bs, 9); + scfsi = get_bits(bs, 7 + gr_count); + } else + { + main_data_begin = get_bits(bs, 8 + gr_count) >> gr_count; + } + + do + { + if (HDR_IS_MONO(hdr)) + { + scfsi <<= 4; + } + gr->part_23_length = (uint16_t)get_bits(bs, 12); + part_23_sum += gr->part_23_length; + gr->big_values = (uint16_t)get_bits(bs, 9); + if (gr->big_values > 288) + { + return -1; + } + gr->global_gain = (uint8_t)get_bits(bs, 8); + gr->scalefac_compress = (uint16_t)get_bits(bs, HDR_TEST_MPEG1(hdr) ? 4 : 9); + gr->sfbtab = g_scf_long[sr_idx]; + gr->n_long_sfb = 22; + gr->n_short_sfb = 0; + if (get_bits(bs, 1)) + { + gr->block_type = (uint8_t)get_bits(bs, 2); + if (!gr->block_type) + { + return -1; + } + gr->mixed_block_flag = (uint8_t)get_bits(bs, 1); + gr->region_count[0] = 7; + gr->region_count[1] = 255; + if (gr->block_type == SHORT_BLOCK_TYPE) + { + scfsi &= 0x0F0F; + if (!gr->mixed_block_flag) + { + gr->region_count[0] = 8; + gr->sfbtab = g_scf_short[sr_idx]; + gr->n_long_sfb = 0; + gr->n_short_sfb = 39; + } else + { + gr->sfbtab = g_scf_mixed[sr_idx]; + gr->n_long_sfb = HDR_TEST_MPEG1(hdr) ? 8 : 6; + gr->n_short_sfb = 30; + } + } + tables = get_bits(bs, 10); + tables <<= 5; + gr->subblock_gain[0] = (uint8_t)get_bits(bs, 3); + gr->subblock_gain[1] = (uint8_t)get_bits(bs, 3); + gr->subblock_gain[2] = (uint8_t)get_bits(bs, 3); + } else + { + gr->block_type = 0; + gr->mixed_block_flag = 0; + tables = get_bits(bs, 15); + gr->region_count[0] = (uint8_t)get_bits(bs, 4); + gr->region_count[1] = (uint8_t)get_bits(bs, 3); + gr->region_count[2] = 255; + } + gr->table_select[0] = (uint8_t)(tables >> 10); + gr->table_select[1] = (uint8_t)((tables >> 5) & 31); + gr->table_select[2] = (uint8_t)((tables) & 31); + gr->preflag = HDR_TEST_MPEG1(hdr) ? get_bits(bs, 1) : (gr->scalefac_compress >= 500); + gr->scalefac_scale = (uint8_t)get_bits(bs, 1); + gr->count1_table = (uint8_t)get_bits(bs, 1); + gr->scfsi = (uint8_t)((scfsi >> 12) & 15); + scfsi <<= 4; + gr++; + } while(--gr_count); + + if (part_23_sum + bs->pos > bs->limit + main_data_begin*8) + { + return -1; + } + + return main_data_begin; +} + +static void L3_read_scalefactors(uint8_t *scf, uint8_t *ist_pos, const uint8_t *scf_size, const uint8_t *scf_count, bs_t *bitbuf, int scfsi) +{ + int i, k; + for (i = 0; i < 4 && scf_count[i]; i++, scfsi *= 2) + { + int cnt = scf_count[i]; + if (scfsi & 8) + { + memcpy(scf, ist_pos, cnt); + } else + { + int bits = scf_size[i]; + if (!bits) + { + memset(scf, 0, cnt); + memset(ist_pos, 0, cnt); + } else + { + int max_scf = (scfsi < 0) ? (1 << bits) - 1 : -1; + for (k = 0; k < cnt; k++) + { + int s = get_bits(bitbuf, bits); + ist_pos[k] = (s == max_scf ? -1 : s); + scf[k] = s; + } + } + } + ist_pos += cnt; + scf += cnt; + } + scf[0] = scf[1] = scf[2] = 0; +} + +static float L3_ldexp_q2(float y, int exp_q2) +{ + static const float g_expfrac[4] = { 9.31322575e-10f,7.83145814e-10f,6.58544508e-10f,5.53767716e-10f }; + int e; + do + { + e = MINIMP3_MIN(30*4, exp_q2); + y *= g_expfrac[e & 3]*(1 << 30 >> (e >> 2)); + } while ((exp_q2 -= e) > 0); + return y; +} + +static void L3_decode_scalefactors(const uint8_t *hdr, uint8_t *ist_pos, bs_t *bs, const L3_gr_info_t *gr, float *scf, int ch) +{ + static const uint8_t g_scf_partitions[3][28] = { + { 6,5,5, 5,6,5,5,5,6,5, 7,3,11,10,0,0, 7, 7, 7,0, 6, 6,6,3, 8, 8,5,0 }, + { 8,9,6,12,6,9,9,9,6,9,12,6,15,18,0,0, 6,15,12,0, 6,12,9,6, 6,18,9,0 }, + { 9,9,6,12,9,9,9,9,9,9,12,6,18,18,0,0,12,12,12,0,12, 9,9,6,15,12,9,0 } + }; + const uint8_t *scf_partition = g_scf_partitions[!!gr->n_short_sfb + !gr->n_long_sfb]; + uint8_t scf_size[4], iscf[40]; + int i, scf_shift = gr->scalefac_scale + 1, gain_exp, scfsi = gr->scfsi; + float gain; + + if (HDR_TEST_MPEG1(hdr)) + { + static const uint8_t g_scfc_decode[16] = { 0,1,2,3, 12,5,6,7, 9,10,11,13, 14,15,18,19 }; + int part = g_scfc_decode[gr->scalefac_compress]; + scf_size[1] = scf_size[0] = (uint8_t)(part >> 2); + scf_size[3] = scf_size[2] = (uint8_t)(part & 3); + } else + { + static const uint8_t g_mod[6*4] = { 5,5,4,4,5,5,4,1,4,3,1,1,5,6,6,1,4,4,4,1,4,3,1,1 }; + int k, modprod, sfc, ist = HDR_TEST_I_STEREO(hdr) && ch; + sfc = gr->scalefac_compress >> ist; + for (k = ist*3*4; sfc >= 0; sfc -= modprod, k += 4) + { + for (modprod = 1, i = 3; i >= 0; i--) + { + scf_size[i] = (uint8_t)(sfc / modprod % g_mod[k + i]); + modprod *= g_mod[k + i]; + } + } + scf_partition += k; + scfsi = -16; + } + L3_read_scalefactors(iscf, ist_pos, scf_size, scf_partition, bs, scfsi); + + if (gr->n_short_sfb) + { + int sh = 3 - scf_shift; + for (i = 0; i < gr->n_short_sfb; i += 3) + { + iscf[gr->n_long_sfb + i + 0] += gr->subblock_gain[0] << sh; + iscf[gr->n_long_sfb + i + 1] += gr->subblock_gain[1] << sh; + iscf[gr->n_long_sfb + i + 2] += gr->subblock_gain[2] << sh; + } + } else if (gr->preflag) + { + static const uint8_t g_preamp[10] = { 1,1,1,1,2,2,3,3,3,2 }; + for (i = 0; i < 10; i++) + { + iscf[11 + i] += g_preamp[i]; + } + } + + gain_exp = gr->global_gain + BITS_DEQUANTIZER_OUT*4 - 210 - (HDR_IS_MS_STEREO(hdr) ? 2 : 0); + gain = L3_ldexp_q2(1 << (MAX_SCFI/4), MAX_SCFI - gain_exp); + for (i = 0; i < (int)(gr->n_long_sfb + gr->n_short_sfb); i++) + { + scf[i] = L3_ldexp_q2(gain, iscf[i] << scf_shift); + } +} + +static const float g_pow43[129 + 16] = { + 0,-1,-2.519842f,-4.326749f,-6.349604f,-8.549880f,-10.902724f,-13.390518f,-16.000000f,-18.720754f,-21.544347f,-24.463781f,-27.473142f,-30.567351f,-33.741992f,-36.993181f, + 0,1,2.519842f,4.326749f,6.349604f,8.549880f,10.902724f,13.390518f,16.000000f,18.720754f,21.544347f,24.463781f,27.473142f,30.567351f,33.741992f,36.993181f,40.317474f,43.711787f,47.173345f,50.699631f,54.288352f,57.937408f,61.644865f,65.408941f,69.227979f,73.100443f,77.024898f,81.000000f,85.024491f,89.097188f,93.216975f,97.382800f,101.593667f,105.848633f,110.146801f,114.487321f,118.869381f,123.292209f,127.755065f,132.257246f,136.798076f,141.376907f,145.993119f,150.646117f,155.335327f,160.060199f,164.820202f,169.614826f,174.443577f,179.305980f,184.201575f,189.129918f,194.090580f,199.083145f,204.107210f,209.162385f,214.248292f,219.364564f,224.510845f,229.686789f,234.892058f,240.126328f,245.389280f,250.680604f,256.000000f,261.347174f,266.721841f,272.123723f,277.552547f,283.008049f,288.489971f,293.998060f,299.532071f,305.091761f,310.676898f,316.287249f,321.922592f,327.582707f,333.267377f,338.976394f,344.709550f,350.466646f,356.247482f,362.051866f,367.879608f,373.730522f,379.604427f,385.501143f,391.420496f,397.362314f,403.326427f,409.312672f,415.320884f,421.350905f,427.402579f,433.475750f,439.570269f,445.685987f,451.822757f,457.980436f,464.158883f,470.357960f,476.577530f,482.817459f,489.077615f,495.357868f,501.658090f,507.978156f,514.317941f,520.677324f,527.056184f,533.454404f,539.871867f,546.308458f,552.764065f,559.238575f,565.731879f,572.243870f,578.774440f,585.323483f,591.890898f,598.476581f,605.080431f,611.702349f,618.342238f,625.000000f,631.675540f,638.368763f,645.079578f +}; + +static float L3_pow_43(int x) +{ + float frac; + int sign, mult = 256; + + if (x < 129) + { + return g_pow43[16 + x]; + } + + if (x < 1024) + { + mult = 16; + x <<= 3; + } + + sign = 2*x & 64; + frac = (float)((x & 63) - sign) / ((x & ~63) + sign); + return g_pow43[16 + ((x + sign) >> 6)]*(1.f + frac*((4.f/3) + frac*(2.f/9)))*mult; +} + +static void L3_huffman(float *dst, bs_t *bs, const L3_gr_info_t *gr_info, const float *scf, int layer3gr_limit) +{ + static const int16_t tabs[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 785,785,785,785,784,784,784,784,513,513,513,513,513,513,513,513,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256, + -255,1313,1298,1282,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,290,288, + -255,1313,1298,1282,769,769,769,769,529,529,529,529,529,529,529,529,528,528,528,528,528,528,528,528,512,512,512,512,512,512,512,512,290,288, + -253,-318,-351,-367,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,819,818,547,547,275,275,275,275,561,560,515,546,289,274,288,258, + -254,-287,1329,1299,1314,1312,1057,1057,1042,1042,1026,1026,784,784,784,784,529,529,529,529,529,529,529,529,769,769,769,769,768,768,768,768,563,560,306,306,291,259, + -252,-413,-477,-542,1298,-575,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-383,-399,1107,1092,1106,1061,849,849,789,789,1104,1091,773,773,1076,1075,341,340,325,309,834,804,577,577,532,532,516,516,832,818,803,816,561,561,531,531,515,546,289,289,288,258, + -252,-429,-493,-559,1057,1057,1042,1042,529,529,529,529,529,529,529,529,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,-382,1077,-415,1106,1061,1104,849,849,789,789,1091,1076,1029,1075,834,834,597,581,340,340,339,324,804,833,532,532,832,772,818,803,817,787,816,771,290,290,290,290,288,258, + -253,-349,-414,-447,-463,1329,1299,-479,1314,1312,1057,1057,1042,1042,1026,1026,785,785,785,785,784,784,784,784,769,769,769,769,768,768,768,768,-319,851,821,-335,836,850,805,849,341,340,325,336,533,533,579,579,564,564,773,832,578,548,563,516,321,276,306,291,304,259, + -251,-572,-733,-830,-863,-879,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,1396,1351,1381,1366,1395,1335,1380,-559,1334,1138,1138,1063,1063,1350,1392,1031,1031,1062,1062,1364,1363,1120,1120,1333,1348,881,881,881,881,375,374,359,373,343,358,341,325,791,791,1123,1122,-703,1105,1045,-719,865,865,790,790,774,774,1104,1029,338,293,323,308,-799,-815,833,788,772,818,803,816,322,292,307,320,561,531,515,546,289,274,288,258, + -251,-525,-605,-685,-765,-831,-846,1298,1057,1057,1312,1282,785,785,785,785,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,1399,1398,1383,1367,1382,1396,1351,-511,1381,1366,1139,1139,1079,1079,1124,1124,1364,1349,1363,1333,882,882,882,882,807,807,807,807,1094,1094,1136,1136,373,341,535,535,881,775,867,822,774,-591,324,338,-671,849,550,550,866,864,609,609,293,336,534,534,789,835,773,-751,834,804,308,307,833,788,832,772,562,562,547,547,305,275,560,515,290,290, + -252,-397,-477,-557,-622,-653,-719,-735,-750,1329,1299,1314,1057,1057,1042,1042,1312,1282,1024,1024,785,785,785,785,784,784,784,784,769,769,769,769,-383,1127,1141,1111,1126,1140,1095,1110,869,869,883,883,1079,1109,882,882,375,374,807,868,838,881,791,-463,867,822,368,263,852,837,836,-543,610,610,550,550,352,336,534,534,865,774,851,821,850,805,593,533,579,564,773,832,578,578,548,548,577,577,307,276,306,291,516,560,259,259, + -250,-2107,-2507,-2764,-2909,-2974,-3007,-3023,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-767,-1052,-1213,-1277,-1358,-1405,-1469,-1535,-1550,-1582,-1614,-1647,-1662,-1694,-1726,-1759,-1774,-1807,-1822,-1854,-1886,1565,-1919,-1935,-1951,-1967,1731,1730,1580,1717,-1983,1729,1564,-1999,1548,-2015,-2031,1715,1595,-2047,1714,-2063,1610,-2079,1609,-2095,1323,1323,1457,1457,1307,1307,1712,1547,1641,1700,1699,1594,1685,1625,1442,1442,1322,1322,-780,-973,-910,1279,1278,1277,1262,1276,1261,1275,1215,1260,1229,-959,974,974,989,989,-943,735,478,478,495,463,506,414,-1039,1003,958,1017,927,942,987,957,431,476,1272,1167,1228,-1183,1256,-1199,895,895,941,941,1242,1227,1212,1135,1014,1014,490,489,503,487,910,1013,985,925,863,894,970,955,1012,847,-1343,831,755,755,984,909,428,366,754,559,-1391,752,486,457,924,997,698,698,983,893,740,740,908,877,739,739,667,667,953,938,497,287,271,271,683,606,590,712,726,574,302,302,738,736,481,286,526,725,605,711,636,724,696,651,589,681,666,710,364,467,573,695,466,466,301,465,379,379,709,604,665,679,316,316,634,633,436,436,464,269,424,394,452,332,438,363,347,408,393,448,331,422,362,407,392,421,346,406,391,376,375,359,1441,1306,-2367,1290,-2383,1337,-2399,-2415,1426,1321,-2431,1411,1336,-2447,-2463,-2479,1169,1169,1049,1049,1424,1289,1412,1352,1319,-2495,1154,1154,1064,1064,1153,1153,416,390,360,404,403,389,344,374,373,343,358,372,327,357,342,311,356,326,1395,1394,1137,1137,1047,1047,1365,1392,1287,1379,1334,1364,1349,1378,1318,1363,792,792,792,792,1152,1152,1032,1032,1121,1121,1046,1046,1120,1120,1030,1030,-2895,1106,1061,1104,849,849,789,789,1091,1076,1029,1090,1060,1075,833,833,309,324,532,532,832,772,818,803,561,561,531,560,515,546,289,274,288,258, + -250,-1179,-1579,-1836,-1996,-2124,-2253,-2333,-2413,-2477,-2542,-2574,-2607,-2622,-2655,1314,1313,1298,1312,1282,785,785,785,785,1040,1040,1025,1025,768,768,768,768,-766,-798,-830,-862,-895,-911,-927,-943,-959,-975,-991,-1007,-1023,-1039,-1055,-1070,1724,1647,-1103,-1119,1631,1767,1662,1738,1708,1723,-1135,1780,1615,1779,1599,1677,1646,1778,1583,-1151,1777,1567,1737,1692,1765,1722,1707,1630,1751,1661,1764,1614,1736,1676,1763,1750,1645,1598,1721,1691,1762,1706,1582,1761,1566,-1167,1749,1629,767,766,751,765,494,494,735,764,719,749,734,763,447,447,748,718,477,506,431,491,446,476,461,505,415,430,475,445,504,399,460,489,414,503,383,474,429,459,502,502,746,752,488,398,501,473,413,472,486,271,480,270,-1439,-1455,1357,-1471,-1487,-1503,1341,1325,-1519,1489,1463,1403,1309,-1535,1372,1448,1418,1476,1356,1462,1387,-1551,1475,1340,1447,1402,1386,-1567,1068,1068,1474,1461,455,380,468,440,395,425,410,454,364,467,466,464,453,269,409,448,268,432,1371,1473,1432,1417,1308,1460,1355,1446,1459,1431,1083,1083,1401,1416,1458,1445,1067,1067,1370,1457,1051,1051,1291,1430,1385,1444,1354,1415,1400,1443,1082,1082,1173,1113,1186,1066,1185,1050,-1967,1158,1128,1172,1097,1171,1081,-1983,1157,1112,416,266,375,400,1170,1142,1127,1065,793,793,1169,1033,1156,1096,1141,1111,1155,1080,1126,1140,898,898,808,808,897,897,792,792,1095,1152,1032,1125,1110,1139,1079,1124,882,807,838,881,853,791,-2319,867,368,263,822,852,837,866,806,865,-2399,851,352,262,534,534,821,836,594,594,549,549,593,593,533,533,848,773,579,579,564,578,548,563,276,276,577,576,306,291,516,560,305,305,275,259, + -251,-892,-2058,-2620,-2828,-2957,-3023,-3039,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,-559,1530,-575,-591,1528,1527,1407,1526,1391,1023,1023,1023,1023,1525,1375,1268,1268,1103,1103,1087,1087,1039,1039,1523,-604,815,815,815,815,510,495,509,479,508,463,507,447,431,505,415,399,-734,-782,1262,-815,1259,1244,-831,1258,1228,-847,-863,1196,-879,1253,987,987,748,-767,493,493,462,477,414,414,686,669,478,446,461,445,474,429,487,458,412,471,1266,1264,1009,1009,799,799,-1019,-1276,-1452,-1581,-1677,-1757,-1821,-1886,-1933,-1997,1257,1257,1483,1468,1512,1422,1497,1406,1467,1496,1421,1510,1134,1134,1225,1225,1466,1451,1374,1405,1252,1252,1358,1480,1164,1164,1251,1251,1238,1238,1389,1465,-1407,1054,1101,-1423,1207,-1439,830,830,1248,1038,1237,1117,1223,1148,1236,1208,411,426,395,410,379,269,1193,1222,1132,1235,1221,1116,976,976,1192,1162,1177,1220,1131,1191,963,963,-1647,961,780,-1663,558,558,994,993,437,408,393,407,829,978,813,797,947,-1743,721,721,377,392,844,950,828,890,706,706,812,859,796,960,948,843,934,874,571,571,-1919,690,555,689,421,346,539,539,944,779,918,873,932,842,903,888,570,570,931,917,674,674,-2575,1562,-2591,1609,-2607,1654,1322,1322,1441,1441,1696,1546,1683,1593,1669,1624,1426,1426,1321,1321,1639,1680,1425,1425,1305,1305,1545,1668,1608,1623,1667,1592,1638,1666,1320,1320,1652,1607,1409,1409,1304,1304,1288,1288,1664,1637,1395,1395,1335,1335,1622,1636,1394,1394,1319,1319,1606,1621,1392,1392,1137,1137,1137,1137,345,390,360,375,404,373,1047,-2751,-2767,-2783,1062,1121,1046,-2799,1077,-2815,1106,1061,789,789,1105,1104,263,355,310,340,325,354,352,262,339,324,1091,1076,1029,1090,1060,1075,833,833,788,788,1088,1028,818,818,803,803,561,561,531,531,816,771,546,546,289,274,288,258, + -253,-317,-381,-446,-478,-509,1279,1279,-811,-1179,-1451,-1756,-1900,-2028,-2189,-2253,-2333,-2414,-2445,-2511,-2526,1313,1298,-2559,1041,1041,1040,1040,1025,1025,1024,1024,1022,1007,1021,991,1020,975,1019,959,687,687,1018,1017,671,671,655,655,1016,1015,639,639,758,758,623,623,757,607,756,591,755,575,754,559,543,543,1009,783,-575,-621,-685,-749,496,-590,750,749,734,748,974,989,1003,958,988,973,1002,942,987,957,972,1001,926,986,941,971,956,1000,910,985,925,999,894,970,-1071,-1087,-1102,1390,-1135,1436,1509,1451,1374,-1151,1405,1358,1480,1420,-1167,1507,1494,1389,1342,1465,1435,1450,1326,1505,1310,1493,1373,1479,1404,1492,1464,1419,428,443,472,397,736,526,464,464,486,457,442,471,484,482,1357,1449,1434,1478,1388,1491,1341,1490,1325,1489,1463,1403,1309,1477,1372,1448,1418,1433,1476,1356,1462,1387,-1439,1475,1340,1447,1402,1474,1324,1461,1371,1473,269,448,1432,1417,1308,1460,-1711,1459,-1727,1441,1099,1099,1446,1386,1431,1401,-1743,1289,1083,1083,1160,1160,1458,1445,1067,1067,1370,1457,1307,1430,1129,1129,1098,1098,268,432,267,416,266,400,-1887,1144,1187,1082,1173,1113,1186,1066,1050,1158,1128,1143,1172,1097,1171,1081,420,391,1157,1112,1170,1142,1127,1065,1169,1049,1156,1096,1141,1111,1155,1080,1126,1154,1064,1153,1140,1095,1048,-2159,1125,1110,1137,-2175,823,823,1139,1138,807,807,384,264,368,263,868,838,853,791,867,822,852,837,866,806,865,790,-2319,851,821,836,352,262,850,805,849,-2399,533,533,835,820,336,261,578,548,563,577,532,532,832,772,562,562,547,547,305,275,560,515,290,290,288,258 }; + static const uint8_t tab32[] = { 130,162,193,209,44,28,76,140,9,9,9,9,9,9,9,9,190,254,222,238,126,94,157,157,109,61,173,205 }; + static const uint8_t tab33[] = { 252,236,220,204,188,172,156,140,124,108,92,76,60,44,28,12 }; + static const int16_t tabindex[2*16] = { 0,32,64,98,0,132,180,218,292,364,426,538,648,746,0,1126,1460,1460,1460,1460,1460,1460,1460,1460,1842,1842,1842,1842,1842,1842,1842,1842 }; + static const uint8_t g_linbits[] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3,4,6,8,10,13,4,5,6,7,8,9,11,13 }; + +#define PEEK_BITS(n) (bs_cache >> (32 - n)) +#define FLUSH_BITS(n) { bs_cache <<= (n); bs_sh += (n); } +#define CHECK_BITS while (bs_sh >= 0) { bs_cache |= (uint32_t)*bs_next_ptr++ << bs_sh; bs_sh -= 8; } +#define BSPOS ((bs_next_ptr - bs->buf)*8 - 24 + bs_sh) + + float one = 0.0f; + int ireg = 0, big_val_cnt = gr_info->big_values; + const uint8_t *sfb = gr_info->sfbtab; + const uint8_t *bs_next_ptr = bs->buf + bs->pos/8; + uint32_t bs_cache = (((bs_next_ptr[0]*256u + bs_next_ptr[1])*256u + bs_next_ptr[2])*256u + bs_next_ptr[3]) << (bs->pos & 7); + int pairs_to_decode, np, bs_sh = (bs->pos & 7) - 8; + bs_next_ptr += 4; + + while (big_val_cnt > 0) + { + int tab_num = gr_info->table_select[ireg]; + int sfb_cnt = gr_info->region_count[ireg++]; + const int16_t *codebook = tabs + tabindex[tab_num]; + int linbits = g_linbits[tab_num]; + if (linbits) + { + do + { + np = *sfb++ / 2; + pairs_to_decode = MINIMP3_MIN(big_val_cnt, np); + one = *scf++; + do + { + int j, w = 5; + int leaf = codebook[PEEK_BITS(w)]; + while (leaf < 0) + { + FLUSH_BITS(w); + w = leaf & 7; + leaf = codebook[PEEK_BITS(w) - (leaf >> 3)]; + } + FLUSH_BITS(leaf >> 8); + + for (j = 0; j < 2; j++, dst++, leaf >>= 4) + { + int lsb = leaf & 0x0F; + if (lsb == 15) + { + lsb += PEEK_BITS(linbits); + FLUSH_BITS(linbits); + CHECK_BITS; + *dst = one*L3_pow_43(lsb)*((int32_t)bs_cache < 0 ? -1: 1); + } else + { + *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one; + } + FLUSH_BITS(lsb ? 1 : 0); + } + CHECK_BITS; + } while (--pairs_to_decode); + } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0); + } else + { + do + { + np = *sfb++ / 2; + pairs_to_decode = MINIMP3_MIN(big_val_cnt, np); + one = *scf++; + do + { + int j, w = 5; + int leaf = codebook[PEEK_BITS(w)]; + while (leaf < 0) + { + FLUSH_BITS(w); + w = leaf & 7; + leaf = codebook[PEEK_BITS(w) - (leaf >> 3)]; + } + FLUSH_BITS(leaf >> 8); + + for (j = 0; j < 2; j++, dst++, leaf >>= 4) + { + int lsb = leaf & 0x0F; + *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one; + FLUSH_BITS(lsb ? 1 : 0); + } + CHECK_BITS; + } while (--pairs_to_decode); + } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0); + } + } + + for (np = 1 - big_val_cnt;; dst += 4) + { + const uint8_t *codebook_count1 = (gr_info->count1_table) ? tab33 : tab32; + int leaf = codebook_count1[PEEK_BITS(4)]; + if (!(leaf & 8)) + { + leaf = codebook_count1[(leaf >> 3) + (bs_cache << 4 >> (32 - (leaf & 3)))]; + } + FLUSH_BITS(leaf & 7); + if (BSPOS > layer3gr_limit) + { + break; + } +#define RELOAD_SCALEFACTOR if (!--np) { np = *sfb++/2; if (!np) break; one = *scf++; } +#define DEQ_COUNT1(s) if (leaf & (128 >> s)) { dst[s] = ((int32_t)bs_cache < 0) ? -one : one; FLUSH_BITS(1) } + RELOAD_SCALEFACTOR; + DEQ_COUNT1(0); + DEQ_COUNT1(1); + RELOAD_SCALEFACTOR; + DEQ_COUNT1(2); + DEQ_COUNT1(3); + CHECK_BITS; + } + + bs->pos = layer3gr_limit; +} + +static void L3_midside_stereo(float *left, int n) +{ + int i = 0; + float *right = left + 576; +#if HAVE_SIMD + if (have_simd()) + { + for (; i < n - 3; i += 4) + { + f4 vl = VLD(left + i); + f4 vr = VLD(right + i); + VSTORE(left + i, VADD(vl, vr)); + VSTORE(right + i, VSUB(vl, vr)); + } +#ifdef __GNUC__ + /* Workaround for spurious -Waggressive-loop-optimizations warning from gcc. + * For more info see: https://github.com/lieff/minimp3/issues/88 + */ + if (__builtin_constant_p(n % 4 == 0) && n % 4 == 0) + return; +#endif + } +#endif /* HAVE_SIMD */ + for (; i < n; i++) + { + float a = left[i]; + float b = right[i]; + left[i] = a + b; + right[i] = a - b; + } +} + +static void L3_intensity_stereo_band(float *left, int n, float kl, float kr) +{ + int i; + for (i = 0; i < n; i++) + { + left[i + 576] = left[i]*kr; + left[i] = left[i]*kl; + } +} + +static void L3_stereo_top_band(const float *right, const uint8_t *sfb, int nbands, int max_band[3]) +{ + int i, k; + + max_band[0] = max_band[1] = max_band[2] = -1; + + for (i = 0; i < nbands; i++) + { + for (k = 0; k < sfb[i]; k += 2) + { + if (right[k] != 0 || right[k + 1] != 0) + { + max_band[i % 3] = i; + break; + } + } + right += sfb[i]; + } +} + +static void L3_stereo_process(float *left, const uint8_t *ist_pos, const uint8_t *sfb, const uint8_t *hdr, int max_band[3], int mpeg2_sh) +{ + static const float g_pan[7*2] = { 0,1,0.21132487f,0.78867513f,0.36602540f,0.63397460f,0.5f,0.5f,0.63397460f,0.36602540f,0.78867513f,0.21132487f,1,0 }; + unsigned i, max_pos = HDR_TEST_MPEG1(hdr) ? 7 : 64; + + for (i = 0; sfb[i]; i++) + { + unsigned ipos = ist_pos[i]; + if ((int)i > max_band[i % 3] && ipos < max_pos) + { + float kl, kr, s = HDR_TEST_MS_STEREO(hdr) ? 1.41421356f : 1; + if (HDR_TEST_MPEG1(hdr)) + { + kl = g_pan[2*ipos]; + kr = g_pan[2*ipos + 1]; + } else + { + kl = 1; + kr = L3_ldexp_q2(1, (ipos + 1) >> 1 << mpeg2_sh); + if (ipos & 1) + { + kl = kr; + kr = 1; + } + } + L3_intensity_stereo_band(left, sfb[i], kl*s, kr*s); + } else if (HDR_TEST_MS_STEREO(hdr)) + { + L3_midside_stereo(left, sfb[i]); + } + left += sfb[i]; + } +} + +static void L3_intensity_stereo(float *left, uint8_t *ist_pos, const L3_gr_info_t *gr, const uint8_t *hdr) +{ + int max_band[3], n_sfb = gr->n_long_sfb + gr->n_short_sfb; + int i, max_blocks = gr->n_short_sfb ? 3 : 1; + + L3_stereo_top_band(left + 576, gr->sfbtab, n_sfb, max_band); + if (gr->n_long_sfb) + { + max_band[0] = max_band[1] = max_band[2] = MINIMP3_MAX(MINIMP3_MAX(max_band[0], max_band[1]), max_band[2]); + } + for (i = 0; i < max_blocks; i++) + { + int default_pos = HDR_TEST_MPEG1(hdr) ? 3 : 0; + int itop = n_sfb - max_blocks + i; + int prev = itop - max_blocks; + ist_pos[itop] = max_band[i] >= prev ? default_pos : ist_pos[prev]; + } + L3_stereo_process(left, ist_pos, gr->sfbtab, hdr, max_band, gr[1].scalefac_compress & 1); +} + +static void L3_reorder(float *grbuf, float *scratch, const uint8_t *sfb) +{ + int i, len; + float *src = grbuf, *dst = scratch; + + for (;0 != (len = *sfb); sfb += 3, src += 2*len) + { + for (i = 0; i < len; i++, src++) + { + *dst++ = src[0*len]; + *dst++ = src[1*len]; + *dst++ = src[2*len]; + } + } + memcpy(grbuf, scratch, (dst - scratch)*sizeof(float)); +} + +static void L3_antialias(float *grbuf, int nbands) +{ + static const float g_aa[2][8] = { + {0.85749293f,0.88174200f,0.94962865f,0.98331459f,0.99551782f,0.99916056f,0.99989920f,0.99999316f}, + {0.51449576f,0.47173197f,0.31337745f,0.18191320f,0.09457419f,0.04096558f,0.01419856f,0.00369997f} + }; + + for (; nbands > 0; nbands--, grbuf += 18) + { + int i = 0; +#if HAVE_SIMD + if (have_simd()) for (; i < 8; i += 4) + { + f4 vu = VLD(grbuf + 18 + i); + f4 vd = VLD(grbuf + 14 - i); + f4 vc0 = VLD(g_aa[0] + i); + f4 vc1 = VLD(g_aa[1] + i); + vd = VREV(vd); + VSTORE(grbuf + 18 + i, VSUB(VMUL(vu, vc0), VMUL(vd, vc1))); + vd = VADD(VMUL(vu, vc1), VMUL(vd, vc0)); + VSTORE(grbuf + 14 - i, VREV(vd)); + } +#endif /* HAVE_SIMD */ +#ifndef MINIMP3_ONLY_SIMD + for(; i < 8; i++) + { + float u = grbuf[18 + i]; + float d = grbuf[17 - i]; + grbuf[18 + i] = u*g_aa[0][i] - d*g_aa[1][i]; + grbuf[17 - i] = u*g_aa[1][i] + d*g_aa[0][i]; + } +#endif /* MINIMP3_ONLY_SIMD */ + } +} + +static void L3_dct3_9(float *y) +{ + float s0, s1, s2, s3, s4, s5, s6, s7, s8, t0, t2, t4; + + s0 = y[0]; s2 = y[2]; s4 = y[4]; s6 = y[6]; s8 = y[8]; + t0 = s0 + s6*0.5f; + s0 -= s6; + t4 = (s4 + s2)*0.93969262f; + t2 = (s8 + s2)*0.76604444f; + s6 = (s4 - s8)*0.17364818f; + s4 += s8 - s2; + + s2 = s0 - s4*0.5f; + y[4] = s4 + s0; + s8 = t0 - t2 + s6; + s0 = t0 - t4 + t2; + s4 = t0 + t4 - s6; + + s1 = y[1]; s3 = y[3]; s5 = y[5]; s7 = y[7]; + + s3 *= 0.86602540f; + t0 = (s5 + s1)*0.98480775f; + t4 = (s5 - s7)*0.34202014f; + t2 = (s1 + s7)*0.64278761f; + s1 = (s1 - s5 - s7)*0.86602540f; + + s5 = t0 - s3 - t2; + s7 = t4 - s3 - t0; + s3 = t4 + s3 - t2; + + y[0] = s4 - s7; + y[1] = s2 + s1; + y[2] = s0 - s3; + y[3] = s8 + s5; + y[5] = s8 - s5; + y[6] = s0 + s3; + y[7] = s2 - s1; + y[8] = s4 + s7; +} + +static void L3_imdct36(float *grbuf, float *overlap, const float *window, int nbands) +{ + int i, j; + static const float g_twid9[18] = { + 0.73727734f,0.79335334f,0.84339145f,0.88701083f,0.92387953f,0.95371695f,0.97629601f,0.99144486f,0.99904822f,0.67559021f,0.60876143f,0.53729961f,0.46174861f,0.38268343f,0.30070580f,0.21643961f,0.13052619f,0.04361938f + }; + + for (j = 0; j < nbands; j++, grbuf += 18, overlap += 9) + { + float co[9], si[9]; + co[0] = -grbuf[0]; + si[0] = grbuf[17]; + for (i = 0; i < 4; i++) + { + si[8 - 2*i] = grbuf[4*i + 1] - grbuf[4*i + 2]; + co[1 + 2*i] = grbuf[4*i + 1] + grbuf[4*i + 2]; + si[7 - 2*i] = grbuf[4*i + 4] - grbuf[4*i + 3]; + co[2 + 2*i] = -(grbuf[4*i + 3] + grbuf[4*i + 4]); + } + L3_dct3_9(co); + L3_dct3_9(si); + + si[1] = -si[1]; + si[3] = -si[3]; + si[5] = -si[5]; + si[7] = -si[7]; + + i = 0; + +#if HAVE_SIMD + if (have_simd()) for (; i < 8; i += 4) + { + f4 vovl = VLD(overlap + i); + f4 vc = VLD(co + i); + f4 vs = VLD(si + i); + f4 vr0 = VLD(g_twid9 + i); + f4 vr1 = VLD(g_twid9 + 9 + i); + f4 vw0 = VLD(window + i); + f4 vw1 = VLD(window + 9 + i); + f4 vsum = VADD(VMUL(vc, vr1), VMUL(vs, vr0)); + VSTORE(overlap + i, VSUB(VMUL(vc, vr0), VMUL(vs, vr1))); + VSTORE(grbuf + i, VSUB(VMUL(vovl, vw0), VMUL(vsum, vw1))); + vsum = VADD(VMUL(vovl, vw1), VMUL(vsum, vw0)); + VSTORE(grbuf + 14 - i, VREV(vsum)); + } +#endif /* HAVE_SIMD */ + for (; i < 9; i++) + { + float ovl = overlap[i]; + float sum = co[i]*g_twid9[9 + i] + si[i]*g_twid9[0 + i]; + overlap[i] = co[i]*g_twid9[0 + i] - si[i]*g_twid9[9 + i]; + grbuf[i] = ovl*window[0 + i] - sum*window[9 + i]; + grbuf[17 - i] = ovl*window[9 + i] + sum*window[0 + i]; + } + } +} + +static void L3_idct3(float x0, float x1, float x2, float *dst) +{ + float m1 = x1*0.86602540f; + float a1 = x0 - x2*0.5f; + dst[1] = x0 + x2; + dst[0] = a1 + m1; + dst[2] = a1 - m1; +} + +static void L3_imdct12(float *x, float *dst, float *overlap) +{ + static const float g_twid3[6] = { 0.79335334f,0.92387953f,0.99144486f, 0.60876143f,0.38268343f,0.13052619f }; + float co[3], si[3]; + int i; + + L3_idct3(-x[0], x[6] + x[3], x[12] + x[9], co); + L3_idct3(x[15], x[12] - x[9], x[6] - x[3], si); + si[1] = -si[1]; + + for (i = 0; i < 3; i++) + { + float ovl = overlap[i]; + float sum = co[i]*g_twid3[3 + i] + si[i]*g_twid3[0 + i]; + overlap[i] = co[i]*g_twid3[0 + i] - si[i]*g_twid3[3 + i]; + dst[i] = ovl*g_twid3[2 - i] - sum*g_twid3[5 - i]; + dst[5 - i] = ovl*g_twid3[5 - i] + sum*g_twid3[2 - i]; + } +} + +static void L3_imdct_short(float *grbuf, float *overlap, int nbands) +{ + for (;nbands > 0; nbands--, overlap += 9, grbuf += 18) + { + float tmp[18]; + memcpy(tmp, grbuf, sizeof(tmp)); + memcpy(grbuf, overlap, 6*sizeof(float)); + L3_imdct12(tmp, grbuf + 6, overlap + 6); + L3_imdct12(tmp + 1, grbuf + 12, overlap + 6); + L3_imdct12(tmp + 2, overlap, overlap + 6); + } +} + +static void L3_change_sign(float *grbuf) +{ + int b, i; + for (b = 0, grbuf += 18; b < 32; b += 2, grbuf += 36) + for (i = 1; i < 18; i += 2) + grbuf[i] = -grbuf[i]; +} + +static void L3_imdct_gr(float *grbuf, float *overlap, unsigned block_type, unsigned n_long_bands) +{ + static const float g_mdct_window[2][18] = { + { 0.99904822f,0.99144486f,0.97629601f,0.95371695f,0.92387953f,0.88701083f,0.84339145f,0.79335334f,0.73727734f,0.04361938f,0.13052619f,0.21643961f,0.30070580f,0.38268343f,0.46174861f,0.53729961f,0.60876143f,0.67559021f }, + { 1,1,1,1,1,1,0.99144486f,0.92387953f,0.79335334f,0,0,0,0,0,0,0.13052619f,0.38268343f,0.60876143f } + }; + if (n_long_bands) + { + L3_imdct36(grbuf, overlap, g_mdct_window[0], n_long_bands); + grbuf += 18*n_long_bands; + overlap += 9*n_long_bands; + } + if (block_type == SHORT_BLOCK_TYPE) + L3_imdct_short(grbuf, overlap, 32 - n_long_bands); + else + L3_imdct36(grbuf, overlap, g_mdct_window[block_type == STOP_BLOCK_TYPE], 32 - n_long_bands); +} + +static void L3_save_reservoir(mp3dec_t *h, mp3dec_scratch_t *s) +{ + int pos = (s->bs.pos + 7)/8u; + int remains = s->bs.limit/8u - pos; + if (remains > MAX_BITRESERVOIR_BYTES) + { + pos += remains - MAX_BITRESERVOIR_BYTES; + remains = MAX_BITRESERVOIR_BYTES; + } + if (remains > 0) + { + memmove(h->reserv_buf, s->maindata + pos, remains); + } + h->reserv = remains; +} + +static int L3_restore_reservoir(mp3dec_t *h, bs_t *bs, mp3dec_scratch_t *s, int main_data_begin) +{ + int frame_bytes = (bs->limit - bs->pos)/8; + int bytes_have = MINIMP3_MIN(h->reserv, main_data_begin); + memcpy(s->maindata, h->reserv_buf + MINIMP3_MAX(0, h->reserv - main_data_begin), MINIMP3_MIN(h->reserv, main_data_begin)); + memcpy(s->maindata + bytes_have, bs->buf + bs->pos/8, frame_bytes); + bs_init(&s->bs, s->maindata, bytes_have + frame_bytes); + return h->reserv >= main_data_begin; +} + +static void L3_decode(mp3dec_t *h, mp3dec_scratch_t *s, L3_gr_info_t *gr_info, int nch) +{ + int ch; + + for (ch = 0; ch < nch; ch++) + { + int layer3gr_limit = s->bs.pos + gr_info[ch].part_23_length; + L3_decode_scalefactors(h->header, s->ist_pos[ch], &s->bs, gr_info + ch, s->scf, ch); + L3_huffman(s->grbuf[ch], &s->bs, gr_info + ch, s->scf, layer3gr_limit); + } + + if (HDR_TEST_I_STEREO(h->header)) + { + L3_intensity_stereo(s->grbuf[0], s->ist_pos[1], gr_info, h->header); + } else if (HDR_IS_MS_STEREO(h->header)) + { + L3_midside_stereo(s->grbuf[0], 576); + } + + for (ch = 0; ch < nch; ch++, gr_info++) + { + int aa_bands = 31; + int n_long_bands = (gr_info->mixed_block_flag ? 2 : 0) << (int)(HDR_GET_MY_SAMPLE_RATE(h->header) == 2); + + if (gr_info->n_short_sfb) + { + aa_bands = n_long_bands - 1; + L3_reorder(s->grbuf[ch] + n_long_bands*18, s->syn[0], gr_info->sfbtab + gr_info->n_long_sfb); + } + + L3_antialias(s->grbuf[ch], aa_bands); + L3_imdct_gr(s->grbuf[ch], h->mdct_overlap[ch], gr_info->block_type, n_long_bands); + L3_change_sign(s->grbuf[ch]); + } +} + +static void mp3d_DCT_II(float *grbuf, int n) +{ + static const float g_sec[24] = { + 10.19000816f,0.50060302f,0.50241929f,3.40760851f,0.50547093f,0.52249861f,2.05778098f,0.51544732f,0.56694406f,1.48416460f,0.53104258f,0.64682180f,1.16943991f,0.55310392f,0.78815460f,0.97256821f,0.58293498f,1.06067765f,0.83934963f,0.62250412f,1.72244716f,0.74453628f,0.67480832f,5.10114861f + }; + int i, k = 0; +#if HAVE_SIMD + if (have_simd()) for (; k < n; k += 4) + { + f4 t[4][8], *x; + float *y = grbuf + k; + + for (x = t[0], i = 0; i < 8; i++, x++) + { + f4 x0 = VLD(&y[i*18]); + f4 x1 = VLD(&y[(15 - i)*18]); + f4 x2 = VLD(&y[(16 + i)*18]); + f4 x3 = VLD(&y[(31 - i)*18]); + f4 t0 = VADD(x0, x3); + f4 t1 = VADD(x1, x2); + f4 t2 = VMUL_S(VSUB(x1, x2), g_sec[3*i + 0]); + f4 t3 = VMUL_S(VSUB(x0, x3), g_sec[3*i + 1]); + x[0] = VADD(t0, t1); + x[8] = VMUL_S(VSUB(t0, t1), g_sec[3*i + 2]); + x[16] = VADD(t3, t2); + x[24] = VMUL_S(VSUB(t3, t2), g_sec[3*i + 2]); + } + for (x = t[0], i = 0; i < 4; i++, x += 8) + { + f4 x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt; + xt = VSUB(x0, x7); x0 = VADD(x0, x7); + x7 = VSUB(x1, x6); x1 = VADD(x1, x6); + x6 = VSUB(x2, x5); x2 = VADD(x2, x5); + x5 = VSUB(x3, x4); x3 = VADD(x3, x4); + x4 = VSUB(x0, x3); x0 = VADD(x0, x3); + x3 = VSUB(x1, x2); x1 = VADD(x1, x2); + x[0] = VADD(x0, x1); + x[4] = VMUL_S(VSUB(x0, x1), 0.70710677f); + x5 = VADD(x5, x6); + x6 = VMUL_S(VADD(x6, x7), 0.70710677f); + x7 = VADD(x7, xt); + x3 = VMUL_S(VADD(x3, x4), 0.70710677f); + x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); /* rotate by PI/8 */ + x7 = VADD(x7, VMUL_S(x5, 0.382683432f)); + x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); + x0 = VSUB(xt, x6); xt = VADD(xt, x6); + x[1] = VMUL_S(VADD(xt, x7), 0.50979561f); + x[2] = VMUL_S(VADD(x4, x3), 0.54119611f); + x[3] = VMUL_S(VSUB(x0, x5), 0.60134488f); + x[5] = VMUL_S(VADD(x0, x5), 0.89997619f); + x[6] = VMUL_S(VSUB(x4, x3), 1.30656302f); + x[7] = VMUL_S(VSUB(xt, x7), 2.56291556f); + } + + if (k > n - 3) + { +#if HAVE_SSE +#define VSAVE2(i, v) _mm_storel_pi((__m64 *)(void*)&y[i*18], v) +#else /* HAVE_SSE */ +#define VSAVE2(i, v) vst1_f32((float32_t *)&y[i*18], vget_low_f32(v)) +#endif /* HAVE_SSE */ + for (i = 0; i < 7; i++, y += 4*18) + { + f4 s = VADD(t[3][i], t[3][i + 1]); + VSAVE2(0, t[0][i]); + VSAVE2(1, VADD(t[2][i], s)); + VSAVE2(2, VADD(t[1][i], t[1][i + 1])); + VSAVE2(3, VADD(t[2][1 + i], s)); + } + VSAVE2(0, t[0][7]); + VSAVE2(1, VADD(t[2][7], t[3][7])); + VSAVE2(2, t[1][7]); + VSAVE2(3, t[3][7]); + } else + { +#define VSAVE4(i, v) VSTORE(&y[i*18], v) + for (i = 0; i < 7; i++, y += 4*18) + { + f4 s = VADD(t[3][i], t[3][i + 1]); + VSAVE4(0, t[0][i]); + VSAVE4(1, VADD(t[2][i], s)); + VSAVE4(2, VADD(t[1][i], t[1][i + 1])); + VSAVE4(3, VADD(t[2][1 + i], s)); + } + VSAVE4(0, t[0][7]); + VSAVE4(1, VADD(t[2][7], t[3][7])); + VSAVE4(2, t[1][7]); + VSAVE4(3, t[3][7]); + } + } else +#endif /* HAVE_SIMD */ +#ifdef MINIMP3_ONLY_SIMD + {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */ +#else /* MINIMP3_ONLY_SIMD */ + for (; k < n; k++) + { + float t[4][8], *x, *y = grbuf + k; + + for (x = t[0], i = 0; i < 8; i++, x++) + { + float x0 = y[i*18]; + float x1 = y[(15 - i)*18]; + float x2 = y[(16 + i)*18]; + float x3 = y[(31 - i)*18]; + float t0 = x0 + x3; + float t1 = x1 + x2; + float t2 = (x1 - x2)*g_sec[3*i + 0]; + float t3 = (x0 - x3)*g_sec[3*i + 1]; + x[0] = t0 + t1; + x[8] = (t0 - t1)*g_sec[3*i + 2]; + x[16] = t3 + t2; + x[24] = (t3 - t2)*g_sec[3*i + 2]; + } + for (x = t[0], i = 0; i < 4; i++, x += 8) + { + float x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt; + xt = x0 - x7; x0 += x7; + x7 = x1 - x6; x1 += x6; + x6 = x2 - x5; x2 += x5; + x5 = x3 - x4; x3 += x4; + x4 = x0 - x3; x0 += x3; + x3 = x1 - x2; x1 += x2; + x[0] = x0 + x1; + x[4] = (x0 - x1)*0.70710677f; + x5 = x5 + x6; + x6 = (x6 + x7)*0.70710677f; + x7 = x7 + xt; + x3 = (x3 + x4)*0.70710677f; + x5 -= x7*0.198912367f; /* rotate by PI/8 */ + x7 += x5*0.382683432f; + x5 -= x7*0.198912367f; + x0 = xt - x6; xt += x6; + x[1] = (xt + x7)*0.50979561f; + x[2] = (x4 + x3)*0.54119611f; + x[3] = (x0 - x5)*0.60134488f; + x[5] = (x0 + x5)*0.89997619f; + x[6] = (x4 - x3)*1.30656302f; + x[7] = (xt - x7)*2.56291556f; + + } + for (i = 0; i < 7; i++, y += 4*18) + { + y[0*18] = t[0][i]; + y[1*18] = t[2][i] + t[3][i] + t[3][i + 1]; + y[2*18] = t[1][i] + t[1][i + 1]; + y[3*18] = t[2][i + 1] + t[3][i] + t[3][i + 1]; + } + y[0*18] = t[0][7]; + y[1*18] = t[2][7] + t[3][7]; + y[2*18] = t[1][7]; + y[3*18] = t[3][7]; + } +#endif /* MINIMP3_ONLY_SIMD */ +} + +#ifndef MINIMP3_FLOAT_OUTPUT +static int16_t mp3d_scale_pcm(float sample) +{ +#if HAVE_ARMV6 + int32_t s32 = (int32_t)(sample + .5f); + s32 -= (s32 < 0); + int16_t s = (int16_t)minimp3_clip_int16_arm(s32); +#else + if (sample >= 32766.5) return (int16_t) 32767; + if (sample <= -32767.5) return (int16_t)-32768; + int16_t s = (int16_t)(sample + .5f); + s -= (s < 0); /* away from zero, to be compliant */ +#endif + return s; +} +#else /* MINIMP3_FLOAT_OUTPUT */ +static float mp3d_scale_pcm(float sample) +{ + return sample*(1.f/32768.f); +} +#endif /* MINIMP3_FLOAT_OUTPUT */ + +static void mp3d_synth_pair(mp3d_sample_t *pcm, int nch, const float *z) +{ + float a; + a = (z[14*64] - z[ 0]) * 29; + a += (z[ 1*64] + z[13*64]) * 213; + a += (z[12*64] - z[ 2*64]) * 459; + a += (z[ 3*64] + z[11*64]) * 2037; + a += (z[10*64] - z[ 4*64]) * 5153; + a += (z[ 5*64] + z[ 9*64]) * 6574; + a += (z[ 8*64] - z[ 6*64]) * 37489; + a += z[ 7*64] * 75038; + pcm[0] = mp3d_scale_pcm(a); + + z += 2; + a = z[14*64] * 104; + a += z[12*64] * 1567; + a += z[10*64] * 9727; + a += z[ 8*64] * 64019; + a += z[ 6*64] * -9975; + a += z[ 4*64] * -45; + a += z[ 2*64] * 146; + a += z[ 0*64] * -5; + pcm[16*nch] = mp3d_scale_pcm(a); +} + +static void mp3d_synth(float *xl, mp3d_sample_t *dstl, int nch, float *lins) +{ + int i; + float *xr = xl + 576*(nch - 1); + mp3d_sample_t *dstr = dstl + (nch - 1); + + static const float g_win[] = { + -1,26,-31,208,218,401,-519,2063,2000,4788,-5517,7134,5959,35640,-39336,74992, + -1,24,-35,202,222,347,-581,2080,1952,4425,-5879,7640,5288,33791,-41176,74856, + -1,21,-38,196,225,294,-645,2087,1893,4063,-6237,8092,4561,31947,-43006,74630, + -1,19,-41,190,227,244,-711,2085,1822,3705,-6589,8492,3776,30112,-44821,74313, + -1,17,-45,183,228,197,-779,2075,1739,3351,-6935,8840,2935,28289,-46617,73908, + -1,16,-49,176,228,153,-848,2057,1644,3004,-7271,9139,2037,26482,-48390,73415, + -2,14,-53,169,227,111,-919,2032,1535,2663,-7597,9389,1082,24694,-50137,72835, + -2,13,-58,161,224,72,-991,2001,1414,2330,-7910,9592,70,22929,-51853,72169, + -2,11,-63,154,221,36,-1064,1962,1280,2006,-8209,9750,-998,21189,-53534,71420, + -2,10,-68,147,215,2,-1137,1919,1131,1692,-8491,9863,-2122,19478,-55178,70590, + -3,9,-73,139,208,-29,-1210,1870,970,1388,-8755,9935,-3300,17799,-56778,69679, + -3,8,-79,132,200,-57,-1283,1817,794,1095,-8998,9966,-4533,16155,-58333,68692, + -4,7,-85,125,189,-83,-1356,1759,605,814,-9219,9959,-5818,14548,-59838,67629, + -4,7,-91,117,177,-106,-1428,1698,402,545,-9416,9916,-7154,12980,-61289,66494, + -5,6,-97,111,163,-127,-1498,1634,185,288,-9585,9838,-8540,11455,-62684,65290 + }; + float *zlin = lins + 15*64; + const float *w = g_win; + + zlin[4*15] = xl[18*16]; + zlin[4*15 + 1] = xr[18*16]; + zlin[4*15 + 2] = xl[0]; + zlin[4*15 + 3] = xr[0]; + + zlin[4*31] = xl[1 + 18*16]; + zlin[4*31 + 1] = xr[1 + 18*16]; + zlin[4*31 + 2] = xl[1]; + zlin[4*31 + 3] = xr[1]; + + mp3d_synth_pair(dstr, nch, lins + 4*15 + 1); + mp3d_synth_pair(dstr + 32*nch, nch, lins + 4*15 + 64 + 1); + mp3d_synth_pair(dstl, nch, lins + 4*15); + mp3d_synth_pair(dstl + 32*nch, nch, lins + 4*15 + 64); + +#if HAVE_SIMD + if (have_simd()) for (i = 14; i >= 0; i--) + { +#define VLOAD(k) f4 w0 = VSET(*w++); f4 w1 = VSET(*w++); f4 vz = VLD(&zlin[4*i - 64*k]); f4 vy = VLD(&zlin[4*i - 64*(15 - k)]); +#define V0(k) { VLOAD(k) b = VADD(VMUL(vz, w1), VMUL(vy, w0)) ; a = VSUB(VMUL(vz, w0), VMUL(vy, w1)); } +#define V1(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vz, w0), VMUL(vy, w1))); } +#define V2(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vy, w1), VMUL(vz, w0))); } + f4 a, b; + zlin[4*i] = xl[18*(31 - i)]; + zlin[4*i + 1] = xr[18*(31 - i)]; + zlin[4*i + 2] = xl[1 + 18*(31 - i)]; + zlin[4*i + 3] = xr[1 + 18*(31 - i)]; + zlin[4*i + 64] = xl[1 + 18*(1 + i)]; + zlin[4*i + 64 + 1] = xr[1 + 18*(1 + i)]; + zlin[4*i - 64 + 2] = xl[18*(1 + i)]; + zlin[4*i - 64 + 3] = xr[18*(1 + i)]; + + V0(0) V2(1) V1(2) V2(3) V1(4) V2(5) V1(6) V2(7) + + { +#ifndef MINIMP3_FLOAT_OUTPUT +#if HAVE_SSE + static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f }; + static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f }; + __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)), + _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min))); + dstr[(15 - i)*nch] = _mm_extract_epi16(pcm8, 1); + dstr[(17 + i)*nch] = _mm_extract_epi16(pcm8, 5); + dstl[(15 - i)*nch] = _mm_extract_epi16(pcm8, 0); + dstl[(17 + i)*nch] = _mm_extract_epi16(pcm8, 4); + dstr[(47 - i)*nch] = _mm_extract_epi16(pcm8, 3); + dstr[(49 + i)*nch] = _mm_extract_epi16(pcm8, 7); + dstl[(47 - i)*nch] = _mm_extract_epi16(pcm8, 2); + dstl[(49 + i)*nch] = _mm_extract_epi16(pcm8, 6); +#else /* HAVE_SSE */ + int16x4_t pcma, pcmb; + a = VADD(a, VSET(0.5f)); + b = VADD(b, VSET(0.5f)); + pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0))))); + pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0))))); + vst1_lane_s16(dstr + (15 - i)*nch, pcma, 1); + vst1_lane_s16(dstr + (17 + i)*nch, pcmb, 1); + vst1_lane_s16(dstl + (15 - i)*nch, pcma, 0); + vst1_lane_s16(dstl + (17 + i)*nch, pcmb, 0); + vst1_lane_s16(dstr + (47 - i)*nch, pcma, 3); + vst1_lane_s16(dstr + (49 + i)*nch, pcmb, 3); + vst1_lane_s16(dstl + (47 - i)*nch, pcma, 2); + vst1_lane_s16(dstl + (49 + i)*nch, pcmb, 2); +#endif /* HAVE_SSE */ + +#else /* MINIMP3_FLOAT_OUTPUT */ + + static const f4 g_scale = { 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f }; + a = VMUL(a, g_scale); + b = VMUL(b, g_scale); +#if HAVE_SSE + _mm_store_ss(dstr + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(1, 1, 1, 1))); + _mm_store_ss(dstr + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(1, 1, 1, 1))); + _mm_store_ss(dstl + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(0, 0, 0, 0))); + _mm_store_ss(dstl + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(0, 0, 0, 0))); + _mm_store_ss(dstr + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(3, 3, 3, 3))); + _mm_store_ss(dstr + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(3, 3, 3, 3))); + _mm_store_ss(dstl + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(2, 2, 2, 2))); + _mm_store_ss(dstl + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(2, 2, 2, 2))); +#else /* HAVE_SSE */ + vst1q_lane_f32(dstr + (15 - i)*nch, a, 1); + vst1q_lane_f32(dstr + (17 + i)*nch, b, 1); + vst1q_lane_f32(dstl + (15 - i)*nch, a, 0); + vst1q_lane_f32(dstl + (17 + i)*nch, b, 0); + vst1q_lane_f32(dstr + (47 - i)*nch, a, 3); + vst1q_lane_f32(dstr + (49 + i)*nch, b, 3); + vst1q_lane_f32(dstl + (47 - i)*nch, a, 2); + vst1q_lane_f32(dstl + (49 + i)*nch, b, 2); +#endif /* HAVE_SSE */ +#endif /* MINIMP3_FLOAT_OUTPUT */ + } + } else +#endif /* HAVE_SIMD */ +#ifdef MINIMP3_ONLY_SIMD + {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */ +#else /* MINIMP3_ONLY_SIMD */ + for (i = 14; i >= 0; i--) + { +#define LOAD(k) float w0 = *w++; float w1 = *w++; float *vz = &zlin[4*i - k*64]; float *vy = &zlin[4*i - (15 - k)*64]; +#define S0(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] = vz[j]*w1 + vy[j]*w0, a[j] = vz[j]*w0 - vy[j]*w1; } +#define S1(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vz[j]*w0 - vy[j]*w1; } +#define S2(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vy[j]*w1 - vz[j]*w0; } + float a[4], b[4]; + + zlin[4*i] = xl[18*(31 - i)]; + zlin[4*i + 1] = xr[18*(31 - i)]; + zlin[4*i + 2] = xl[1 + 18*(31 - i)]; + zlin[4*i + 3] = xr[1 + 18*(31 - i)]; + zlin[4*(i + 16)] = xl[1 + 18*(1 + i)]; + zlin[4*(i + 16) + 1] = xr[1 + 18*(1 + i)]; + zlin[4*(i - 16) + 2] = xl[18*(1 + i)]; + zlin[4*(i - 16) + 3] = xr[18*(1 + i)]; + + S0(0) S2(1) S1(2) S2(3) S1(4) S2(5) S1(6) S2(7) + + dstr[(15 - i)*nch] = mp3d_scale_pcm(a[1]); + dstr[(17 + i)*nch] = mp3d_scale_pcm(b[1]); + dstl[(15 - i)*nch] = mp3d_scale_pcm(a[0]); + dstl[(17 + i)*nch] = mp3d_scale_pcm(b[0]); + dstr[(47 - i)*nch] = mp3d_scale_pcm(a[3]); + dstr[(49 + i)*nch] = mp3d_scale_pcm(b[3]); + dstl[(47 - i)*nch] = mp3d_scale_pcm(a[2]); + dstl[(49 + i)*nch] = mp3d_scale_pcm(b[2]); + } +#endif /* MINIMP3_ONLY_SIMD */ +} + +static void mp3d_synth_granule(float *qmf_state, float *grbuf, int nbands, int nch, mp3d_sample_t *pcm, float *lins) +{ + int i; + for (i = 0; i < nch; i++) + { + mp3d_DCT_II(grbuf + 576*i, nbands); + } + + memcpy(lins, qmf_state, sizeof(float)*15*64); + + for (i = 0; i < nbands; i += 2) + { + mp3d_synth(grbuf + i, pcm + 32*nch*i, nch, lins + i*64); + } +#ifndef MINIMP3_NONSTANDARD_BUT_LOGICAL + if (nch == 1) + { + for (i = 0; i < 15*64; i += 2) + { + qmf_state[i] = lins[nbands*64 + i]; + } + } else +#endif /* MINIMP3_NONSTANDARD_BUT_LOGICAL */ + { + memcpy(qmf_state, lins + nbands*64, sizeof(float)*15*64); + } +} + +static int mp3d_match_frame(const uint8_t *hdr, int mp3_bytes, int frame_bytes) +{ + int i, nmatch; + for (i = 0, nmatch = 0; nmatch < MAX_FRAME_SYNC_MATCHES; nmatch++) + { + i += hdr_frame_bytes(hdr + i, frame_bytes) + hdr_padding(hdr + i); + if (i + HDR_SIZE > mp3_bytes) + return nmatch > 0; + if (!hdr_compare(hdr, hdr + i)) + return 0; + } + return 1; +} + +static int mp3d_find_frame(const uint8_t *mp3, int mp3_bytes, int *free_format_bytes, int *ptr_frame_bytes) +{ + int i, k; + for (i = 0; i < mp3_bytes - HDR_SIZE; i++, mp3++) + { + if (hdr_valid(mp3)) + { + int frame_bytes = hdr_frame_bytes(mp3, *free_format_bytes); + int frame_and_padding = frame_bytes + hdr_padding(mp3); + + for (k = HDR_SIZE; !frame_bytes && k < MAX_FREE_FORMAT_FRAME_SIZE && i + 2*k < mp3_bytes - HDR_SIZE; k++) + { + if (hdr_compare(mp3, mp3 + k)) + { + int fb = k - hdr_padding(mp3); + int nextfb = fb + hdr_padding(mp3 + k); + if (i + k + nextfb + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + k + nextfb)) + continue; + frame_and_padding = k; + frame_bytes = fb; + *free_format_bytes = fb; + } + } + if ((frame_bytes && i + frame_and_padding <= mp3_bytes && + mp3d_match_frame(mp3, mp3_bytes - i, frame_bytes)) || + (!i && frame_and_padding == mp3_bytes)) + { + *ptr_frame_bytes = frame_and_padding; + return i; + } + *free_format_bytes = 0; + } + } + *ptr_frame_bytes = 0; + return mp3_bytes; +} + +void mp3dec_init(mp3dec_t *dec) +{ + dec->header[0] = 0; +} + +int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info +#ifdef MINIMP3_EXTERNAL_SCRATCH + , void *workspace +#endif +) +{ + int i = 0, igr, frame_size = 0, success = 1; + const uint8_t *hdr; + bs_t bs_frame[1]; +#ifdef MINIMP3_EXTERNAL_SCRATCH + /* Trail Mate: decoder workspace is caller-owned PSRAM, never an ESP task local. */ + mp3dec_scratch_t *scratch_pointer = (mp3dec_scratch_t *)workspace; +#define scratch (*scratch_pointer) +#else + mp3dec_scratch_t scratch; +#endif + + if (mp3_bytes > 4 && dec->header[0] == 0xff && hdr_compare(dec->header, mp3)) + { + frame_size = hdr_frame_bytes(mp3, dec->free_format_bytes) + hdr_padding(mp3); + if (frame_size != mp3_bytes && (frame_size + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + frame_size))) + { + frame_size = 0; + } + } + if (!frame_size) + { + memset(dec, 0, sizeof(mp3dec_t)); + i = mp3d_find_frame(mp3, mp3_bytes, &dec->free_format_bytes, &frame_size); + if (!frame_size || i + frame_size > mp3_bytes) + { + info->frame_bytes = i; + return 0; + } + } + + hdr = mp3 + i; + memcpy(dec->header, hdr, HDR_SIZE); + info->frame_bytes = i + frame_size; + info->frame_offset = i; + info->channels = HDR_IS_MONO(hdr) ? 1 : 2; + info->hz = hdr_sample_rate_hz(hdr); + info->layer = 4 - HDR_GET_LAYER(hdr); + info->bitrate_kbps = hdr_bitrate_kbps(hdr); + + if (!pcm) + { + return hdr_frame_samples(hdr); + } + + bs_init(bs_frame, hdr + HDR_SIZE, frame_size - HDR_SIZE); + if (HDR_IS_CRC(hdr)) + { + get_bits(bs_frame, 16); + } + + if (info->layer == 3) + { + int main_data_begin = L3_read_side_info(bs_frame, scratch.gr_info, hdr); + if (main_data_begin < 0 || bs_frame->pos > bs_frame->limit) + { + mp3dec_init(dec); + return 0; + } + success = L3_restore_reservoir(dec, bs_frame, &scratch, main_data_begin); + if (success) + { + for (igr = 0; igr < (HDR_TEST_MPEG1(hdr) ? 2 : 1); igr++, pcm += 576*info->channels) + { + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + L3_decode(dec, &scratch, scratch.gr_info + igr*info->channels, info->channels); + mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 18, info->channels, pcm, scratch.syn[0]); + } + } + L3_save_reservoir(dec, &scratch); + } else + { +#ifdef MINIMP3_ONLY_MP3 + return 0; +#else /* MINIMP3_ONLY_MP3 */ + L12_scale_info sci[1]; + L12_read_scale_info(hdr, bs_frame, sci); + + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + for (i = 0, igr = 0; igr < 3; igr++) + { + if (12 == (i += L12_dequantize_granule(scratch.grbuf[0] + i, bs_frame, sci, info->layer | 1))) + { + i = 0; + L12_apply_scf_384(sci, sci->scf + igr, scratch.grbuf[0]); + mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 12, info->channels, pcm, scratch.syn[0]); + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + pcm += 384*info->channels; + } + if (bs_frame->pos > bs_frame->limit) + { + mp3dec_init(dec); + return 0; + } + } +#endif /* MINIMP3_ONLY_MP3 */ + } + return success*hdr_frame_samples(dec->header); +} + +#ifdef MINIMP3_FLOAT_OUTPUT +void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples) +{ + int i = 0; +#if HAVE_SIMD + int aligned_count = num_samples & ~7; + for(; i < aligned_count; i += 8) + { + static const f4 g_scale = { 32768.0f, 32768.0f, 32768.0f, 32768.0f }; + f4 a = VMUL(VLD(&in[i ]), g_scale); + f4 b = VMUL(VLD(&in[i+4]), g_scale); +#if HAVE_SSE + static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f }; + static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f }; + __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)), + _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min))); + out[i ] = _mm_extract_epi16(pcm8, 0); + out[i+1] = _mm_extract_epi16(pcm8, 1); + out[i+2] = _mm_extract_epi16(pcm8, 2); + out[i+3] = _mm_extract_epi16(pcm8, 3); + out[i+4] = _mm_extract_epi16(pcm8, 4); + out[i+5] = _mm_extract_epi16(pcm8, 5); + out[i+6] = _mm_extract_epi16(pcm8, 6); + out[i+7] = _mm_extract_epi16(pcm8, 7); +#else /* HAVE_SSE */ + int16x4_t pcma, pcmb; + a = VADD(a, VSET(0.5f)); + b = VADD(b, VSET(0.5f)); + pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0))))); + pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0))))); + vst1_lane_s16(out+i , pcma, 0); + vst1_lane_s16(out+i+1, pcma, 1); + vst1_lane_s16(out+i+2, pcma, 2); + vst1_lane_s16(out+i+3, pcma, 3); + vst1_lane_s16(out+i+4, pcmb, 0); + vst1_lane_s16(out+i+5, pcmb, 1); + vst1_lane_s16(out+i+6, pcmb, 2); + vst1_lane_s16(out+i+7, pcmb, 3); +#endif /* HAVE_SSE */ + } +#endif /* HAVE_SIMD */ + for(; i < num_samples; i++) + { + float sample = in[i] * 32768.0f; + if (sample >= 32766.5) + out[i] = (int16_t) 32767; + else if (sample <= -32767.5) + out[i] = (int16_t)-32768; + else + { + int16_t s = (int16_t)(sample + .5f); + s -= (s < 0); /* away from zero, to be compliant */ + out[i] = s; + } + } +} +#endif /* MINIMP3_FLOAT_OUTPUT */ +#endif /* MINIMP3_IMPLEMENTATION && !_MINIMP3_IMPLEMENTATION_GUARD */ diff --git a/tools/web_simulator/CMakeLists.txt b/tools/web_simulator/CMakeLists.txt index c758cac8..d3d583e9 100644 --- a/tools/web_simulator/CMakeLists.txt +++ b/tools/web_simulator/CMakeLists.txt @@ -33,6 +33,8 @@ add_executable(${PREVIEW_TARGET} preview_platform.cpp preview_sensors.cpp preview_agenda.cpp + preview_music.cpp + preview_controls.cpp preview_facade.cpp preview_status.cpp preview_extensions.cpp @@ -118,6 +120,7 @@ target_include_directories(${PREVIEW_TARGET} PRIVATE "${REPO_ROOT}/modules/core_chat/include" "${REPO_ROOT}/modules/core_gps/include" "${REPO_ROOT}/modules/core_agenda/include" + "${REPO_ROOT}/modules/core_music/include" "${REPO_ROOT}/modules/core_track_sharing/include" "${REPO_ROOT}/modules/core_geocaching/include" "${REPO_ROOT}/third_party/yxml" @@ -127,6 +130,8 @@ target_compile_options(${PREVIEW_TARGET} PRIVATE -O2) target_compile_definitions(${PREVIEW_TARGET} PRIVATE LV_CONF_INCLUDE_SIMPLE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 ${BOARD_DEFINITION}=1) target_link_libraries(${PREVIEW_TARGET} PRIVATE lvgl) file(GLOB DASHBOARD_WIDGET_SOURCES "${UI}/menu/dashboard/*.cpp") +file(GLOB MUSIC_UI_SOURCES "${UI}/screens/music/*.cpp") +target_sources(${PREVIEW_TARGET} PRIVATE ${MUSIC_UI_SOURCES}) target_sources(${PREVIEW_TARGET} PRIVATE "${UI}/menu/menu_dashboard.cpp" ${DASHBOARD_WIDGET_SOURCES} preview_p4_dashboard.cpp "${UI}/presentation_sources/runtime_mesh_status_source.cpp" @@ -185,6 +190,10 @@ target_sources(${PREVIEW_TARGET} PRIVATE ${CORE_TEAM_SOURCES} ${EXTRA_VIEW_SOURC "${REPO_ROOT}/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp" "${REPO_ROOT}/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp" "${REPO_ROOT}/platform/linux/common/src/platform/ui/tracker_runtime.cpp" + "${REPO_ROOT}/platform/linux/common/src/platform/ui/music_runtime.cpp" + "${REPO_ROOT}/platform/linux/common/src/platform/ui/agenda_widget_runtime.cpp" + "${REPO_ROOT}/platform/linux/common/src/platform/resource_lock.cpp" + "${REPO_ROOT}/platform/linux/common/src/platform/ui/quick_controls_runtime.cpp" "${REPO_ROOT}/platform/linux/common/src/platform/ui/route_storage.cpp" "${REPO_ROOT}/platform/linux/common/src/platform/linux/runtime_paths.cpp" "${REPO_ROOT}/platform/linux/common/src/platform/ui/firmware_update_runtime.cpp" diff --git a/tools/web_simulator/native_menu.cpp b/tools/web_simulator/native_menu.cpp index c0c21576..221b801c 100644 --- a/tools/web_simulator/native_menu.cpp +++ b/tools/web_simulator/native_menu.cpp @@ -16,11 +16,14 @@ namespace ui::agenda class IAgendaSource; } ui::agenda::IAgendaSource* preview_agenda_source(); +void preview_bind_controls(); +void preview_bind_music(); static bool menu_created = false; static AppScreen* active_app = nullptr; static ui::AppCatalog catalog; static int page_for(const char* id) { + if (std::strcmp(id, "music") == 0) return 19; const char* ids[] = {"calculator", "sky_plot", "usb_mass_storage", "walkie_talkie", "sstv", "chat", "", "", "extensions", "network", "", "settings", "contacts", "team", "tracker", "map", "energy_sweep"}; for (int i = 0; i < 17; ++i) if (std::strcmp(ids[i], id) == 0) return i; @@ -29,6 +32,8 @@ static int page_for(const char* id) void preview_menu_enter() { + preview_bind_controls(); + preview_bind_music(); if (!menu_created) { auto* previous_group = app_g; diff --git a/tools/web_simulator/native_pages.cpp b/tools/web_simulator/native_pages.cpp index a817dc9c..b6bf4a0d 100644 --- a/tools/web_simulator/native_pages.cpp +++ b/tools/web_simulator/native_pages.cpp @@ -7,6 +7,7 @@ #include "ui/screens/extensions/extensions_page_runtime.h" #include "ui/screens/gnss/gnss_skyplot_page_runtime.h" #include "ui/screens/gps/gps_page_runtime.h" +#include "ui/screens/music/music_page_shell.h" #include "ui/screens/network/network_page_shell.h" #include "ui/screens/settings/settings_page_runtime.h" #include "ui/screens/sstv/sstv_page_runtime.h" @@ -24,11 +25,15 @@ void preview_power_enter(); void preview_power_exit(); void preview_chat_enter(int page); void preview_chat_exit(); +void preview_bind_music(); void preview_exit_page() { switch (current_page) { + case 19: + ui::music_page::exit(nullptr, lv_screen_active()); + break; case 0: calculator::ui::runtime::exit(lv_screen_active()); break; @@ -93,6 +98,10 @@ extern "C" EMSCRIPTEN_KEEPALIVE void native_open_page(int page) if (page != 10 && page != 17 && page != 18) ui::menu_runtime::setScene(ui::menu_runtime::Scene::App); switch (page) { + case 19: + preview_bind_music(); + ui::music_page::enter(nullptr, lv_screen_active()); + break; case 0: calculator::ui::runtime::enter(nullptr, lv_screen_active()); break; diff --git a/tools/web_simulator/native_runtime.cpp b/tools/web_simulator/native_runtime.cpp index 46e35638..319a45b4 100644 --- a/tools/web_simulator/native_runtime.cpp +++ b/tools/web_simulator/native_runtime.cpp @@ -101,6 +101,16 @@ static void append_json_string(const char* value) static void collect_labels(lv_obj_t* obj) { if (!lv_obj_is_visible(obj)) return; + if (lv_obj_check_type(obj, &lv_slider_class)) + { + lv_area_t area; + lv_obj_get_coords(obj, &area); + char data[220]; + std::snprintf(data, sizeof(data), "%s{\"slider\":true,\"x\":%d,\"y\":%d,\"w\":%d,\"h\":%d,\"value\":%d}", + snapshot.size() > 1 ? "," : "", static_cast(area.x1), static_cast(area.y1), + static_cast(lv_area_get_width(&area)), static_cast(lv_area_get_height(&area)), static_cast(lv_slider_get_value(obj))); + snapshot += data; + } if (lv_obj_check_type(obj, &lv_textarea_class)) { lv_area_t area; @@ -192,6 +202,7 @@ extern "C" } EMSCRIPTEN_KEEPALIVE void native_key(int key) { + if (preview_current_page() == 10 && ui::menu::dashboard::handleKey(key)) return; if (key == LV_KEY_ENTER && preview_current_page() == 10) { auto* group = input_group(); diff --git a/tools/web_simulator/preview_controls.cpp b/tools/web_simulator/preview_controls.cpp new file mode 100644 index 00000000..98aa924e --- /dev/null +++ b/tools/web_simulator/preview_controls.cpp @@ -0,0 +1,61 @@ +// Preview-only service fixture. Production widgets use the same IQuickControls port. +#include "music/service.h" +#include "ui/menu/menu_dashboard.h" +#include "ui_presentation/controls/quick_controls.h" +#include +#include +music::IService* preview_music_service(); +namespace +{ +class Source final : public ui::controls::IQuickControls +{ + ui::controls::Snapshot state; + + public: + Source() + { + state.wifi = ui::controls::SwitchState::Off; + state.lora = state.gps = ui::controls::SwitchState::On; + state.volume_supported = state.brightness_supported = true; + state.volume = 35; + state.brightness = 70; + } + bool snapshot(ui::controls::Snapshot& out) override + { + state.wifi_enabled = state.wifi == ui::controls::SwitchState::On; + state.lora_enabled = state.lora == ui::controls::SwitchState::On; + state.gps_enabled = state.gps == ui::controls::SwitchState::On; + state.volume = preview_music_service()->volume(); + out = state; + return true; + } + bool set(ui::controls::Control control, uint8_t value) override + { + using namespace ui::controls; + switch (control) + { + case Control::Wifi: + state.wifi = value ? SwitchState::On : SwitchState::Off; + break; + case Control::Lora: + state.lora = value ? SwitchState::On : SwitchState::Off; + break; + case Control::Gps: + state.gps = value ? SwitchState::On : SwitchState::Off; + break; + case Control::Volume: + { + music::Request request; + request.action = music::Action::Volume; + request.value = value; + return preview_music_service()->send(request); + } + case Control::Brightness: + state.brightness = std::clamp(value, 10, 100); + break; + } + return true; + } +} source; +} // namespace +void preview_bind_controls() { ui::menu::dashboard::bindControlsSource(&source); } diff --git a/tools/web_simulator/preview_music.cpp b/tools/web_simulator/preview_music.cpp new file mode 100644 index 00000000..c14ba0cd --- /dev/null +++ b/tools/web_simulator/preview_music.cpp @@ -0,0 +1,339 @@ +// Preview fixtures only. This file is never part of firmware or its SD library. +#include "../../modules/ui_shared/src/ui/screens/music/music_state.h" +#include "lvgl.h" +#include "music/service.h" +#include "ui/menu/menu_dashboard.h" +#include "ui/screens/music/music_page_shell.h" +#include +#include +#include +#include +#include +namespace +{ +struct Song +{ + const char* title; + const char* artist; + const char* album; + const char* folder; +}; +constexpr Song songs[] = { + {"After the rain", "Aster", "Small horizons", "Field recordings"}, + {"Pines at dusk", "Aster", "Small horizons", "Field recordings"}, + {"A longer way home", "Northbound", "Open country", "Trail mix"}, + {"River stones", "Northbound", "Open country", "Trail mix"}, + {"Morning window", "Linden", "Quiet rooms", "Ambient"}, + {"The last light", "Linden", "Quiet rooms", "Ambient"}, + {"Still water", "Aster", "Small horizons", "Field recordings"}, + {"Campfire radio", "Northbound", "Open country", "Trail mix"}, + {"Footsteps", "Linden", "Quiet rooms", "Ambient"}, + {"Faraway hills", "Aster", "Small horizons", "Field recordings"}, + {"Beneath the clouds", "Northbound", "Open country", "Trail mix"}, + {"Trail memo", "Unknown artist", "Unknown album", "Voice notes"}}; +class Source final : public music::IService +{ + uint8_t visible = 4; + uint32_t started = 0, base_position = 0; + char playlist_names[8][49]{"Evening trail", "On the move"}; + uint16_t playlist_tracks[8]{3, 12}; + uint8_t playlists = 2; + music::Artwork* art = nullptr; + uint16_t art_edge = 0; + void metadata(unsigned offset) + { + static constexpr struct + { + const char* id; + const char* name; + const char* value; + } fields[] = { + {"TIT2", "Title", "After the rain"}, {"TPE1", "Artist", "Aster"}, {"TALB", "Album", "Small horizons"}, {"TPE2", "Album artist", "Aster"}, {"TCOM", "Composer", "Mira Vale"}, {"TDRC", "Recording date", "2026-09-12"}, {"TRCK", "Track number", "1/12"}, {"TPOS", "Disc number", "1/1"}, {"TCON", "Genre", "Ambient"}, {"TCOP", "Copyright", "2026 Aster. Preview fixture."}, {"TENC", "Encoded by", "Trail Mate test encoder"}, {"COMM", "Comment", "Recorded beside the northern trail after the rain."}, {"APIC", "Front cover", "JPEG | 640 x 640 | type 3"}, {"USLT", "Embedded lyrics", "Available in Lyrics"}, {"FILE", "File", "/Music/Field recordings/After the rain.mp3"}}; + auto& page = out.metadata; + page = {}; + page.track_id = out.current.id; + page.total = std::size(fields); + page.offset = offset; + for (unsigned i = offset; i < std::size(fields) && page.count < music::kMetadataRows; i++) + { + auto& dest = page.fields[page.count++]; + std::snprintf(dest.id, sizeof(dest.id), "%s", fields[i].id); + std::snprintf(dest.name, sizeof(dest.name), "%s", fields[i].name); + std::snprintf(dest.value, sizeof(dest.value), "%s", fields[i].value); + } + ++out.metadata_revision; + } + void rows() + { + out.row_count = 0; + const auto append = [&](uint64_t id, const char* title, const char* subtitle, uint32_t count) + { + if(out.row_count>=visible)return; + auto& row=out.rows[out.row_count++];row={};row.id=id;row.count=count; + std::snprintf(row.title,sizeof(row.title),"%s",title);std::snprintf(row.subtitle,sizeof(row.subtitle),"%s",subtitle); }; + if (out.browse == music::Browse::Playlists) + { + out.total = playlists; + for (unsigned index = out.offset; index < playlists && out.row_count < visible; index++) + { + char count[32]; + std::snprintf(count, sizeof(count), "%u songs", playlist_tracks[index]); + append(100 + index, playlist_names[index], count, playlist_tracks[index]); + } + } + else if (out.browse == music::Browse::Artists || out.browse == music::Browse::Albums || out.browse == music::Browse::Folders) + { + const char* artists[] = {"Aster", "Northbound", "Linden", "Unknown artist"}; + const char* albums[] = {"Small horizons", "Open country", "Quiet rooms", "Unknown album"}; + const char* folders[] = {"Field recordings", "Trail mix", "Ambient", "Voice notes"}; + const auto* values = out.browse == music::Browse::Artists ? artists : out.browse == music::Browse::Albums ? albums + : folders; + out.total = 4; + for (unsigned index = out.offset; index < 4 && out.row_count < visible; index++) append(index + 1, values[index], index == 3 ? "1 song" : "4 songs", index == 3 ? 1 : 4); + } + else + { + unsigned matched = 0; + for (unsigned index = 0; index < std::size(songs); index++) + { + const auto& song = songs[index]; + const bool match = out.browse == music::Browse::ArtistTracks ? std::strcmp(out.category, song.artist) == 0 : out.browse == music::Browse::AlbumTracks ? std::strcmp(out.category, song.album) == 0 + : out.browse == music::Browse::FolderTracks ? std::strcmp(out.category, song.folder) == 0 + : out.browse == music::Browse::PlaylistTracks ? index < playlist_tracks[out.playlist_id - 100] + : true; + if (!match) continue; + if (matched++ < out.offset) continue; + char subtitle[128]; + std::snprintf(subtitle, sizeof(subtitle), "%s | %s", song.artist, song.album); + append(index + 1, song.title, subtitle, 0); + } + out.total = matched; + } + ++out.library_revision; + ++out.revision; + } + void play(uint64_t id) + { + id = std::clamp(id, 1, std::size(songs)); + out.current = {}; + out.current.id = id; + const auto& song = songs[id - 1]; + std::snprintf(out.current.title, sizeof(out.current.title), "%s", song.title); + std::snprintf(out.current.artist, sizeof(out.current.artist), "%s", song.artist); + std::snprintf(out.current.album, sizeof(out.current.album), "%s", song.album); + std::snprintf(out.current.genre, sizeof(out.current.genre), "Ambient"); + std::snprintf(out.current.path, sizeof(out.current.path), "/Music/%s/%s.mp3", song.folder, song.title); + out.current.duration_ms = 214000; + out.current.picture_size = 48000; + out.current.picture_format = music::PictureFormat::Jpeg; + out.playback = music::Playback::Playing; + base_position = 74000; + started = lv_tick_get(); + out.position_ms = base_position; + out.has_lyrics = out.synchronized_lyrics = true; + std::snprintf(out.lyric_previous, sizeof(out.lyric_previous), "The evening settles on the pines"); + std::snprintf(out.lyric_current, sizeof(out.lyric_current), "We take the quiet way home"); + std::snprintf(out.lyric_next, sizeof(out.lyric_next), "One more bend before the light is gone"); + ++out.revision; + if (art) + { + art->drop(); + art = nullptr; + } + request_artwork(art_edge); + } + + public: + music::Snapshot out{}; + uint8_t volume() const override { return out.volume; } + Source() + { + out.storage_ready = out.audio_supported = true; + rows(); + } + void attach(uint8_t count) override + { + visible = count; + rows(); + } + void detach() override { request_artwork(0); } + void request_artwork(uint16_t edge) override + { + art_edge = edge; + if (!edge) + { + if (art) + { + art->drop(); + art = nullptr; + ++out.artwork_revision; + } + return; + } + if (!out.current.id || (art && art->track_id == out.current.id && art->width == edge)) return; + if (art) + { + art->drop(); + art = nullptr; + } + art = music::make_artwork(out.current.id, edge, edge); + if (art) + for (unsigned y = 0; y < edge; y++) + for (unsigned x = 0; x < edge; x++) + { + const int px = x * 240 / edge, py = y * 240 / edge; + int r = 214 - py / 4, g = 219 - py / 5, b = 193 - py / 6; + if ((px - 173) * (px - 173) + (py - 62) * (py - 62) < 29 * 29) + { + r = 231; + g = 174; + b = 96; + } + if (py > 113 + std::abs(px - 84) / 3) + { + r = 119; + g = 151; + b = 142; + } + if (py > 153 - std::abs(px - 168) / 4) + { + r = 62; + g = 111; + b = 108; + } + if (py > 193 + std::abs(px - 55) / 5) + { + r = 35; + g = 78; + b = 80; + } + if (py > 190 && std::abs(px - (109 + (py - 190) / 3)) < 2) + { + r = 222; + g = 204; + b = 162; + } + art->pixels[y * edge + x] = static_cast((r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)); + } + ++out.artwork_revision; + } + music::Artwork* acquire_artwork() override + { + if (art) art->retain(); + return art; + } + bool snapshot(music::Snapshot& value) override + { + if (out.playback == music::Playback::Playing) out.position_ms = std::min(out.current.duration_ms, base_position + lv_tick_elaps(started)); + value = out; + return true; + } + bool send(const music::Request& request) override + { + out.notice[0] = '\0'; + switch (request.action) + { + case music::Action::MetadataPage: + metadata(std::max(0, request.value)); + break; + case music::Action::SetPlayMode: + out.mode = static_cast(std::clamp(request.value, 0, 2)); + break; + case music::Action::Browse: + out.browse = request.browse; + out.offset = std::max(0, request.value); + out.playlist_id = request.id; + std::snprintf(out.category, sizeof(out.category), "%s", request.text); + rows(); + break; + case music::Action::Play: + play(request.id); + break; + case music::Action::TogglePause: + if (out.playback == music::Playback::Playing) + { + base_position = out.position_ms; + out.playback = music::Playback::Paused; + } + else if (out.current.id) + { + started = lv_tick_get(); + out.playback = music::Playback::Playing; + } + break; + case music::Action::Stop: + out.playback = music::Playback::Stopped; + break; + case music::Action::Next: + { + const bool paused = out.playback == music::Playback::Paused; + play(out.current.id % std::size(songs) + 1); + if (paused) out.playback = music::Playback::Paused; + break; + } + case music::Action::Previous: + { + const bool paused = out.playback == music::Playback::Paused; + play(out.current.id > 1 ? out.current.id - 1 : std::size(songs)); + if (paused) out.playback = music::Playback::Paused; + break; + } + case music::Action::Seek: + base_position = std::clamp(request.value, 0, out.current.duration_ms); + started = lv_tick_get(); + break; + case music::Action::Volume: + out.volume = std::clamp(request.value, 0, 100); + break; + case music::Action::CreatePlaylist: + if (playlists < 8 && request.text[0]) + { + std::snprintf(playlist_names[playlists], 49, "%.48s", request.text); + playlist_tracks[playlists++] = request.id ? 1 : 0; + } + rows(); + break; + case music::Action::AddToPlaylist: + if (request.playlist_id >= 100 && request.playlist_id < 100 + playlists) playlist_tracks[request.playlist_id - 100]++; + rows(); + break; + case music::Action::Rescan: + std::snprintf(out.notice, sizeof(out.notice), "Library up to date"); + break; + default: + break; + } + ++out.revision; + return true; + } + void set_playing(bool value) + { + if (value) play(1); + else out.playback = music::Playback::Stopped; + } +} source; +} // namespace +void preview_bind_music() +{ + ui::music_page::bind_service(&source); + ui::menu::dashboard::bindMusicSource(&source); +} +music::IService* preview_music_service() { return &source; } +extern "C" EMSCRIPTEN_KEEPALIVE void native_preview_music_playing(int value) { source.set_playing(value != 0); } +// QA only: inspect the actual shared LVGL objects, including slider knob clearance. +extern "C" EMSCRIPTEN_KEEPALIVE int native_music_geometry() +{ + const auto* self = ui::music_page::state(); + if (!self || !self->cover || !self->progress) return -1; + lv_obj_update_layout(self->root); + lv_area_t cover{}, progress{}, top{}, body{}, footer{}; + lv_obj_get_coords(lv_obj_get_parent(self->cover), &cover); + lv_obj_get_coords(self->progress, &progress); + lv_obj_get_coords(self->bar.container, &top); + lv_obj_get_coords(self->body, &body); + lv_obj_get_coords(self->footer, &footer); + const auto knob = lv_obj_get_style_pad_top(self->progress, LV_PART_KNOB) + lv_obj_get_height(self->progress); + if (cover.y2 + knob >= progress.y1) return 1; + if (top.y2 >= body.y1 || body.y2 >= footer.y1) return 2; + return 0; +} diff --git a/tools/web_simulator/preview_sensors.cpp b/tools/web_simulator/preview_sensors.cpp index 9b540f7d..8926eeb7 100644 --- a/tools/web_simulator/preview_sensors.cpp +++ b/tools/web_simulator/preview_sensors.cpp @@ -13,6 +13,8 @@ namespace platform::ui::orientation { +bool acquire_heading() { return true; } +void release_heading() {} static HeadingState preview_heading{}; HeadingState get_heading() { return preview_heading; } ScreenOrientationState get_screen_orientation()