From b44bd7d3f039c0acea70c4e498bb2c337183d1ca Mon Sep 17 00:00:00 2001 From: "DESKTOP-PUHFO60\\dsx2016" Date: Sun, 18 Oct 2026 18:51:05 +0800 Subject: [PATCH] Snapshot Beep + WAV UI before Pomodoro rewrite. Keep LEDC tones, TF WAV playback, and compact list layout as a tagged baseline. Co-authored-by: Cursor --- CHANGELOG.md | 41 +++++ include/audio_test.h | 15 +- include/sd_put.h | 7 + include/ui.h | 4 + include/wav_list.h | 23 +++ include/wav_player.h | 12 ++ platformio.ini | 5 +- src/audio_test.cpp | 217 +++++++++++++++++++------ src/main.cpp | 30 +++- src/sd_info.cpp | 4 +- src/sd_put.cpp | 121 ++++++++++++++ src/ui.cpp | 322 +++++++++++++++++++++++++++----------- src/wav_list.cpp | 129 +++++++++++++++ src/wav_player.cpp | 365 +++++++++++++++++++++++++++++++++++++++++++ tools/sd_put.py | 150 ++++++++++++++++++ 15 files changed, 1295 insertions(+), 150 deletions(-) create mode 100644 include/sd_put.h create mode 100644 include/wav_list.h create mode 100644 include/wav_player.h create mode 100644 src/sd_put.cpp create mode 100644 src/wav_list.cpp create mode 100644 src/wav_player.cpp create mode 100644 tools/sd_put.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 208e9c6..44453e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## 2026-10-18 — WAV via DAC2 only (touch-safe) + +- Drop I2S INTERNAL_DAC (it enables GPIO25 and kills touch). +- PCM WAV → RAM → dac_output on GPIO26 only; Beep tab unchanged. + +## 2026-10-18 — Beep tab + WAV tab + +- Keep 16 LEDC beeps on Beep tab. +- WAV tab scans TF (/ and /wav); short files load to RAM then play via Internal DAC. +- Core0 decode task; gain capped for onboard amp. + +## 2026-10-18 — Built-in UI beep sounds + +- Drop MP3; LEDC tone bank: Click/Key/Pop/Confirm/Cancel/Success/Error/... +- Scrollable SOUNDS list; tap to play. + +## 2026-10-18 — Smooth MP3 + keep UI alive + +- MP3 decode on FreeRTOS core0; UI/touch on core1. +- DAC1(GPIO25) disabled while playing so touch CLK works. +- No SD rescan during playback (SPI contention). + +## 2026-10-18 — Music list from TF + +- Scan root + /music for .mp3; scrollable list; tap row to play. +- PLAY uses selected track; auto-enables speaker on list tap. + +## 2026-10-18 — Play /paomo.mp3 from TF + +- PLAY streams `/paomo.mp3` via ESP8266Audio + internal DAC (GPIO26). +- Serial `#PUT` + `tools/sd_put.py` to copy MP3 onto the TF card. + +## 2026-10-18 — Canon in D melody + +- Replace Twinkle Twinkle with Pachelbel Canon in D (monophonic). + +## 2026-10-17 — ASCII glyphs + volume slider + +- Replace ·/— (tofu boxes) with ASCII `-` / `|`. +- Add VOL slider (0-100); live duty update while playing. + ## 2026-10-17 — UI layout refresh - Compact 320x240 layout: header status, capacity hero, usage bar, dual actions. diff --git a/include/audio_test.h b/include/audio_test.h index 1a78116..1fb1005 100644 --- a/include/audio_test.h +++ b/include/audio_test.h @@ -1,20 +1,29 @@ #pragma once #include +#include void audio_init(); void audio_set_enabled(bool on); bool audio_is_enabled(); -// 音量 0–100,默认 20 void audio_set_volume(uint8_t percent); uint8_t audio_get_volume(); -void audio_play_music(); +// 内置提示音 +uint8_t sound_count(); +const char *sound_name(uint8_t idx); +int8_t sound_selected(); +void sound_set_selected(int8_t idx); +void audio_play_sound(uint8_t idx); + +// WAV(TF) +void audio_play_wav_index(uint8_t idx); +void audio_play_selected(); // 按当前 tab 语义由 UI 决定;保留播 beep 选中 + void audio_stop(); bool audio_is_playing(); void audio_tick(); - const char *audio_last_status(); diff --git a/include/sd_put.h b/include/sd_put.h new file mode 100644 index 0000000..30deee2 --- /dev/null +++ b/include/sd_put.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +// 串口协议: "#PUT \n" -> READY -> raw -> DONE/FAIL +// 在 loop 里调用;返回 true 表示刚处理完一次传输 +bool sd_put_poll(); diff --git a/include/ui.h b/include/ui.h index 204e7cf..96be760 100644 --- a/include/ui.h +++ b/include/ui.h @@ -6,4 +6,8 @@ void ui_create(); void ui_update(const SdCardInfo &info); void ui_refresh_audio_buttons(); +void ui_refresh_lists(); void ui_set_touch_debug(int16_t lx, int16_t ly, bool down); + +// 0=Beep 1=WAV +uint8_t ui_active_tab(); diff --git a/include/wav_list.h b/include/wav_list.h new file mode 100644 index 0000000..643bd4f --- /dev/null +++ b/include/wav_list.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +static constexpr size_t WAV_MAX = 24; +static constexpr size_t WAV_PATH_MAX = 64; +static constexpr size_t WAV_NAME_MAX = 28; + +struct WavTrack { + char path[WAV_PATH_MAX]; + char name[WAV_NAME_MAX]; + uint32_t bytes; +}; + +uint8_t wav_list_count(); +int8_t wav_list_selected(); +void wav_list_set_selected(int8_t idx); +const WavTrack *wav_list_get(uint8_t idx); +const char *wav_list_current_path(); + +bool wav_list_scan(); diff --git a/include/wav_player.h b/include/wav_player.h new file mode 100644 index 0000000..fdd2ab1 --- /dev/null +++ b/include/wav_player.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +void wav_player_init(); +void wav_player_stop(); +bool wav_player_is_playing(); +bool wav_player_play_path(const char *path); +void wav_player_set_volume(uint8_t percent); // 0-100, maps to SetGain +void wav_player_tick(); // no-op if task handles; kept for API +const char *wav_player_status(); diff --git a/platformio.ini b/platformio.ini index 2d1324a..c8c504b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,8 @@ platform = espressif32 board = esp32dev framework = arduino -monitor_speed = 115200 +monitor_speed = 460800 +upload_speed = 460800 board_build.partitions = default.csv lib_deps = @@ -47,6 +48,8 @@ build_flags = -DLV_USE_BUTTON=1 -DLV_USE_LABEL=1 -DLV_USE_BAR=1 + -DLV_USE_SLIDER=1 + -DLV_USE_TABVIEW=1 -I${PROJECT_DIR}/include extra_scripts = pre:copy_lv_conf.py diff --git a/src/audio_test.cpp b/src/audio_test.cpp index fad2c7c..7e354a9 100644 --- a/src/audio_test.cpp +++ b/src/audio_test.cpp @@ -1,34 +1,92 @@ #include "audio_test.h" #include "pins.h" +#include "wav_list.h" +#include "wav_player.h" + +#include static const char *g_status = "Speaker OFF"; static bool g_enabled = false; -static bool g_playing = false; -static uint8_t g_volume = 20; // 默认 20% +static bool g_beep_playing = false; +static uint8_t g_volume = 40; +static int8_t g_selected = 0; + static size_t g_note_i = 0; static uint32_t g_until_ms = 0; - -enum Phase : uint8_t { kStartNote, kWaitNote, kWaitGap }; -static Phase g_phase = kStartNote; +enum Phase : uint8_t { kStart, kWaitTone, kWaitGap }; +static Phase g_phase = kStart; static constexpr int k_ledc_ch = 0; static constexpr int k_ledc_res = 8; -struct Note { +struct ToneNote { uint16_t freq; uint16_t ms; }; -static const Note k_music[] = { - {262, 280}, {262, 280}, {392, 280}, {392, 280}, {440, 280}, {440, 280}, {392, 520}, - {349, 280}, {349, 280}, {330, 280}, {330, 280}, {294, 280}, {294, 280}, {262, 520}, - {392, 280}, {392, 280}, {349, 280}, {349, 280}, {330, 280}, {330, 280}, {294, 520}, - {392, 280}, {392, 280}, {349, 280}, {349, 280}, {330, 280}, {330, 280}, {294, 520}, - {262, 280}, {262, 280}, {392, 280}, {392, 280}, {440, 280}, {440, 280}, {392, 520}, - {349, 280}, {349, 280}, {330, 280}, {330, 280}, {294, 280}, {294, 280}, {262, 700}, +struct SoundDef { + const char *name; + const ToneNote *notes; + uint8_t count; }; -static constexpr size_t k_music_len = sizeof(k_music) / sizeof(k_music[0]); +static const ToneNote k_click[] = {{1200, 40}}; +static const ToneNote k_confirm[] = {{880, 70}, {0, 30}, {1175, 90}}; +static const ToneNote k_cancel[] = {{600, 80}, {0, 40}, {440, 100}}; +static const ToneNote k_success[] = { + {523, 80}, {0, 20}, {659, 80}, {0, 20}, {784, 120}}; +static const ToneNote k_error[] = { + {400, 120}, {0, 40}, {400, 120}, {0, 40}, {300, 180}}; +static const ToneNote k_warn[] = {{900, 90}, {0, 70}, {900, 90}}; +static const ToneNote k_notify[] = { + {1000, 50}, {0, 40}, {1000, 50}, {0, 40}, {1000, 50}}; +static const ToneNote k_boot[] = { + {440, 70}, {0, 20}, {554, 70}, {0, 20}, {659, 100}}; +static const ToneNote k_shutdown[] = { + {659, 80}, {0, 20}, {554, 80}, {0, 20}, {440, 120}}; +static const ToneNote k_alarm[] = { + {880, 120}, {0, 80}, {880, 120}, {0, 80}, {880, 120}, + {0, 80}, {880, 200}}; +static const ToneNote k_connect[] = {{700, 60}, {0, 30}, {1050, 100}}; +static const ToneNote k_disconnect[] = {{1050, 60}, {0, 30}, {700, 100}}; +static const ToneNote k_lowbat[] = {{500, 150}, {0, 100}, {400, 200}}; +static const ToneNote k_chime[] = { + {784, 100}, {0, 30}, {988, 100}, {0, 30}, {1175, 140}}; +static const ToneNote k_key[] = {{1400, 25}}; +static const ToneNote k_pop[] = {{1800, 30}, {0, 15}, {1200, 40}}; + +static const SoundDef k_sounds[] = { + {"Click", k_click, (uint8_t)(sizeof(k_click) / sizeof(k_click[0]))}, + {"Key", k_key, (uint8_t)(sizeof(k_key) / sizeof(k_key[0]))}, + {"Pop", k_pop, (uint8_t)(sizeof(k_pop) / sizeof(k_pop[0]))}, + {"Confirm", k_confirm, (uint8_t)(sizeof(k_confirm) / sizeof(k_confirm[0]))}, + {"Cancel", k_cancel, (uint8_t)(sizeof(k_cancel) / sizeof(k_cancel[0]))}, + {"Success", k_success, (uint8_t)(sizeof(k_success) / sizeof(k_success[0]))}, + {"Error", k_error, (uint8_t)(sizeof(k_error) / sizeof(k_error[0]))}, + {"Warn", k_warn, (uint8_t)(sizeof(k_warn) / sizeof(k_warn[0]))}, + {"Notify", k_notify, (uint8_t)(sizeof(k_notify) / sizeof(k_notify[0]))}, + {"Boot", k_boot, (uint8_t)(sizeof(k_boot) / sizeof(k_boot[0]))}, + {"Shutdown", k_shutdown, (uint8_t)(sizeof(k_shutdown) / sizeof(k_shutdown[0]))}, + {"Alarm", k_alarm, (uint8_t)(sizeof(k_alarm) / sizeof(k_alarm[0]))}, + {"Connect", k_connect, (uint8_t)(sizeof(k_connect) / sizeof(k_connect[0]))}, + {"Disconnect", k_disconnect, + (uint8_t)(sizeof(k_disconnect) / sizeof(k_disconnect[0]))}, + {"LowBat", k_lowbat, (uint8_t)(sizeof(k_lowbat) / sizeof(k_lowbat[0]))}, + {"Chime", k_chime, (uint8_t)(sizeof(k_chime) / sizeof(k_chime[0]))}, +}; + +static constexpr uint8_t k_sound_count = + (uint8_t)(sizeof(k_sounds) / sizeof(k_sounds[0])); + +static const SoundDef *g_active = nullptr; + +static uint32_t volume_duty() { + uint32_t duty = (127u * g_volume) / 100u; + if (duty < 1 && g_volume > 0) { + duty = 1; + } + return duty; +} static void speaker_off_pin() { ledcWrite(k_ledc_ch, 0); @@ -43,22 +101,51 @@ static void speaker_tone(uint16_t freq) { } ledcSetup(k_ledc_ch, freq, k_ledc_res); ledcAttachPin(SPEAKER_PIN, k_ledc_ch); - // 方波占空比随音量;20% → duty≈25/255 - uint32_t duty = (127u * g_volume) / 100u; - if (duty < 1) { - duty = 1; + ledcWrite(k_ledc_ch, volume_duty()); +} + +static void stop_beep_only() { + g_beep_playing = false; + g_active = nullptr; + g_note_i = 0; + g_phase = kStart; + speaker_off_pin(); +} + +uint8_t sound_count() { + return k_sound_count; +} + +const char *sound_name(uint8_t idx) { + if (idx >= k_sound_count) { + return "-"; } - ledcWrite(k_ledc_ch, duty); + return k_sounds[idx].name; +} + +int8_t sound_selected() { + return g_selected; +} + +void sound_set_selected(int8_t idx) { + if (idx < 0 || idx >= (int8_t)k_sound_count) { + g_selected = -1; + return; + } + g_selected = idx; } void audio_init() { pinMode(SPEAKER_PIN, OUTPUT); digitalWrite(SPEAKER_PIN, LOW); g_enabled = false; - g_playing = false; - g_volume = 20; + g_beep_playing = false; + g_volume = 40; + g_selected = 0; g_status = "Speaker OFF"; - Serial.printf("Speaker GPIO%d vol=%u%%\n", SPEAKER_PIN, (unsigned)g_volume); + wav_player_init(); + Serial.printf("Speaker GPIO%d vol=%u%% beeps=%u\n", SPEAKER_PIN, + (unsigned)g_volume, (unsigned)k_sound_count); } void audio_set_volume(uint8_t percent) { @@ -66,6 +153,7 @@ void audio_set_volume(uint8_t percent) { percent = 100; } g_volume = percent; + wav_player_set_volume(percent); Serial.printf("Volume %u%%\n", (unsigned)g_volume); } @@ -80,8 +168,9 @@ void audio_set_enabled(bool on) { g_status = "Speaker OFF"; } else { g_status = "Speaker ON"; + wav_player_stop(); speaker_tone(1000); - delay(80); + delay(60); speaker_off_pin(); } Serial.printf("Speaker %s\n", on ? "ON" : "OFF"); @@ -92,35 +181,69 @@ bool audio_is_enabled() { } void audio_stop() { - g_playing = false; - g_note_i = 0; - g_phase = kStartNote; - speaker_off_pin(); + stop_beep_only(); + wav_player_stop(); if (g_enabled) { g_status = "Speaker ON"; } } bool audio_is_playing() { - return g_playing; + return g_beep_playing || wav_player_is_playing(); } -void audio_play_music() { - if (!g_enabled) { - g_status = "Turn ON first"; - Serial.println("Audio: blocked (speaker off)"); +void audio_play_sound(uint8_t idx) { + if (idx >= k_sound_count) { + g_status = "Bad sound"; return; } - g_playing = true; + if (!g_enabled) { + g_enabled = true; + } + wav_player_stop(); + sound_set_selected((int8_t)idx); + g_active = &k_sounds[idx]; g_note_i = 0; - g_phase = kStartNote; + g_phase = kStart; g_until_ms = 0; - g_status = "Playing..."; - Serial.println("Audio: play music"); + g_beep_playing = true; + g_status = g_active->name; + Serial.printf("Beep: %s\n", g_active->name); +} + +void audio_play_wav_index(uint8_t idx) { + const WavTrack *t = wav_list_get(idx); + if (!t) { + g_status = "No wav"; + return; + } + if (!g_enabled) { + g_enabled = true; + } + stop_beep_only(); + wav_list_set_selected((int8_t)idx); + if (wav_player_play_path(t->path)) { + g_status = wav_player_status(); + } else { + g_status = wav_player_status(); + } +} + +void audio_play_selected() { + if (g_selected < 0) { + g_status = "No sound"; + return; + } + audio_play_sound((uint8_t)g_selected); } void audio_tick() { - if (!g_playing || !g_enabled) { + if (wav_player_is_playing()) { + g_status = wav_player_status(); + return; + } + + if (!g_beep_playing || !g_active || !g_enabled) { return; } @@ -129,31 +252,33 @@ void audio_tick() { return; } - if (g_phase == kWaitNote) { + if (g_phase == kWaitTone) { speaker_off_pin(); g_phase = kWaitGap; - g_until_ms = now + 35; + g_until_ms = now + 12; g_note_i++; return; } if (g_phase == kWaitGap) { - g_phase = kStartNote; + g_phase = kStart; } - if (g_note_i >= k_music_len) { - audio_stop(); - g_status = "Music done"; - Serial.println("Audio: music done"); + if (g_note_i >= g_active->count) { + stop_beep_only(); + g_status = g_enabled ? "Done" : "Speaker OFF"; return; } - const Note &n = k_music[g_note_i]; + const ToneNote &n = g_active->notes[g_note_i]; speaker_tone(n.freq); g_until_ms = now + n.ms; - g_phase = kWaitNote; + g_phase = kWaitTone; } const char *audio_last_status() { + if (wav_player_is_playing()) { + return wav_player_status(); + } return g_status; } diff --git a/src/main.cpp b/src/main.cpp index 0c7de53..d154d45 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,8 +4,10 @@ #include "audio_test.h" #include "pins.h" #include "sd_info.h" +#include "sd_put.h" #include "touch.h" #include "ui.h" +#include "wav_list.h" static constexpr uint32_t DRAW_BUF_SIZE = (SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8)); @@ -13,7 +15,7 @@ static uint32_t draw_buf[DRAW_BUF_SIZE / 4]; static uint32_t last_refresh_ms = 0; static uint32_t last_btn_ui_ms = 0; -static constexpr uint32_t REFRESH_MS = 3000; +static constexpr uint32_t REFRESH_MS = 4000; static void refresh_sd_ui() { SdCardInfo info = sd_read_info(); @@ -24,22 +26,27 @@ static void refresh_sd_ui() { } else { Serial.printf("Type: %s FS: %s Cap: %s\n", info.type, info.filesystem, format_bytes(info.card_bytes).c_str()); + if (!audio_is_playing()) { + wav_list_scan(); + ui_refresh_lists(); + } } ui_update(info); } void setup() { - Serial.begin(115200); + Serial.setRxBufferSize(16384); + Serial.begin(460800); delay(200); Serial.println(); - Serial.printf("LVGL %d.%d.%d | CYD TF + Speaker\n", lv_version_major(), + Serial.printf("LVGL %d.%d.%d | CYD Beep + WAV\n", lv_version_major(), lv_version_minor(), lv_version_patch()); + Serial.println("BOOT_OK"); pinMode(TFT_BL_PIN, OUTPUT); digitalWrite(TFT_BL_PIN, HIGH); - // 先触摸再 SD:触控 SoftSPI 与 SD VSPI 分脚,映射勿改 touch.cpp touch_init(); audio_init(); @@ -66,20 +73,31 @@ void setup() { } void loop() { + const bool playing = audio_is_playing(); + + if (!playing && sd_put_poll()) { + refresh_sd_ui(); + } + lv_timer_handler(); lv_tick_inc(5); audio_tick(); const uint32_t now = millis(); - if (now - last_refresh_ms >= REFRESH_MS) { + if (!playing && now - last_refresh_ms >= REFRESH_MS) { last_refresh_ms = now; refresh_sd_ui(); } if (now - last_btn_ui_ms >= 200) { last_btn_ui_ms = now; + static bool was_playing = false; ui_refresh_audio_buttons(); + if (playing != was_playing) { + was_playing = playing; + ui_refresh_lists(); + } } - delay(5); + delay(playing ? 2 : 5); } diff --git a/src/sd_info.cpp b/src/sd_info.cpp index 4a69ec2..88f9b53 100644 --- a/src/sd_info.cpp +++ b/src/sd_info.cpp @@ -21,7 +21,7 @@ static const char *card_type_name(uint8_t t) { static const char *detect_filesystem() { File root = SD.open("/"); if (!root) { - return "—"; + return "-"; } root.close(); @@ -86,7 +86,7 @@ SdCardInfo sd_read_info() { info.mounted = false; info.present = false; info.type = "NONE"; - info.filesystem = "—"; + info.filesystem = "-"; info.card_bytes = 0; info.total_bytes = 0; info.used_bytes = 0; diff --git a/src/sd_put.cpp b/src/sd_put.cpp new file mode 100644 index 0000000..2cd2623 --- /dev/null +++ b/src/sd_put.cpp @@ -0,0 +1,121 @@ +#include "sd_put.h" +#include "sd_info.h" + +#include + +static String read_line_timeout(uint32_t timeout_ms) { + String line; + const uint32_t start = millis(); + while (millis() - start < timeout_ms) { + while (Serial.available()) { + const char c = (char)Serial.read(); + if (c == '\n' || c == '\r') { + if (line.length()) { + return line; + } + continue; + } + line += c; + if (line.length() > 200) { + return line; + } + } + delay(1); + } + return line; +} + +bool sd_put_poll() { + if (!Serial.available()) { + return false; + } + + // 只在行首是 #PUT 时进入,避免吃掉普通日志 + if (Serial.peek() != '#') { + // 丢弃非协议噪声到换行,防止堵死 + while (Serial.available() && Serial.peek() != '#') { + const int c = Serial.read(); + if (c == '\n' || c == '\r') { + break; + } + } + return false; + } + + const String line = read_line_timeout(500); + if (!line.startsWith("#PUT ")) { + return false; + } + + // #PUT /paomo.mp3 4143009 + int sp1 = line.indexOf(' ', 5); + if (sp1 < 0) { + Serial.println("PUT_ERR bad args"); + return true; + } + const String path = line.substring(5, sp1); + const uint32_t size = (uint32_t)line.substring(sp1 + 1).toInt(); + if (path.length() < 2 || path[0] != '/' || size == 0 || size > 64UL * 1024UL * 1024UL) { + Serial.println("PUT_ERR bad path/size"); + return true; + } + + if (!sd_init()) { + Serial.println("PUT_ERR sd"); + return true; + } + + if (SD.exists(path.c_str())) { + SD.remove(path.c_str()); + } + + File f = SD.open(path.c_str(), FILE_WRITE); + if (!f) { + Serial.println("PUT_ERR open"); + return true; + } + + Serial.println("READY"); + Serial.flush(); + + // 窗口协议:每收满一块回 ACK,避免 RX 溢出 + constexpr size_t k_win = 4096; + uint8_t buf[k_win]; + uint32_t got = 0; + while (got < size) { + const uint32_t need = size - got; + const size_t chunk = need > k_win ? k_win : (size_t)need; + size_t n = 0; + const uint32_t wait_start = millis(); + while (n < chunk && millis() - wait_start < 30000) { + if (!Serial.available()) { + delay(1); + continue; + } + Serial.setTimeout(50); + const int r = Serial.readBytes(buf + n, chunk - n); + if (r > 0) { + n += (size_t)r; + } + } + if (n == 0) { + f.close(); + SD.remove(path.c_str()); + Serial.println("PUT_ERR timeout"); + return true; + } + if (f.write(buf, n) != n) { + f.close(); + SD.remove(path.c_str()); + Serial.println("PUT_ERR write"); + return true; + } + got += n; + Serial.printf("ACK %u\n", (unsigned)got); + Serial.flush(); + } + f.flush(); + f.close(); + Serial.println("DONE"); + return true; +} diff --git a/src/ui.cpp b/src/ui.cpp index c68fe35..a4d4a03 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1,11 +1,12 @@ #include "ui.h" #include "audio_test.h" #include "pins.h" +#include "wav_list.h" #include #include +#include -// 紧凑横屏仪表:顶栏状态 → 容量主信息 → 底栏操作(触控区保持大) static constexpr uint32_t COL_BG = 0x0A1016; static constexpr uint32_t COL_INK = 0xEAF0F6; static constexpr uint32_t COL_MUTED = 0x7A8B9C; @@ -17,17 +18,21 @@ static constexpr uint32_t COL_BTN_OFF = 0x243140; static constexpr uint32_t COL_BTN_ON = 0x1B5E4A; static constexpr uint32_t COL_BTN_PLAY = 0x1A4A6E; static constexpr uint32_t COL_BTN_STOP = 0x8B2E2E; +static constexpr uint32_t COL_ROW = 0x15202C; +static constexpr uint32_t COL_ROW_SEL = 0x1E3A4A; -static lv_obj_t *lbl_title; static lv_obj_t *lbl_chip; -static lv_obj_t *lbl_capacity; -static lv_obj_t *lbl_meta; static lv_obj_t *lbl_audio; -static lv_obj_t *bar_usage; +static lv_obj_t *lbl_vol; +static lv_obj_t *slider_vol; +static lv_obj_t *tabview; +static lv_obj_t *list_beep; +static lv_obj_t *list_wav; static lv_obj_t *btn_speaker; static lv_obj_t *btn_play; static lv_obj_t *lbl_speaker; static lv_obj_t *lbl_play; +static uint8_t g_tab = 0; static void style_btn(lv_obj_t *btn, uint32_t bg) { lv_obj_set_style_radius(btn, 10, 0); @@ -38,6 +43,29 @@ static void style_btn(lv_obj_t *btn, uint32_t bg) { lv_obj_set_style_pad_all(btn, 0, 0); } +static void sync_vol_label() { + if (!lbl_vol) { + return; + } + char buf[16]; + snprintf(buf, sizeof(buf), "%u%%", (unsigned)audio_get_volume()); + lv_label_set_text(lbl_vol, buf); +} + +static void style_row(lv_obj_t *row, bool selected, bool playing) { + uint32_t bg = selected ? COL_ROW_SEL : COL_ROW; + if (playing) { + bg = COL_BTN_PLAY; + } + lv_obj_set_style_bg_color(row, lv_color_hex(bg), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_radius(row, 8, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_pad_left(row, 10, 0); + lv_obj_set_style_pad_right(row, 10, 0); +} + static void on_speaker_clicked(lv_event_t *e) { LV_UNUSED(e); audio_set_enabled(!audio_is_enabled()); @@ -48,33 +76,159 @@ static void on_play_clicked(lv_event_t *e) { LV_UNUSED(e); if (audio_is_playing()) { audio_stop(); + } else if (g_tab == 1) { + const int8_t sel = wav_list_selected(); + if (sel >= 0) { + audio_play_wav_index((uint8_t)sel); + } } else { - audio_play_music(); + audio_play_selected(); } ui_refresh_audio_buttons(); + ui_refresh_lists(); +} + +static void on_vol_changed(lv_event_t *e) { + lv_obj_t *slider = (lv_obj_t *)lv_event_get_target(e); + audio_set_volume((uint8_t)lv_slider_get_value(slider)); + sync_vol_label(); +} + +static void on_beep_clicked(lv_event_t *e) { + const int idx = (int)(intptr_t)lv_event_get_user_data(e); + if (idx < 0 || idx >= (int)sound_count()) { + return; + } + audio_play_sound((uint8_t)idx); + ui_refresh_audio_buttons(); + ui_refresh_lists(); +} + +static void on_wav_clicked(lv_event_t *e) { + const int idx = (int)(intptr_t)lv_event_get_user_data(e); + if (idx < 0 || idx >= (int)wav_list_count()) { + return; + } + audio_play_wav_index((uint8_t)idx); + ui_refresh_audio_buttons(); + ui_refresh_lists(); +} + +static void on_tab_changed(lv_event_t *e) { + lv_obj_t *tv = (lv_obj_t *)lv_event_get_target(e); + g_tab = (uint8_t)lv_tabview_get_tab_active(tv); + ui_refresh_lists(); +} + +static void fill_list_beep() { + if (!list_beep) { + return; + } + lv_obj_clean(list_beep); + const uint8_t n = sound_count(); + const int8_t sel = sound_selected(); + const bool playing = audio_is_playing() && g_tab == 0; + + for (uint8_t i = 0; i < n; i++) { + lv_obj_t *row = lv_button_create(list_beep); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, 32); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(row, on_beep_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)i); + style_row(row, sel == (int8_t)i, playing && sel == (int8_t)i); + + lv_obj_t *lbl = lv_label_create(row); + char line[36]; + snprintf(line, sizeof(line), "%u %s", (unsigned)(i + 1), sound_name(i)); + lv_label_set_text(lbl, line); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_INK), 0); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 0, 0); + } +} + +static void fill_list_wav() { + if (!list_wav) { + return; + } + lv_obj_clean(list_wav); + const uint8_t n = wav_list_count(); + if (n == 0) { + lv_obj_t *empty = lv_label_create(list_wav); + lv_label_set_text(empty, "No .wav on TF\nPut in / or /wav"); + lv_obj_set_style_text_font(empty, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(empty, lv_color_hex(COL_MUTED), 0); + return; + } + + const int8_t sel = wav_list_selected(); + const bool playing = audio_is_playing() && g_tab == 1; + + for (uint8_t i = 0; i < n; i++) { + const WavTrack *t = wav_list_get(i); + if (!t) { + continue; + } + lv_obj_t *row = lv_button_create(list_wav); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, 32); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(row, on_wav_clicked, LV_EVENT_CLICKED, (void *)(intptr_t)i); + style_row(row, sel == (int8_t)i, playing && sel == (int8_t)i); + + lv_obj_t *lbl = lv_label_create(row); + char line[40]; + snprintf(line, sizeof(line), "%u %s", (unsigned)(i + 1), t->name); + lv_label_set_text(lbl, line); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_INK), 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_width(lbl, lv_pct(100)); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 0, 0); + } +} + +void ui_refresh_lists() { + fill_list_beep(); + fill_list_wav(); +} + +uint8_t ui_active_tab() { + return g_tab; } void ui_refresh_audio_buttons() { if (lbl_speaker) { - char buf[24]; - snprintf(buf, sizeof(buf), "%s %u%%", - audio_is_enabled() ? "ON" : "OFF", (unsigned)audio_get_volume()); - lv_label_set_text(lbl_speaker, buf); + lv_label_set_text(lbl_speaker, audio_is_enabled() ? "ON" : "OFF"); } if (btn_speaker) { style_btn(btn_speaker, audio_is_enabled() ? COL_BTN_ON : COL_BTN_OFF); } - if (lbl_play) { lv_label_set_text(lbl_play, audio_is_playing() ? "STOP" : "PLAY"); } if (btn_play) { style_btn(btn_play, audio_is_playing() ? COL_BTN_STOP : COL_BTN_PLAY); } - if (lbl_audio) { lv_label_set_text(lbl_audio, audio_last_status()); } + if (slider_vol && (int32_t)audio_get_volume() != lv_slider_get_value(slider_vol)) { + lv_slider_set_value(slider_vol, audio_get_volume(), LV_ANIM_OFF); + } + sync_vol_label(); +} + +static void style_scroll_list(lv_obj_t *list) { + lv_obj_set_size(list, lv_pct(100), lv_pct(100)); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(list, 0, 0); + lv_obj_set_style_pad_all(list, 4, 0); + lv_obj_set_style_pad_row(list, 4, 0); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(list, LV_DIR_VER); + lv_obj_add_flag(list, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(list, LV_OBJ_FLAG_CLICKABLE); } void ui_create() { @@ -83,72 +237,78 @@ void ui_create() { lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE); - // —— 顶栏 —— - lbl_title = lv_label_create(scr); + lv_obj_t *lbl_title = lv_label_create(scr); lv_label_set_text(lbl_title, "CYD"); lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_20, 0); lv_obj_set_style_text_color(lbl_title, lv_color_hex(COL_ACCENT), 0); - lv_obj_set_pos(lbl_title, 14, 10); + lv_obj_set_pos(lbl_title, 12, 4); lbl_chip = lv_label_create(scr); - lv_label_set_text(lbl_chip, "—"); + lv_label_set_text(lbl_chip, "-"); lv_obj_set_style_text_font(lbl_chip, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_MUTED), 0); - lv_obj_align(lbl_chip, LV_ALIGN_TOP_RIGHT, -14, 14); + lv_obj_align(lbl_chip, LV_ALIGN_TOP_RIGHT, -12, 8); - lv_obj_t *rule = lv_obj_create(scr); - lv_obj_set_size(rule, UI_HOR_RES - 28, 1); - lv_obj_set_pos(rule, 14, 40); - lv_obj_set_style_bg_color(rule, lv_color_hex(COL_LINE), 0); - lv_obj_set_style_bg_opa(rule, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(rule, 0, 0); - lv_obj_set_style_radius(rule, 0, 0); - lv_obj_clear_flag(rule, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(rule, LV_OBJ_FLAG_CLICKABLE); + lv_obj_t *vol_cap = lv_label_create(scr); + lv_label_set_text(vol_cap, "VOL"); + lv_obj_set_style_text_font(vol_cap, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(vol_cap, lv_color_hex(COL_MUTED), 0); + lv_obj_set_pos(vol_cap, 12, 30); - // —— 主信息:容量 —— - lv_obj_t *cap_hint = lv_label_create(scr); - lv_label_set_text(cap_hint, "CAPACITY"); - lv_obj_set_style_text_font(cap_hint, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(cap_hint, lv_color_hex(COL_MUTED), 0); - lv_obj_set_pos(cap_hint, 14, 52); + slider_vol = lv_slider_create(scr); + lv_obj_set_size(slider_vol, UI_HOR_RES - 12 - 56 - 44, 14); + lv_obj_set_pos(slider_vol, 52, 30); + lv_slider_set_range(slider_vol, 0, 100); + lv_slider_set_value(slider_vol, audio_get_volume(), LV_ANIM_OFF); + lv_obj_set_style_bg_color(slider_vol, lv_color_hex(COL_LINE), LV_PART_MAIN); + lv_obj_set_style_bg_opa(slider_vol, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(slider_vol, 4, LV_PART_MAIN); + lv_obj_set_style_bg_color(slider_vol, lv_color_hex(COL_OK), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(slider_vol, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(slider_vol, lv_color_hex(COL_INK), LV_PART_KNOB); + lv_obj_set_style_pad_all(slider_vol, 4, LV_PART_KNOB); + lv_obj_add_event_cb(slider_vol, on_vol_changed, LV_EVENT_VALUE_CHANGED, nullptr); - lbl_capacity = lv_label_create(scr); - lv_label_set_text(lbl_capacity, "—"); - lv_obj_set_style_text_font(lbl_capacity, &lv_font_montserrat_20, 0); - lv_obj_set_style_text_color(lbl_capacity, lv_color_hex(COL_INK), 0); - lv_obj_set_pos(lbl_capacity, 14, 72); - - lbl_meta = lv_label_create(scr); - lv_label_set_text(lbl_meta, "Type — · FS —"); - lv_obj_set_style_text_font(lbl_meta, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(lbl_meta, lv_color_hex(COL_MUTED), 0); - lv_obj_set_pos(lbl_meta, 14, 100); - - bar_usage = lv_bar_create(scr); - lv_obj_set_size(bar_usage, UI_HOR_RES - 28, 8); - lv_obj_set_pos(bar_usage, 14, 124); - lv_bar_set_range(bar_usage, 0, 100); - lv_bar_set_value(bar_usage, 0, LV_ANIM_OFF); - lv_obj_set_style_radius(bar_usage, 4, 0); - lv_obj_set_style_bg_color(bar_usage, lv_color_hex(COL_LINE), 0); - lv_obj_set_style_bg_color(bar_usage, lv_color_hex(COL_ACCENT), LV_PART_INDICATOR); - lv_obj_set_style_radius(bar_usage, 4, LV_PART_INDICATOR); + lbl_vol = lv_label_create(scr); + lv_label_set_text(lbl_vol, "40%"); + lv_obj_set_style_text_font(lbl_vol, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl_vol, lv_color_hex(COL_INK), 0); + lv_obj_align(lbl_vol, LV_ALIGN_TOP_RIGHT, -12, 30); lbl_audio = lv_label_create(scr); lv_label_set_text(lbl_audio, "Speaker OFF"); lv_obj_set_style_text_font(lbl_audio, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(lbl_audio, lv_color_hex(COL_MUTED), 0); - lv_obj_set_pos(lbl_audio, 14, 140); + lv_obj_set_pos(lbl_audio, 12, 48); - // —— 底栏双按钮(大命中区)—— - const int btn_y = 168; - const int btn_h = 58; + // Tabs: Beep | WAV + tabview = lv_tabview_create(scr); + lv_obj_set_pos(tabview, 8, 66); + lv_obj_set_size(tabview, UI_HOR_RES - 16, 110); + lv_tabview_set_tab_bar_size(tabview, 28); + lv_obj_set_style_bg_color(tabview, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(tabview, on_tab_changed, LV_EVENT_VALUE_CHANGED, nullptr); + + lv_obj_t *tab_beep = lv_tabview_add_tab(tabview, "Beep"); + lv_obj_t *tab_wav = lv_tabview_add_tab(tabview, "WAV"); + lv_obj_set_style_bg_color(tab_beep, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_color(tab_wav, lv_color_hex(COL_BG), 0); + lv_obj_set_style_pad_all(tab_beep, 0, 0); + lv_obj_set_style_pad_all(tab_wav, 0, 0); + + list_beep = lv_obj_create(tab_beep); + style_scroll_list(list_beep); + + list_wav = lv_obj_create(tab_wav); + style_scroll_list(list_wav); + + const int btn_y = 184; + const int btn_h = 48; const int gap = 10; - const int btn_w = (UI_HOR_RES - 28 - gap) / 2; + const int btn_w = (UI_HOR_RES - 24 - gap) / 2; btn_speaker = lv_button_create(scr); - lv_obj_set_pos(btn_speaker, 14, btn_y); + lv_obj_set_pos(btn_speaker, 12, btn_y); lv_obj_set_size(btn_speaker, btn_w, btn_h); lv_obj_add_flag(btn_speaker, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(btn_speaker, on_speaker_clicked, LV_EVENT_CLICKED, nullptr); @@ -158,70 +318,48 @@ void ui_create() { lv_label_set_text(spk_cap, "SPEAKER"); lv_obj_set_style_text_font(spk_cap, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(spk_cap, lv_color_hex(COL_MUTED), 0); - lv_obj_align(spk_cap, LV_ALIGN_TOP_MID, 0, 8); + lv_obj_align(spk_cap, LV_ALIGN_TOP_MID, 0, 4); lbl_speaker = lv_label_create(btn_speaker); - lv_label_set_text(lbl_speaker, "OFF 20%"); + lv_label_set_text(lbl_speaker, "OFF"); lv_obj_set_style_text_font(lbl_speaker, &lv_font_montserrat_16, 0); lv_obj_set_style_text_color(lbl_speaker, lv_color_hex(COL_INK), 0); - lv_obj_align(lbl_speaker, LV_ALIGN_BOTTOM_MID, 0, -10); + lv_obj_align(lbl_speaker, LV_ALIGN_BOTTOM_MID, 0, -6); btn_play = lv_button_create(scr); - lv_obj_set_pos(btn_play, 14 + btn_w + gap, btn_y); + lv_obj_set_pos(btn_play, 12 + btn_w + gap, btn_y); lv_obj_set_size(btn_play, btn_w, btn_h); lv_obj_add_flag(btn_play, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(btn_play, on_play_clicked, LV_EVENT_CLICKED, nullptr); style_btn(btn_play, COL_BTN_PLAY); lv_obj_t *play_cap = lv_label_create(btn_play); - lv_label_set_text(play_cap, "MUSIC"); + lv_label_set_text(play_cap, "SOUND"); lv_obj_set_style_text_font(play_cap, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(play_cap, lv_color_hex(COL_MUTED), 0); - lv_obj_align(play_cap, LV_ALIGN_TOP_MID, 0, 8); + lv_obj_align(play_cap, LV_ALIGN_TOP_MID, 0, 4); lbl_play = lv_label_create(btn_play); lv_label_set_text(lbl_play, "PLAY"); lv_obj_set_style_text_font(lbl_play, &lv_font_montserrat_16, 0); lv_obj_set_style_text_color(lbl_play, lv_color_hex(COL_INK), 0); - lv_obj_align(lbl_play, LV_ALIGN_BOTTOM_MID, 0, -10); + lv_obj_align(lbl_play, LV_ALIGN_BOTTOM_MID, 0, -6); ui_refresh_audio_buttons(); + ui_refresh_lists(); } -void ui_set_touch_debug(int16_t /*lx*/, int16_t /*ly*/, bool /*down*/) { - // 触控已标定;界面不再显示调试光标 -} +void ui_set_touch_debug(int16_t /*lx*/, int16_t /*ly*/, bool /*down*/) {} void ui_update(const SdCardInfo &info) { if (!info.mounted || !info.present) { lv_label_set_text(lbl_chip, info.error.length() ? info.error.c_str() : "FAIL"); lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_BAD), 0); - lv_label_set_text(lbl_capacity, "—"); - lv_label_set_text(lbl_meta, "No card"); - lv_bar_set_value(bar_usage, 0, LV_ANIM_OFF); } else { - char chip[32]; - snprintf(chip, sizeof(chip), "OK · %s", info.type); + char chip[40]; + snprintf(chip, sizeof(chip), "OK %s W%u", info.type, (unsigned)wav_list_count()); lv_label_set_text(lbl_chip, chip); lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_OK), 0); - - String cap = format_bytes(info.card_bytes); - lv_label_set_text(lbl_capacity, cap.c_str()); - - char meta[48]; - snprintf(meta, sizeof(meta), "%s · %s · %u files", info.type, - info.filesystem, (unsigned)info.root_files); - lv_label_set_text(lbl_meta, meta); - - int pct = 0; - if (info.total_bytes > 0) { - pct = static_cast((info.used_bytes * 100ULL) / info.total_bytes); - if (pct > 100) { - pct = 100; - } - } - lv_bar_set_value(bar_usage, pct, LV_ANIM_ON); } - ui_refresh_audio_buttons(); } diff --git a/src/wav_list.cpp b/src/wav_list.cpp new file mode 100644 index 0000000..446c250 --- /dev/null +++ b/src/wav_list.cpp @@ -0,0 +1,129 @@ +#include "wav_list.h" +#include "sd_info.h" + +#include +#include + +static WavTrack g_tracks[WAV_MAX]; +static uint8_t g_count = 0; +static int8_t g_selected = -1; + +static bool ends_with_wav(const char *name) { + const size_t n = strlen(name); + if (n < 4) { + return false; + } + const char *e = name + n - 4; + return (e[0] == '.' && + (e[1] == 'w' || e[1] == 'W') && + (e[2] == 'a' || e[2] == 'A') && + (e[3] == 'v' || e[3] == 'V')); +} + +static void make_display_name(const char *filename, char *out, size_t out_len) { + size_t len = strlen(filename); + if (len >= 4 && ends_with_wav(filename)) { + len -= 4; + } + char tmp[WAV_NAME_MAX]; + size_t j = 0; + for (size_t i = 0; i < len && j + 1 < sizeof(tmp); i++) { + const unsigned char c = (unsigned char)filename[i]; + tmp[j++] = (c >= 0x20 && c < 0x7F) ? (char)c : '_'; + } + tmp[j] = '\0'; + if (tmp[0] == '\0') { + snprintf(out, out_len, "wav"); + } else { + snprintf(out, out_len, "%s", tmp); + } +} + +static bool add_track(const char *dir, const char *filename, uint32_t bytes) { + if (g_count >= WAV_MAX || !ends_with_wav(filename)) { + return false; + } + WavTrack &t = g_tracks[g_count]; + if (dir && dir[0] && !(dir[0] == '/' && dir[1] == '\0')) { + snprintf(t.path, sizeof(t.path), "%s/%s", dir, filename); + } else { + snprintf(t.path, sizeof(t.path), "/%s", filename); + } + make_display_name(filename, t.name, sizeof(t.name)); + t.bytes = bytes; + g_count++; + return true; +} + +static void scan_dir(const char *dir) { + File root = SD.open(dir); + if (!root || !root.isDirectory()) { + if (root) { + root.close(); + } + return; + } + File f = root.openNextFile(); + while (f && g_count < WAV_MAX) { + if (!f.isDirectory()) { + const char *name = f.name(); + const char *base = strrchr(name, '/'); + base = base ? base + 1 : name; + add_track(dir, base, (uint32_t)f.size()); + } + f = root.openNextFile(); + } + root.close(); +} + +bool wav_list_scan() { + g_count = 0; + g_selected = -1; + if (!sd_init()) { + return false; + } + scan_dir("/"); + scan_dir("/wav"); + scan_dir("/WAV"); + scan_dir("/sounds"); + scan_dir("/Sounds"); + if (g_count > 0) { + g_selected = 0; + } + Serial.printf("WAV list: %u\n", (unsigned)g_count); + for (uint8_t i = 0; i < g_count; i++) { + Serial.printf(" [%u] %s %uB\n", (unsigned)i, g_tracks[i].path, + (unsigned)g_tracks[i].bytes); + } + return g_count > 0; +} + +uint8_t wav_list_count() { + return g_count; +} + +int8_t wav_list_selected() { + return g_selected; +} + +void wav_list_set_selected(int8_t idx) { + if (idx < 0 || idx >= (int8_t)g_count) { + g_selected = -1; + return; + } + g_selected = idx; +} + +const WavTrack *wav_list_get(uint8_t idx) { + if (idx >= g_count) { + return nullptr; + } + return &g_tracks[idx]; +} + +const char *wav_list_current_path() { + if (g_selected < 0 || g_selected >= (int8_t)g_count) { + return nullptr; + } + return g_tracks[g_selected].path; +} diff --git a/src/wav_player.cpp b/src/wav_player.cpp new file mode 100644 index 0000000..f6efa60 --- /dev/null +++ b/src/wav_player.cpp @@ -0,0 +1,365 @@ +#include "wav_player.h" +#include "pins.h" +#include "sd_info.h" + +#include +#include +#include +#include +#include + +#include +#include + +// DAC2-only;大文件边读边降采样成 8bit mono,避免 "TooBig" + +static constexpr uint32_t k_max_pcm = 120 * 1024; // ~7.5s @16k +static constexpr uint32_t k_max_file = 3 * 1024 * 1024; +static constexpr uint32_t k_target_hz = 22050; + +static const char *g_status = "idle"; +static volatile bool g_playing = false; +static volatile bool g_stop_req = false; +static uint8_t g_volume = 40; + +static uint8_t *g_pcm = nullptr; +static uint32_t g_pcm_len = 0; +static uint32_t g_rate = 16000; + +static TaskHandle_t g_task = nullptr; + +static void release_dac_pins() { + dac_output_disable(DAC_CHANNEL_1); + dac_output_disable(DAC_CHANNEL_2); + pinMode(SPEAKER_PIN, OUTPUT); + digitalWrite(SPEAKER_PIN, LOW); +} + +static void teardown_pcm() { + if (g_pcm) { + free(g_pcm); + g_pcm = nullptr; + } + g_pcm_len = 0; +} + +static uint8_t scale_sample(uint8_t u8) { + int c = (int)u8 - 128; + c = (c * (int)g_volume) / 100; + c = (c * 45) / 100; + int o = 128 + c; + if (o < 0) { + o = 0; + } + if (o > 255) { + o = 255; + } + return (uint8_t)o; +} + +static bool read_u16(File &f, uint16_t &v) { + uint8_t b[2]; + if (f.read(b, 2) != 2) { + return false; + } + v = (uint16_t)b[0] | ((uint16_t)b[1] << 8); + return true; +} + +static bool read_u32(File &f, uint32_t &v) { + uint8_t b[4]; + if (f.read(b, 4) != 4) { + return false; + } + v = (uint32_t)b[0] | ((uint32_t)b[1] << 8) | ((uint32_t)b[2] << 16) | + ((uint32_t)b[3] << 24); + return true; +} + +static bool read_frame_s16(File &f, uint16_t channels, uint16_t bits, int32_t &mono) { + int32_t mix = 0; + for (uint16_t c = 0; c < channels; c++) { + if (bits == 8) { + const int b = f.read(); + if (b < 0) { + return false; + } + mix += (b - 128) * 256; + } else { + uint16_t s16; + if (!read_u16(f, s16)) { + return false; + } + mix += (int16_t)s16; + } + } + if (channels == 2) { + mix /= 2; + } + mono = mix; + return true; +} + +static bool load_wav_pcm(const char *path) { + teardown_pcm(); + + File f = SD.open(path, FILE_READ); + if (!f) { + g_status = "OpenFail"; + return false; + } + const uint32_t fsz = f.size(); + if (fsz > k_max_file) { + f.close(); + g_status = "TooBig"; + Serial.printf("WAV reject size=%u\n", (unsigned)fsz); + return false; + } + + uint32_t tag = 0; + if (!read_u32(f, tag) || tag != 0x46464952) { + f.close(); + g_status = "NotRIFF"; + return false; + } + uint32_t riff_size = 0; + read_u32(f, riff_size); + (void)riff_size; + if (!read_u32(f, tag) || tag != 0x45564157) { + f.close(); + g_status = "NotWAVE"; + return false; + } + + uint16_t audio_fmt = 0, channels = 0, bits = 0; + uint32_t sample_rate = 0; + bool got_fmt = false; + uint32_t data_size = 0; + uint32_t data_pos = 0; + bool got_data = false; + + while (f.available()) { + if (!read_u32(f, tag)) { + break; + } + uint32_t chunk_size = 0; + if (!read_u32(f, chunk_size)) { + break; + } + if (tag == 0x20746d66) { // fmt + uint16_t block_align = 0; + uint32_t byte_rate = 0; + if (!read_u16(f, audio_fmt) || !read_u16(f, channels) || + !read_u32(f, sample_rate) || !read_u32(f, byte_rate) || + !read_u16(f, block_align) || !read_u16(f, bits)) { + f.close(); + g_status = "BadFmt"; + return false; + } + (void)byte_rate; + (void)block_align; + if (chunk_size > 16) { + f.seek(f.position() + (chunk_size - 16)); + } + // WAVE_FORMAT_EXTENSIBLE(0xFFFE) 也常见,当 PCM 用若 bits 合法 + got_fmt = true; + } else if (tag == 0x61746164) { // data + data_pos = f.position(); + data_size = chunk_size; + got_data = true; + break; + } else { + f.seek(f.position() + chunk_size + (chunk_size & 1)); + } + } + + // 1=PCM, 0xFFFE=extensible(很多导出工具) + if (!got_fmt || !got_data || (audio_fmt != 1 && audio_fmt != 0xFFFE)) { + f.close(); + g_status = "NeedPCM"; + Serial.printf("WAV fmt=%u\n", (unsigned)audio_fmt); + return false; + } + if (channels < 1 || channels > 2 || (bits != 8 && bits != 16)) { + f.close(); + g_status = "BadBits"; + return false; + } + if (sample_rate < 4000 || sample_rate > 96000) { + f.close(); + g_status = "BadRate"; + return false; + } + + // 降采样到 <= 22.05k,省 RAM、也更好定时 + uint32_t out_rate = sample_rate; + uint32_t decim = 1; + while (out_rate > k_target_hz) { + out_rate /= 2; + decim *= 2; + } + if (decim < 1) { + decim = 1; + } + + const uint32_t bytes_per_frame = (bits / 8u) * channels; + const uint32_t src_frames = data_size / bytes_per_frame; + uint32_t out_frames = src_frames / decim; + if (out_frames > k_max_pcm) { + out_frames = k_max_pcm; + } + if (out_frames == 0) { + f.close(); + g_status = "Empty"; + return false; + } + + g_pcm = (uint8_t *)malloc(out_frames); + if (!g_pcm) { + f.close(); + g_status = "OOM"; + return false; + } + + f.seek(data_pos); + uint32_t got = 0; + for (uint32_t oi = 0; oi < out_frames; oi++) { + int64_t acc = 0; + uint32_t n = 0; + for (uint32_t d = 0; d < decim; d++) { + int32_t mono = 0; + if (!read_frame_s16(f, channels, bits, mono)) { + break; + } + acc += mono; + n++; + } + if (n == 0) { + break; + } + const int32_t mix = (int32_t)(acc / (int64_t)n); + int v = (mix / 256) + 128; + if (v < 0) { + v = 0; + } + if (v > 255) { + v = 255; + } + g_pcm[got++] = (uint8_t)v; + } + f.close(); + + if (got == 0) { + teardown_pcm(); + g_status = "Empty"; + return false; + } + + g_pcm_len = got; + g_rate = out_rate; + Serial.printf("WAV ok %uB -> %u samp @%u (decim %u)\n", (unsigned)fsz, + (unsigned)g_pcm_len, (unsigned)g_rate, (unsigned)decim); + return true; +} + +static void wav_task(void * /*arg*/) { + for (;;) { + if (!g_playing || !g_pcm || g_pcm_len == 0) { + vTaskDelay(pdMS_TO_TICKS(15)); + continue; + } + + g_stop_req = false; + dac_output_disable(DAC_CHANNEL_1); + dac_output_enable(DAC_CHANNEL_2); + + const uint32_t period_us = 1000000UL / (g_rate ? g_rate : 16000); + int64_t next = esp_timer_get_time(); + + for (uint32_t i = 0; i < g_pcm_len; i++) { + if (g_stop_req) { + break; + } + dac_output_voltage(DAC_CHANNEL_2, scale_sample(g_pcm[i])); + next += (int64_t)period_us; + const int64_t now = esp_timer_get_time(); + const int64_t wait = next - now; + if (wait > 50) { + ets_delay_us((uint32_t)wait); + } else if (wait < -3000) { + next = now; + } + } + + release_dac_pins(); + g_playing = false; + g_status = g_stop_req ? "Stop" : "Done"; + Serial.println(g_status); + } +} + +void wav_player_init() { + release_dac_pins(); + xTaskCreatePinnedToCore(wav_task, "wavdac", 4096, nullptr, 4, &g_task, 0); + g_status = "idle"; + Serial.println("WAV: DAC2-only + downsample"); +} + +void wav_player_set_volume(uint8_t percent) { + if (percent > 100) { + percent = 100; + } + g_volume = percent; +} + +void wav_player_stop() { + if (g_playing) { + g_stop_req = true; + for (int i = 0; i < 40 && g_playing; i++) { + delay(5); + } + } + g_playing = false; + g_stop_req = false; + release_dac_pins(); + g_status = "idle"; +} + +bool wav_player_is_playing() { + return g_playing; +} + +const char *wav_player_status() { + return g_status; +} + +bool wav_player_play_path(const char *path) { + if (!path || !path[0]) { + g_status = "NoFile"; + return false; + } + if (!sd_init()) { + g_status = "SDFail"; + return false; + } + if (!SD.exists(path)) { + g_status = "Missing"; + return false; + } + + wav_player_stop(); + ledcDetachPin(SPEAKER_PIN); + release_dac_pins(); + + if (!load_wav_pcm(path)) { + return false; + } + + g_stop_req = false; + g_playing = true; + g_status = "Play"; + Serial.printf("WAV play %s\n", path); + return true; +} + +void wav_player_tick() {} diff --git a/tools/sd_put.py b/tools/sd_put.py new file mode 100644 index 0000000..72b3fab --- /dev/null +++ b/tools/sd_put.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Upload a file to the CYD TF card over serial (#PUT + ACK window).""" +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +try: + import serial + from serial.tools import list_ports +except ImportError: + print("Need pyserial: pip install pyserial", file=sys.stderr) + sys.exit(1) + +WIN = 4096 + + +def pick_port(preferred: str | None) -> str: + if preferred: + return preferred + ports = list(list_ports.comports()) + for p in ports: + desc = (p.description or "") + (p.hwid or "") + if "USB" in desc.upper() or "UART" in desc.upper() or "CP210" in desc.upper() or "CH340" in desc.upper(): + return p.device + if ports: + return ports[0].device + raise SystemExit("No serial port found") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("file", type=Path, help="Local file to upload") + ap.add_argument("--port", default=None) + ap.add_argument("--baud", type=int, default=460800) + ap.add_argument("--remote", default="/paomo.mp3") + args = ap.parse_args() + + path: Path = args.file + if not path.is_file(): + print(f"Missing file: {path}", file=sys.stderr) + return 1 + + size = path.stat().st_size + port = pick_port(args.port) + print(f"Port {port} {path.name} -> {args.remote} ({size} bytes)", flush=True) + + ser = serial.Serial() + ser.port = port + ser.baudrate = args.baud + ser.timeout = 1 + ser.dtr = False + ser.rts = False + ser.open() + # 硬复位 ESP32 + ser.rts = True + time.sleep(0.1) + ser.rts = False + print("waiting BOOT_OK...", flush=True) + deadline = time.time() + 12 + booted = False + while time.time() < deadline: + line = ser.readline().decode("utf-8", errors="ignore").strip() + if line: + print("<", line, flush=True) + if "BOOT_OK" in line: + booted = True + break + if not booted: + print("warn: BOOT_OK not seen, continuing", flush=True) + time.sleep(0.5) + ser.reset_input_buffer() + + cmd = f"#PUT {args.remote} {size}\n".encode("ascii") + print(f"send {cmd!r}", flush=True) + ser.write(cmd) + ser.flush() + + deadline = time.time() + 20 + while time.time() < deadline: + line = ser.readline().decode("utf-8", errors="ignore").strip() + if line: + print("<", line, flush=True) + if line == "READY": + break + if line.startswith("PUT_ERR"): + return 2 + else: + print("Timeout waiting READY", file=sys.stderr, flush=True) + return 3 + + sent = 0 + t0 = time.time() + with path.open("rb") as f: + while sent < size: + chunk = f.read(min(WIN, size - sent)) + if not chunk: + break + ser.write(chunk) + ser.flush() + sent += len(chunk) + + # 等 ACK + ack_deadline = time.time() + 30 + acked = False + while time.time() < ack_deadline: + line = ser.readline().decode("utf-8", errors="ignore").strip() + if not line: + continue + print("<", line, flush=True) + if line.startswith("PUT_ERR"): + return 4 + if line.startswith("ACK "): + try: + got = int(line.split()[1]) + except (IndexError, ValueError): + continue + if got >= sent: + acked = True + break + if line == "DONE" and sent >= size: + print("OK", flush=True) + return 0 + if not acked: + print(f"Timeout waiting ACK after {sent}", file=sys.stderr, flush=True) + return 4 + + if sent % (256 * 1024) < WIN or sent == size: + rate = sent / max(time.time() - t0, 0.001) + print(f"sent {sent}/{size} ({rate/1024:.1f} KB/s)", flush=True) + + deadline = time.time() + 30 + while time.time() < deadline: + line = ser.readline().decode("utf-8", errors="ignore").strip() + if line: + print("<", line, flush=True) + if line == "DONE": + print("OK", flush=True) + return 0 + if line.startswith("PUT_ERR"): + return 5 + + print("Timeout waiting DONE", file=sys.stderr, flush=True) + return 6 + + +if __name__ == "__main__": + sys.exit(main())