From 3df8082d2ceffa3243236c0e90cba8fc5f095cb4 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Fri, 16 Oct 2026 11:58:53 +0800 Subject: [PATCH] Add PSRAM music playback and coordinated device widgets --- .github/workflows/ci.yml | 8 + .../boards/t_display_p4/heading_runtime.h | 2 + .../boards/t_display_p4/t_display_p4_board.h | 1 + boards/t_display_p4/src/heading_runtime.cpp | 76 +- .../t_display_p4/src/t_display_p4_board.cpp | 5 + .../boards/tdeck/tdeck_audio_runtime.h | 4 + .../tdeck/include/boards/tdeck/tdeck_board.h | 1 + boards/tdeck/src/tdeck_audio_runtime.cpp | 38 + boards/tdeck/src/tdeck_board.cpp | 3 + .../boards/tlora_pager/tlora_pager_board.h | 1 + boards/tlora_pager/src/tlora_pager_board.cpp | 28 + .../esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake | 36 + builds/esp_idf/main/CMakeLists.txt | 2 + cmake/TrailMateLinuxSources.cmake | 3 + docs/images/music/p4-music-widget.png | Bin 0 -> 42774 bytes docs/images/music/p4-quick-controls.png | Bin 0 -> 41558 bytes docs/images/music/pager-now-playing.png | Bin 0 -> 8369 bytes docs/images/music/pager-quick-controls.png | Bin 0 -> 9165 bytes .../43-裸NVS入口与存储别名清单.md | 2 +- docs/uiux/components/menu_widgets.md | 27 +- docs/uiux/components/music.md | 132 ++ modules/core_music/include/music/artwork.h | 74 + modules/core_music/include/music/catalog.h | 51 + modules/core_music/include/music/decoder.h | 36 + modules/core_music/include/music/id3.h | 36 + modules/core_music/include/music/lyrics.h | 36 + modules/core_music/include/music/memory.h | 40 + .../include/music/playback_policy.h | 58 + modules/core_music/include/music/player.h | 90 + modules/core_music/include/music/playlists.h | 38 + modules/core_music/include/music/reader.h | 13 + modules/core_music/include/music/service.h | 141 ++ modules/core_music/include/music/storage.h | 50 + modules/core_music/library.json | 6 + modules/core_music/src/catalog.cpp | 522 +++++ modules/core_music/src/decoder.cpp | 137 ++ modules/core_music/src/id3.cpp | 437 ++++ modules/core_music/src/lyrics.cpp | 157 ++ modules/core_music/src/player.cpp | 484 +++++ modules/core_music/src/playlists.cpp | 281 +++ modules/core_music/src/storage.cpp | 45 + .../include/platform/audio/audio_focus.h | 42 + .../include/platform/audio/media_volume.h | 11 + .../include/platform/radio/radio_power.h | 54 + .../include/platform/sync/resource_lock.h | 24 + .../platform/ui/agenda_widget_runtime.h | 12 + .../include/platform/ui/music_runtime.h | 10 + .../include/platform/ui/orientation_runtime.h | 4 + .../platform/ui/quick_controls_runtime.h | 12 + .../include/platform/ui/wifi_runtime.h | 3 + .../src/platform/audio/audio_focus.cpp | 77 + .../src/platform/audio/media_volume.cpp | 30 + .../src/platform/radio/radio_power.cpp | 10 + .../music_image/include/music/cover_decoder.h | 9 + modules/music_image/library.json | 6 + modules/music_image/src/cover_decoder.cpp | 227 ++ .../ui_presentation/controls/quick_controls.h | 38 + .../ui/menu/dashboard/dashboard_widget.h | 3 + .../ui/menu/dashboard/dashboard_widgets.h | 10 + .../include/ui/menu/menu_dashboard.h | 17 +- .../ui/screens/music/music_page_shell.h | 12 + modules/ui_shared/library.json | 2 + .../ui_shared/src/ui/app_catalog_builder.cpp | 10 +- modules/ui_shared/src/ui/assets/music.c | 166 ++ .../dashboard/dashboard_agenda_widget.cpp | 11 + .../dashboard/dashboard_compass_widget.cpp | 6 + .../dashboard/dashboard_controls_widget.cpp | 189 ++ .../menu/dashboard/dashboard_music_widget.cpp | 215 ++ .../ui_shared/src/ui/menu/menu_dashboard.cpp | 16 + modules/ui_shared/src/ui/menu/menu_layout.cpp | 7 +- .../src/ui/screens/music/music_page_input.cpp | 308 +++ .../ui/screens/music/music_page_layout.cpp | 492 +++++ .../src/ui/screens/music/music_page_shell.cpp | 102 + .../src/ui/screens/music/music_state.cpp | 35 + .../src/ui/screens/music/music_state.h | 103 + .../ui_shared/tests/agenda/test_catalog.cpp | 13 +- .../src/app_config_tms_settings_extension.cpp | 10 + .../esp/arduino_common/src/app_context.cpp | 2 + platform/esp/arduino_common/src/app_tasks.cpp | 40 +- .../src/exclusive_lora_runtime.cpp | 7 + .../src/platform_ui_device_runtime.cpp | 2 + .../src/platform_ui_orientation_runtime.cpp | 2 + .../platform/esp/common/music/music_ports.h | 43 + .../platform/esp/common/wifi_runtime_impl.h | 36 +- platform/esp/common/library.json | 3 + .../esp/common/src/agenda_widget_runtime.cpp | 150 ++ platform/esp/common/src/music/music_audio.cpp | 99 + .../esp/common/src/music/music_runtime.cpp | 479 +++++ .../esp/common/src/music/music_storage.cpp | 108 + .../esp/common/src/quick_controls_runtime.cpp | 189 ++ platform/esp/common/src/resource_lock.cpp | 12 + .../platform/esp/idf_common/sx126x_radio.h | 2 +- .../src/platform_ui_device_runtime.cpp | 5 +- .../src/platform_ui_orientation_runtime.cpp | 15 + .../src/reticulum_call_runtime_support.cpp | 19 +- platform/esp/idf_common/src/sx126x_radio.cpp | 11 +- .../t_display_p4/CMakeLists.txt | 1 + .../bsp/trail_mate_t_display_p4_runtime.h | 2 + .../t_display_p4_audio_runtime.cpp | 72 +- .../esp/wio_tracker_l2/wio_tracker_l2_board.h | 3 + .../src/wio_tracker_l2_board.cpp | 77 +- .../common/src/platform/resource_lock.cpp | 28 + .../src/platform/ui/agenda_widget_runtime.cpp | 6 + .../common/src/platform/ui/music_runtime.cpp | 5 + .../src/platform/ui/orientation_runtime.cpp | 2 + .../platform/ui/quick_controls_runtime.cpp | 6 + .../common/src/platform/ui/wifi_runtime.cpp | 2 + .../test_settings_scalar_projection.cpp | 3 +- tests/hostlink/test_tms_scalar_storage.cpp | 14 + tests/menu_widgets/CMakeLists.txt | 15 +- tests/menu_widgets/agenda_fakes/esp_timer.h | 3 + .../arduino_common/storage/sd_card_runtime.h | 85 + tests/menu_widgets/test_agenda_widget.cpp | 113 + tests/music/CMakeLists.txt | 66 + .../controls_fakes/app/app_facade_access.h | 33 + tests/music/controls_fakes/board/LoraBoard.h | 7 + .../platform/esp/idf_common/sx126x_radio.h | 15 + tests/music/fixtures/cover.jpg | Bin 0 -> 5061 bytes tests/music/fixtures/cover.png | Bin 0 -> 1988 bytes tests/music/fixtures/landscape.jpg | Bin 0 -> 4794 bytes tests/music/fixtures/progressive.jpg | Bin 0 -> 4572 bytes tests/music/generate_cover_fixtures.py | 25 + tests/music/image_memory.cpp | 47 + tests/music/lv_conf.h | 8 + tests/music/memory_storage.h | 117 + tests/music/test_catalog.cpp | 90 + tests/music/test_controls.cpp | 182 ++ tests/music/test_decoder.cpp | 56 + tests/music/test_focus.cpp | 63 + tests/music/test_lyrics.cpp | 51 + tests/music/test_media_volume.cpp | 18 + tests/music/test_playback_policy.cpp | 28 + tests/music/test_player.cpp | 149 ++ tests/music/test_playlists.cpp | 64 + tests/music/test_radio_power.cpp | 35 + tests/music/test_tags.cpp | 196 ++ tests/p4_hardware/CMakeLists.txt | 2 + tests/p4_hardware/fakes/hardware.h | 15 +- tests/p4_hardware/test_audio.cpp | 16 + tests/p4_hardware/test_heading.cpp | 10 + tests/site/menu-widgets-native.test.mjs | 17 +- tests/site/music-native-ui.test.mjs | 34 + tests/site/quick-controls-native.test.mjs | 61 + tests/site/track-ground-overlays.browser.mjs | 3 + third_party/minimp3/LICENSE | 117 + third_party/minimp3/README.trail-mate.md | 14 + third_party/minimp3/minimp3.h | 1879 +++++++++++++++++ tools/web_simulator/CMakeLists.txt | 9 + tools/web_simulator/native_menu.cpp | 5 + tools/web_simulator/native_pages.cpp | 9 + tools/web_simulator/native_runtime.cpp | 11 + tools/web_simulator/preview_controls.cpp | 61 + tools/web_simulator/preview_music.cpp | 339 +++ tools/web_simulator/preview_sensors.cpp | 2 + 154 files changed, 10857 insertions(+), 55 deletions(-) create mode 100644 docs/images/music/p4-music-widget.png create mode 100644 docs/images/music/p4-quick-controls.png create mode 100644 docs/images/music/pager-now-playing.png create mode 100644 docs/images/music/pager-quick-controls.png create mode 100644 docs/uiux/components/music.md create mode 100644 modules/core_music/include/music/artwork.h create mode 100644 modules/core_music/include/music/catalog.h create mode 100644 modules/core_music/include/music/decoder.h create mode 100644 modules/core_music/include/music/id3.h create mode 100644 modules/core_music/include/music/lyrics.h create mode 100644 modules/core_music/include/music/memory.h create mode 100644 modules/core_music/include/music/playback_policy.h create mode 100644 modules/core_music/include/music/player.h create mode 100644 modules/core_music/include/music/playlists.h create mode 100644 modules/core_music/include/music/reader.h create mode 100644 modules/core_music/include/music/service.h create mode 100644 modules/core_music/include/music/storage.h create mode 100644 modules/core_music/library.json create mode 100644 modules/core_music/src/catalog.cpp create mode 100644 modules/core_music/src/decoder.cpp create mode 100644 modules/core_music/src/id3.cpp create mode 100644 modules/core_music/src/lyrics.cpp create mode 100644 modules/core_music/src/player.cpp create mode 100644 modules/core_music/src/playlists.cpp create mode 100644 modules/core_music/src/storage.cpp create mode 100644 modules/core_sys/include/platform/audio/audio_focus.h create mode 100644 modules/core_sys/include/platform/audio/media_volume.h create mode 100644 modules/core_sys/include/platform/radio/radio_power.h create mode 100644 modules/core_sys/include/platform/sync/resource_lock.h create mode 100644 modules/core_sys/include/platform/ui/agenda_widget_runtime.h create mode 100644 modules/core_sys/include/platform/ui/music_runtime.h create mode 100644 modules/core_sys/include/platform/ui/quick_controls_runtime.h create mode 100644 modules/core_sys/src/platform/audio/audio_focus.cpp create mode 100644 modules/core_sys/src/platform/audio/media_volume.cpp create mode 100644 modules/core_sys/src/platform/radio/radio_power.cpp create mode 100644 modules/music_image/include/music/cover_decoder.h create mode 100644 modules/music_image/library.json create mode 100644 modules/music_image/src/cover_decoder.cpp create mode 100644 modules/ui_presentation/include/ui_presentation/controls/quick_controls.h create mode 100644 modules/ui_shared/include/ui/screens/music/music_page_shell.h create mode 100644 modules/ui_shared/src/ui/assets/music.c create mode 100644 modules/ui_shared/src/ui/menu/dashboard/dashboard_controls_widget.cpp create mode 100644 modules/ui_shared/src/ui/menu/dashboard/dashboard_music_widget.cpp create mode 100644 modules/ui_shared/src/ui/screens/music/music_page_input.cpp create mode 100644 modules/ui_shared/src/ui/screens/music/music_page_layout.cpp create mode 100644 modules/ui_shared/src/ui/screens/music/music_page_shell.cpp create mode 100644 modules/ui_shared/src/ui/screens/music/music_state.cpp create mode 100644 modules/ui_shared/src/ui/screens/music/music_state.h create mode 100644 platform/esp/common/include/platform/esp/common/music/music_ports.h create mode 100644 platform/esp/common/src/agenda_widget_runtime.cpp create mode 100644 platform/esp/common/src/music/music_audio.cpp create mode 100644 platform/esp/common/src/music/music_runtime.cpp create mode 100644 platform/esp/common/src/music/music_storage.cpp create mode 100644 platform/esp/common/src/quick_controls_runtime.cpp create mode 100644 platform/esp/common/src/resource_lock.cpp create mode 100644 platform/linux/common/src/platform/resource_lock.cpp create mode 100644 platform/linux/common/src/platform/ui/agenda_widget_runtime.cpp create mode 100644 platform/linux/common/src/platform/ui/music_runtime.cpp create mode 100644 platform/linux/common/src/platform/ui/quick_controls_runtime.cpp create mode 100644 tests/menu_widgets/agenda_fakes/esp_timer.h create mode 100644 tests/menu_widgets/agenda_fakes/platform/esp/arduino_common/storage/sd_card_runtime.h create mode 100644 tests/menu_widgets/test_agenda_widget.cpp create mode 100644 tests/music/CMakeLists.txt create mode 100644 tests/music/controls_fakes/app/app_facade_access.h create mode 100644 tests/music/controls_fakes/board/LoraBoard.h create mode 100644 tests/music/controls_fakes/platform/esp/idf_common/sx126x_radio.h create mode 100644 tests/music/fixtures/cover.jpg create mode 100644 tests/music/fixtures/cover.png create mode 100644 tests/music/fixtures/landscape.jpg create mode 100644 tests/music/fixtures/progressive.jpg create mode 100644 tests/music/generate_cover_fixtures.py create mode 100644 tests/music/image_memory.cpp create mode 100644 tests/music/lv_conf.h create mode 100644 tests/music/memory_storage.h create mode 100644 tests/music/test_catalog.cpp create mode 100644 tests/music/test_controls.cpp create mode 100644 tests/music/test_decoder.cpp create mode 100644 tests/music/test_focus.cpp create mode 100644 tests/music/test_lyrics.cpp create mode 100644 tests/music/test_media_volume.cpp create mode 100644 tests/music/test_playback_policy.cpp create mode 100644 tests/music/test_player.cpp create mode 100644 tests/music/test_playlists.cpp create mode 100644 tests/music/test_radio_power.cpp create mode 100644 tests/music/test_tags.cpp create mode 100644 tests/site/music-native-ui.test.mjs create mode 100644 tests/site/quick-controls-native.test.mjs create mode 100644 third_party/minimp3/LICENSE create mode 100644 third_party/minimp3/README.trail-mate.md create mode 100644 third_party/minimp3/minimp3.h create mode 100644 tools/web_simulator/preview_controls.cpp create mode 100644 tools/web_simulator/preview_music.cpp 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 0000000000000000000000000000000000000000..deb949054a1ae50ee5dc7b703a82b7ade6dbf829 GIT binary patch literal 42774 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 JPY<63{|{ma)7k(4 literal 0 HcmV?d00001 diff --git a/docs/images/music/p4-quick-controls.png b/docs/images/music/p4-quick-controls.png new file mode 100644 index 0000000000000000000000000000000000000000..f4389fcb869e01043afb91573c80d2a3d418fa78 GIT binary patch literal 41558 zcmb@tXH-*b*ESjvP$`N^lPXfANQa;lQRxCAT?i$#P!d48QbiF23>_3AAiV|xfuPg? zQl&@>300-{UZj2t_kP~-yyuK_etl~&AcL%RmsziS&TEA~eWFH7#X;WBx!%z<+Fs;SdcaPzO7}GMl_dEf*PBq99_aJX z*OHz;o?oC{<5Eoc;OCwhlw`|Wo;^3y$m@k@NwS-f_pq4DQS2IE0hR$=cQO?9f++v_ z{PR*U?cYyO4KB>Nzn|_1kb^-W_3r6xuRc~+DJaot?eTawRgL*1)3+gsaS?Q) zTG*p?@5P`|pR7{&+A=LwP~x{C_eD8CJ{E1$*+#D4cLG!n`JzQ+!w2d~tff^m%vk4E zUG06nQyaA-8?`$DiPiRx7i*|Npljm9f{3D-m?QV@>1Q#+LJ8KuWShQr?(#LLT8j*a z<{vAasyQb<1t&fllR+b&D~x8j4KYv8)|T@u$Hg##_OPIRY4-{VsIqid zB)?9m?Dh&DIuW&|c?1)e?ncUo-RS(v8L{5E z=xAxH<`e7Z&xDSjz=IH#A@MF#kD7T{K@aZV?H@Et@L}U$%ZD%oSU#;0m+|z}&s*p5gJ0<{^YKQfhbFl!%fvt0lT5(!Og8tjKWp?B?3PFo3(8%+ zWQ5Y(qAVbbu3R8c?d#QtSX|!659OY6j^ie1h-5iR?VQgSZ1!mQOM&6tgw=(>f)D+tJ{xg&ZF0OAgM11_v5j^(yO`W^4TNSnN_D=YVNmT{Vo0JVZGDDp2OR3& z!Su1QJAaEM(GfwQh%7=rvjH#E(WJD*4gUA{9L*qC2U8jYekSRV8)O_P?mW8;OraPIhMx zO~zQ*M>Ly>X2~|cn5`rc2PcS7VLfr4WKJSK{GJ|k>$vz@T;^>RL!&PXL^758jH-Wb zhT$nePua4jQr3qoV0`0R9CMwy(F1n+Ziq`Ysf(d^QLFP7L)TIxl8eD**^yoiM;!}N;A1$FT3enqyM7V`g1W_$ zjZQyXr$HE~v~%eB?IpzRYfDcmYWsM}tO%6M`qkWzjo?~1_0;@mU;h-EWsg^sG!1E+ zjE5cX8%Lh4Pz%tP9|caXsGM-;Sor8NVs~X%YozzWMo-H4FMW@hQ$wc}mp@82GpC)% zwF3kWx$3cWojBq_hH~4ka6#9KM%up_nd+Ao!4wp%$$&u5%(C&SH`>^V{JxHsMOn=z z2i7~G2lWqP5g180VgBONV&=G$-cl!O+*1vnAWlI~&k6=N9@z_>2^SF{5XDt`A^|e9 zCmk4Idi>(%vZqs+)Ax@NiVK&${sdh?x{ja{6AwBb)JnrIC|E&oYg^3y*5qKq_04GZ zjc)HEf1+dRm6SQ1VC{QLx&O$Ao~Rn;jiu8$@@L9nP=jV++S_hxtLqAX`FLZkK45Wx)H#C zQeirLJ;h4sSYN0V99ZfUNk{C2L_2d;L2?3;bENNMrF+X>(SmN?&aQON#+{@+%-!uN z31(Y1*a@)0=%Y`~GiRWK@|GlwO>DJcMx1)mZ?;q}6JA?u-q>$zO{9L)WX6f1Q*TBM z!o)plflFjS+!61-gg8Zy2HXs~FIB24bh;;pVZTp_qt2@4N}3#QDKk0w!GElOeXV$r zsbITgWWQvzTiuBDxAQdKh~$i3emBz`#f>iO0?Rw+)DHl5{ZcE6f-%TxZ8=3FsdJu; zM8C^c8D&7zang|z^#aCQg*nS1(1#C$R{$GT%-JktZ(UR?#PF6LO|HeY>j?YNj<&%T z%nfU?ER#DjLPAa6dDXy|4H1}efx4m*D^Xvl78f;#_Xe=9rD@fAf}XjhBjA+>E;8 ztg#tYywXMj^4!=*>$Di4pLhm=2dfF+2vN0@iF~-qvzin;jF8} zDK+TDD6?_Hz@;pldb|fn8&)u}Dz#EZAX`7c3 zvvv2Ss|VmMCpUajyh@veB-fJ*LDUkNP$3qY>wHUl-3MzrY9ykttz#Kk z5NLX0+i&0`=Q`YAjRvthHd6W8q%y~P!f_Y%`Xw0jisz$@!eMmkJ0}omy?U9rn`JYx zed~C{of2FFy5a%<-j-%BSzQqA4g!_;q&=%i$aOsJp#;6MPDvvyTPh7d-k&t7>ov;a9k7X*hT1d=aE&lm?r>6~LWg%z?)|ZIpmu&g z7g|Ta`I6qGlA5})$G`UNLWkg*kw)b?_kRQ5-|`zD$w@dUwFiM-m`t+H=Ga;Y5wt~x zXDh80!`8Osu8cN(d{k`jL5Ul*xkUZ|(9XL?k^R%Y#%gKMEBQksnIKQSE=P`z6}7fN zqmy2eMelBwq)*>i1-O1xmDvfIjU~NzSvNq9%eYTm0ZC6))4?G6?^Orxxs`ccjPkwSycaC>S^x7uiiJ>Jsqu zE2fRQUrg}{!Vk@auR5Z~#tV==Q_6B)rQ%=BDk3{RHMMiHFMas6S3~pf3U7%k4Jt)& z8Wjeeh|ALLvkt912G+SU*EtRAvlx8&IFHh#p?mB_e% zs1+3gY^!cDHA|k8p4^Kv1>{eSz14dOs zp6QF}LBQVm)LzeRT>!l>{7~;<*_rgf1nyq&zDKUE)XS~1;-{I(5uTA)Ks+&=v1ACx zC-I(nSuD{QBM+xIiu%NM$zNU2D~0fnQR`f z5@OZSjd4%GK{B*j(st(`#M~bNV3tK<&fV&ul~S2b)#OLU>#YlpOMZsc=95K*qYPR# z=kVq0Yd=d7mqx7O8&u>FkKuL)zU~D-=s(q3^y#Pw24st>uffg7v3YCRl@cxTyL66d zt;x-?C?9Bz5t+r?e)E*@Wzj^WF zp!)^zQts`Nqla`nmG zr-oXG$bwS}0MeF|0}=0Loa0YRsW<0Y4ORMwxz4km(uK_)CIVJesJs2?2lb0Dhu$g7 z*|&dMz*=W#j>sTe$Bze*I#D5u>E7Zz=xrqh9?+fe{9tWUp{2acK*3EC;>DOAE@UBE zgxb-Q+V5T8hi!dbi_*Ke*{`Y0!QDNGlPQ04+?|JKPWkOJBDg!D`GfMNg1Q5vkP|fP zk#ZY3lc+y*>Ux$v)J77P7L05N+CQ6b;)8_npDD}xpa&_L3CiU+z$+MyL?q2j$?6lN z6T2L%q0uA&iR2K~%^xSdGn154CKoBF5Z9HK*ad%w5}&~d6EZCD?p9O%F*?7nh`@~8 zlWjWUHCr>s->TR$Q|`LvD7T!Plj8DjhZK9^B|V(W1wvig$=11NAK8gLZwQ#T2-#bK zFTg@7DsOZ(Zxvi~cjp;A*|cb8tl@Ew#WQ+MW$zB7Y-=3IK;P(zp5I()QFETjUIVPD zK7e5yr>7@Sf3(^fdoq~IJ^QkVE?B$asX#V`L$S19*o{EsMMP~+3ICC~mqRh3qTZE4 zY`@GU@j}+YbMwm6AVcvhk7wP6+|93jt6owtquynYD%xcinHfyK_drYzW8Uq!q;sio zGhGpgAdB8_Wu4A^)Q_cbs~~&LFcDd96Ct2CaJ4=-?6Bh-T=p6raXxuqDbrD5@3fYY>h)|hw+v|dOydT_xrJb};&c;5jdS}SXGW+$T7!b(Bqh=A!>V!(xV zd&u@eAO>Q_RZ6n?1vVI)7rncb1GjdKU zt`Zs9!;oI!=Gk^cUGtMg9Z}6yS#N`>XOo$oqjgiq&n43)p^p7lyy<3* z?{_Qf9v=GcAP<{Jb)wkTz3p+8k%OAK`4{3N2N%e}No|Ee2T4yuN&E8;pe-?jRuad$ zKsjLJ>vPExg(ftVH!D?ujf&7Jg8r_y~~&Hd>nswXRWV!TqSWmg|A9! zN7=EM(CKOn{K%r3pbhtlrAv9V#}(qlQXlZ!p4=b%PX!vxfqUFpx7=A z-VEUz_;L9v=;rx)nIuUp$x-+V^JWE1;iRw=0H4e(MZq-b1a%*DO3R2kIXKsccvU9l zn|N}o1nNtl*6~Xi9=ZQq-wWFlbOFp*{eH1or>An5oy{#O{qq}R+tP)AE=S~*#Kyq! zT7%1~$&Wyys8^3Oo+Nagmmv^oJh-?uuUJ90|tN^mJy~~Q5 zZ})-SG#JxYU8(K?PuoHiG{lUN<9bT=WoZ@P3w4jfL!zCF!Pnvu9wv$xdaa4Bvu-5u zVAu`XlgoreIF*Z8fIDQzg5rYcdzDJDd)))jhKc>BS0@ZNuZ|KO2dW49Ikf;Zr+GMm zITSzGI|xF4@RIuetpfYSQtuuOVz9BH#>kupQ`XczX!(S2o;9bE zpjvcNmWF^$9ll#nURn6ezEn(EUIx^wxG`_-kqm04*eBo9v~Qx*&wqF5D&&nWio)F@ zAm@oq1MGpc^P4UO4MeuA@XD5~k$6sQ0r>cl_VG~5>|N)S5AKT%X#hS(^s1S>q(%(F zZ5tP)1LK5+#?_J}F=Z97laweInodMG4{!aOclI!wQ`
EyvB021-kjBV^KlLi(m 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 Zb!fb!?fR6Wn@1bFuBMU3=i3g?{|8=@2z>wm literal 0 HcmV?d00001 diff --git a/docs/images/music/pager-now-playing.png b/docs/images/music/pager-now-playing.png new file mode 100644 index 0000000000000000000000000000000000000000..f80985b88699441157395e6b7b58d7d509765b2d GIT binary patch literal 8369 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 W?KW9c2?NyyNL$_DL7A#u)c*muyR6&* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b107ea0da7fd5adfa6218067ca915d9c9902cf5b GIT binary patch literal 5061 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 literal 0 HcmV?d00001 diff --git a/tests/music/fixtures/progressive.jpg b/tests/music/fixtures/progressive.jpg new file mode 100644 index 0000000000000000000000000000000000000000..88edf1ba02b5fed151d9eccd249599006ebad280 GIT binary patch literal 4572 zcmb_f2{@Ep`+sJbLADuNLKs3wp=8Te_L9b)JuR{uyRtNdLLxNEPL^zqWo*foQkLw* zRAfzdAxrq4LA~{V-}k!y-*tWG`CZT4&z$@G&hMV{-1j-N{eF80KxnFJr~(iO06@SC z*d_vJ05T|)1hA2VfKkF=C{|DkfTLTBiTHZPx=xN?;zCCxh?+P$Yy53E6G{*hx~! zASAs%80MVzbDG^ZzYy$oyVw*>^(Uc!q0j1r53Uz3gPonyD4B98 z(wUu~r+eW{=NB44DCMlTyB~3m-nC@m(NRRa}Ms~RhF?Et5J4)Ve#&`YxNXk?x>Z$O(evXSkRBoAFhw{w1EVMc$tP~f#DY5R48A#(;9CWO zLAQaRctN)t1ry6Ou}{7pQ%t_nHhGshaUC^y^mV`*SH8uVl?7^wYi>n*eZ6IKt7#jc zo;u}w%TMLK#Flx?$qQ;DX7T$fmU4WBwkRXC4ZQ9Pcqx1H8z|eWCL74v+#6HPSkzuS z_Asn7b2;km*EZznEY)Vq1NPB2wITum?UQF7TUNkkT+R{IO{by)NomucvQ1l4e=6xQ3LvWU6CGHCF{nAazSXT&G5jJ?^;Q~5WiC|EyGqq{)=0$h^ANN_y>7!(d>>TZKT0WxwVgQysS5r*PX zx`5{9RmASB2v`9WAgewnSwD_@1=8z;mo$qKq(%g}5uh6z(V zyJI9yRiQPb)S}YZt(H*Cnc`P?AdI7Jf%<@#s1^Gsn$ttuV`Jf zV-&Tt;x3+rQ#`$Deoiub#9ExcST?{j4nrI6tpLM)j>#_(wXwbK@_g>AivPC)li2mx z=*wUT0y`mqb&qT>0hvacJA8|8GW%m_(qO?f7U zIg0rY6UcnC+ zWE@~tHi*9EeNh#WkFQ0G2Pp;DWa~v-(wKpX6ocP9>e7WCUYMV)?9R(4q{vRrcv#GC zCiNP=2SrOD00>y_DZzTZmkMB|M3SL+6v-L5#gx#Oq8NEGScK?tozs>sh4bPUoI4iw z(gLmkrUmTdrJ}8jR@IS5u>#h5b1iBjc$e8_;sMuE@pU5lQR&n7uhn;P23ylv}; zJdRAc2(yE(<(%=n4cP;(&yDI4X1Ysdo2j2W?7XKAoVF`hBZjQJ=^wMY?ovg#pV;pdWKFg(pM1le zn0@A6O@dsHvB12{p*smd@gJRnJQ)}5hs(M28nJXXk!liYlj+naaxV1^{s>@Bp!0YE zS#}dxy+vR4%+q7_;wxwQoSEse`eJO07c+N9r?E{%fz*u~i{JH2JkNDu?4ua_Y~KlE zmkph+R)eFpVvR0M%e2ZFgk|X>T8~D$e{cK2JJ!F)^7{1exSW5@#-Ca>{N!I;`9r#{ij0EZu}J)%L$fC; z{E|YDC>^u@{}ZcLf&Af#+et^zhhso$++gRQQb;tM%3-4w0s%alp)+k^Jzl&SO_Fg# znLY?1{Hkehc7~uzLa*r$W=5i{@LcU{;lPk}Aytb^))f!z52jzza9wn}EDU=rOmO^r zpUi4_Ikh|6)+V~^u~tvJUii9wBc}24>dzo?^V6K_i&c*WweYPkTYJoD(PMWfy5yvv zd@v(_yCOw1645xn8z^q^A3Qrya!)>-zOumq@0cKw>ZTr^8tqOl5FGN{pSv2u*3iPHC&+hgizCTFMlMyyByHDO6PXT_FZH{6v`D6i8vpM_3JtN~M}aI>0? ztEsgA^N3nt1t)oF`O&@(szzUYx=2&|u+&g;d8ehWb-qAo3%gYN?eYj)`K|1EK0=*G z^_}ywGeHL&4MLEOcbDx(vO+g&o89c4Sw?nQ)1`* z2<8s}AifX!{)7@a;wKm*q((qQpFVzG7EHHa*J2>8#Wp}A`jUjmm*+$mn~xNMxD5df zOe4o08s_|lL~#>4j~jEovi71P3L#)K++V) z)9;!UZ4B*9#|_>`!bqvVx3~VR@L(7aq!*6b#(I-}=&?^G>-YQb zQZqQzcZ38WDTA`Rg=0WJ0=679CBMMeGWX>|yt^Q=|k zCL*+M{IWsxcTPS$#!t!n1mWj)Chk+!)SyD#(6{QX7u*5jtAQaeS24?Vpn4$$xVeJI zPVj#fc;F;82!KRkz#`18cnKTTfe=ldw=AsNTMF>tsesr9*mfLuZztomGP`U_1|0s) zC@TQ~xR6=9$&<6Gmwc)RXZ@{*BapV)Q>)pOLh0N!^c5hHm!sanYQ7nq8AV{{TRSr+ z`nI;^rNDopHC9h#`eg3UrLDuK&igr76MaI`f|2AnT?^2EJ+ zQt9al%atFUDSPwMbj>OEb7jM40e_gj^Y`)T;bNjU)~>*&<72NK?tM2o(+jm*c#KdW zQ)%&-i+3$UinL0L7yHpK!}{U)B~IPw=;Yov`Ow2(+>ZCSFEHxeTOhtRzC1SU%vlqV zQ-1TC2j=4Rk6J?CiL!$quT}WA?ulnC<$3l4U-N7&SU~CW!}e4Sr;x@H^`wFOXGb2j z2qZ{+&rXRP=Ym1>06H%IUY^PBNXt`^4(cL&d~h`KOU!qTQQLnM=U zxdXS))Z^=porqJpX;>T;?)~9Il5d7L=v$5k>1#>G2iL{3;FdQd!)CB6)lv!dUN0?d z@7W}<0MLYBu5t5wYj2+m55WuzJ!p(2o@KpQpHy44p+!x#eW9#=&t*4qw?!aaVkP?t z-jMh47k{zJ*dD^Cqq1-jw!W?pdT4Hb%ccv4k*;SMXk6vd*Rz|kn%3P&U%Y%@BV(L4 ztKAG*!?Hk4qJ8VKyKt^umFeyz#dYtNHB;_%((;^(AL|?Is?#{;x}mh6NH*hxk-ohU kT;0R;aNnq_8U47VPG#xyR~z&NG5-9Kot#x$8{7T=0X-x2p8x;= literal 0 HcmV?d00001 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()