diff --git a/Apps/Mp3Player/main/Source/main.c b/Apps/Mp3Player/main/Source/main.c index 8970779..a309f00 100644 --- a/Apps/Mp3Player/main/Source/main.c +++ b/Apps/Mp3Player/main/Source/main.c @@ -124,6 +124,15 @@ static void mp3_playback_task(void* arg) { while (ctx->state != STATE_IDLE) { if (ctx->state == STATE_PAUSED) { + if (ctx->sample_rate != 0) { + // Reset I2S immediately on pause to stop DMA loops + device_lock(ctx->i2s_dev); + i2s_controller_reset(ctx->i2s_dev); + device_unlock(ctx->i2s_dev); + // Clear configuration cache to force reconfig on resume + ctx->sample_rate = 0; + ctx->channels = 0; + } vTaskDelay(pdMS_TO_TICKS(50)); continue; } @@ -224,12 +233,19 @@ static void mp3_playback_task(void* arg) { tt_lvgl_unlock(); } - taskYIELD(); + // taskYIELD removed to avoid audio gaps - decoder loop is fast enough } fclose(ctx->file); ctx->file = NULL; + // Reset I2S controller to clean up DMA channels and stop looping noise + if (ctx->i2s_dev) { + device_lock(ctx->i2s_dev); + i2s_controller_reset(ctx->i2s_dev); + device_unlock(ctx->i2s_dev); + } + ESP_LOGI(TAG, "Playback task finished"); tt_lvgl_lock(portMAX_DELAY); @@ -249,7 +265,7 @@ static void on_play_pause_click(lv_event_t* e) { if (ctx->state == STATE_IDLE) { ctx->state = STATE_PLAYING; update_ui(ctx); - xTaskCreate(mp3_playback_task, "mp3_play", 4096, ctx, 5, &ctx->playback_task_handle); + xTaskCreate(mp3_playback_task, "mp3_play", 6144, ctx, 6, &ctx->playback_task_handle); } else if (ctx->state == STATE_PLAYING) { ctx->state = STATE_PAUSED; update_ui(ctx); @@ -408,6 +424,13 @@ static void onHideApp(AppHandle app, void* data) { vTaskDelay(pdMS_TO_TICKS(10)); } + // Reset I2S to ensure DMA channel is stopped + if (g_ctx.i2s_dev) { + device_lock(g_ctx.i2s_dev); + i2s_controller_reset(g_ctx.i2s_dev); + device_unlock(g_ctx.i2s_dev); + } + if (g_ctx.input_buf) { free(g_ctx.input_buf); g_ctx.input_buf = NULL; diff --git a/Apps/VoiceRecorder/main/Source/main.c b/Apps/VoiceRecorder/main/Source/main.c new file mode 100644 index 0000000..ec3b354 --- /dev/null +++ b/Apps/VoiceRecorder/main/Source/main.c @@ -0,0 +1,805 @@ +/** + * Voice Recorder App for Tactility OS + * + * Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/) + * and plays them back. + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#define TAG "VoiceRecorder" + +#define SAMPLE_RATE 16000 +#define BITS_PER_SAMPLE 16 +#define CHUNK_SAMPLES 512 +#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes +#define AUDIO_BUF_SIZE 4096 + +#define MAX_MEMOS 50 +#define MAX_FILENAME_LEN 64 + +typedef enum { + STATE_IDLE, + STATE_RECORDING, + STATE_PLAYING, + STATE_PAUSED +} AudioState; + +typedef struct { + struct Device* i2s_dev; + uint8_t* audio_buf; + AudioState state; + int volume; + + // Memo directory listing + char memos[MAX_MEMOS][MAX_FILENAME_LEN]; + int memo_count; + int selected_index; + + // UI elements + lv_obj_t* lbl_status; + lv_obj_t* bar_progress; + lv_obj_t* lst_memos; + lv_obj_t* btn_record; + lv_obj_t* btn_play_pause; + lv_obj_t* btn_stop; + lv_obj_t* btn_delete; + + TaskHandle_t task_handle; +} AppCtx; + +typedef struct __attribute__((packed)) { + char riff[4]; // "RIFF" + uint32_t overall_size; // file size - 8 + char wave[4]; // "WAVE" + char fmt_chunk_marker[4]; // "fmt " + uint32_t length_of_fmt; // 16 for PCM + uint16_t format_type; // 1 for PCM + uint16_t channels; // 1 for mono + uint32_t sample_rate; // 16000 + uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8) + uint16_t block_align; // channels * (bits_per_sample / 8) + uint16_t bits_per_sample; // 16 + char data_chunk_header[4]; // "data" + uint32_t data_size; // data size in bytes +} WavHeader; + +static AppCtx g_ctx; + +/* ─── Forward Decls ─── */ +static void update_ui(AppCtx* ctx); +static void refresh_memo_list(AppCtx* ctx); +static void on_memo_selected(lv_event_t* e); + +/* ─── WAV Header Writer ─── */ +static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) { + WavHeader header; + memcpy(header.riff, "RIFF", 4); + header.overall_size = 36 + data_size; + memcpy(header.wave, "WAVE", 4); + memcpy(header.fmt_chunk_marker, "fmt ", 4); + header.length_of_fmt = 16; + header.format_type = 1; // PCM + header.channels = channels; + header.sample_rate = sample_rate; + header.byterate = sample_rate * channels * (bits_per_sample / 8); + header.block_align = channels * (bits_per_sample / 8); + header.bits_per_sample = bits_per_sample; + memcpy(header.data_chunk_header, "data", 4); + header.data_size = data_size; + + fwrite(&header, 1, sizeof(WavHeader), f); +} + +/* ─── Next Filename Finder ─── */ +static void get_next_memo_filename(char* out_filename, size_t max_len) { + char datetime_buf[32]; + time_t rawtime = time(NULL); + struct tm timeinfo; + localtime_r(&rawtime, &timeinfo); + + // Format: YYYYMMDD_HHMMSS (e.g., 20260629_232202) + strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo); + + // Default unique name candidate + snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf); + + char filepath[256]; + snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename); + + struct stat st; + if (stat(filepath, &st) != 0) { + // File does not exist, safe to use + return; + } + + // File exists, let's search for a unique name by appending _N + int suffix = 1; + while (1) { + snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix); + snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename); + if (stat(filepath, &st) != 0) { + // Found a unique name! + return; + } + suffix++; + } +} + +/* ─── Record Task ─── */ +static void record_task(void* arg) { + AppCtx* ctx = (AppCtx*)arg; + char filepath[256]; + char filename[64]; + + get_next_memo_filename(filename, sizeof(filename)); + snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", filename); + + FILE* f = fopen(filepath, "wb"); + if (f == NULL) { + ESP_LOGE(TAG, "Failed to open file for recording: %s", filepath); + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + lv_label_set_text(ctx->lbl_status, "Error: Cannot write file"); + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + // Write default header + write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0); + + /* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics) + * even when we want mono file. Working MicroPython uses I2S.STEREO for RX + * then extracts left channel. If we request MONO, driver uses MONO slot mode + * but ES7210 still sends 2 slots, causing channel mismatch / low pitch. + * So request STEREO from I2S and downmix to mono in the loop. + * See audio_util.py: i2s = I2S(..., format=I2S.STEREO, rate=16000) */ + struct I2sConfig cfg = { + .communication_format = I2S_FORMAT_STAND_I2S, + .sample_rate = SAMPLE_RATE, + .bits_per_sample = BITS_PER_SAMPLE, + .channel_left = 0, + .channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below + }; + + device_lock(ctx->i2s_dev); + error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg); + device_unlock(ctx->i2s_dev); + + if (err != ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set I2S config: %d", err); + fclose(f); + unlink(filepath); + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + lv_label_set_text(ctx->lbl_status, "Error: Mic Config Failed"); + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "Recording started -> %s", filepath); + + size_t total_written = 0; + uint32_t start_time = xTaskGetTickCount(); + + while (ctx->state == STATE_RECORDING) { + size_t bytes_read = 0; + err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100)); + if (err == ERROR_NONE && bytes_read > 0) { + // ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample) + // Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2] + // CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half + size_t mono_bytes = bytes_read / 2; + // In-place downmix using separate temp? Use static tmp buffer via audio_buf + mono offset + // Easiest: convert in-place from end to start to avoid overwrite + // Stereo layout: L0 R0 L1 R1 ... each 2 bytes, total bytes_read + // We want L0 L1 L2 ... -> mono_bytes + // Do backward copy: dest index = mono_bytes-2, src left = bytes_read-4 + // This avoids needing second buffer + for (int src = (int)bytes_read - 4, dst = (int)mono_bytes - 2; src >= 0 && dst >= 0; src -= 4, dst -= 2) { + ctx->audio_buf[dst] = ctx->audio_buf[src]; + ctx->audio_buf[dst+1] = ctx->audio_buf[src+1]; + } + size_t written = fwrite(ctx->audio_buf, 1, mono_bytes, f); + if (written != mono_bytes) { + ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)mono_bytes); + break; + } + total_written += written; + } + + // Update elapsed time in UI + uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ; + char status_buf[64]; + snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60)); + + // Animate progress bar using modulo + int bar_val = (int)(((xTaskGetTickCount() - start_time) * 100) / (configTICK_RATE_HZ * 300)); // 5 min max + + tt_lvgl_lock(portMAX_DELAY); + lv_label_set_text(ctx->lbl_status, status_buf); + lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF); + tt_lvgl_unlock(); + } + + // Rewrite WAV header with actual recorded size + fseek(f, 0, SEEK_SET); + write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written); + fclose(f); + + ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written); + + // Reset I2S controller to stop/release DMA channel + device_lock(ctx->i2s_dev); + i2s_controller_reset(ctx->i2s_dev); + device_unlock(ctx->i2s_dev); + + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + refresh_memo_list(ctx); + + // Select the new file automatically + ctx->selected_index = -1; + for (int i = 0; i < ctx->memo_count; i++) { + if (strcmp(ctx->memos[i], filename) == 0) { + ctx->selected_index = i; + break; + } + } + + update_ui(ctx); + tt_lvgl_unlock(); + + ctx->task_handle = NULL; + vTaskDelete(NULL); +} + +/* ─── Playback Task ─── */ +static void play_task(void* arg) { + AppCtx* ctx = (AppCtx*)arg; + if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) { + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + char filepath[256]; + snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", ctx->memos[ctx->selected_index]); + + FILE* f = fopen(filepath, "rb"); + if (f == NULL) { + ESP_LOGE(TAG, "Failed to open file for playback: %s", filepath); + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + lv_label_set_text(ctx->lbl_status, "Error: File not found"); + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + // Read and parse WAV header + WavHeader header; + if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) { + ESP_LOGE(TAG, "Failed to read WAV header"); + fclose(f); + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + lv_label_set_text(ctx->lbl_status, "Error: Invalid WAV Header"); + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + // Verify file structure + if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) { + ESP_LOGE(TAG, "Invalid WAV format"); + fclose(f); + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + lv_label_set_text(ctx->lbl_status, "Error: Unsupported WAV"); + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + /* Configure I2S based on file metadata */ + struct I2sConfig cfg = { + .communication_format = I2S_FORMAT_STAND_I2S, + .sample_rate = header.sample_rate, + .bits_per_sample = header.bits_per_sample, + .channel_left = 0, + .channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE + }; + + device_lock(ctx->i2s_dev); + error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg); + device_unlock(ctx->i2s_dev); + + if (err != ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err); + fclose(f); + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + lv_label_set_text(ctx->lbl_status, "Error: Audio Setup Failed"); + update_ui(ctx); + tt_lvgl_unlock(); + ctx->task_handle = NULL; + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes", + filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size); + + size_t total_played = 0; + size_t data_size = header.data_size; + if (data_size == 0) { + fseek(f, 0, SEEK_END); + data_size = ftell(f) - sizeof(WavHeader); + fseek(f, sizeof(WavHeader), SEEK_SET); + } + + bool i2s_configured = true; + + while (total_played < data_size && ctx->state != STATE_IDLE) { + if (ctx->state == STATE_PAUSED) { + if (i2s_configured) { + // Reset I2S controller to stop DMA immediately when pausing + device_lock(ctx->i2s_dev); + i2s_controller_reset(ctx->i2s_dev); + device_unlock(ctx->i2s_dev); + i2s_configured = false; + } + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + + if (!i2s_configured) { + // Reconfigure I2S when resuming + device_lock(ctx->i2s_dev); + err = i2s_controller_set_config(ctx->i2s_dev, &cfg); + device_unlock(ctx->i2s_dev); + if (err != ERROR_NONE) { + ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err); + break; + } + i2s_configured = true; + } + + size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES; + size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f); + if (read_bytes == 0) { + break; // EOF + } + + // Scaling Volume + int vol = ctx->volume; + int16_t* samples_ptr = (int16_t*)ctx->audio_buf; + size_t sample_count = read_bytes / sizeof(int16_t); + for (size_t i = 0; i < sample_count; ++i) { + int32_t scaled = (int32_t)samples_ptr[i] * vol / 100; + samples_ptr[i] = (int16_t)scaled; + } + + size_t offset = 0; + bool write_err = false; + while (offset < read_bytes && ctx->state == STATE_PLAYING) { + size_t written = 0; + err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100)); + if (err != ERROR_NONE || written == 0) { + ESP_LOGE(TAG, "I2S playback write error: %d", err); + write_err = true; + break; + } + offset += written; + } + + if (write_err) { + break; + } + + total_played += read_bytes; + + // UI progress update + int pct = (int)((total_played * 100) / data_size); + uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8); + uint32_t elapsed_sec = total_played / bytes_per_sec; + uint32_t total_sec = data_size / bytes_per_sec; + + char status_buf[64]; + snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u", + (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60), + (unsigned)(total_sec / 60), (unsigned)(total_sec % 60)); + + tt_lvgl_lock(portMAX_DELAY); + lv_label_set_text(ctx->lbl_status, status_buf); + lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF); + tt_lvgl_unlock(); + } + + fclose(f); + ESP_LOGI(TAG, "Playback task complete"); + + // Reset I2S controller to stop/release DMA channel and prevent looping noise + device_lock(ctx->i2s_dev); + i2s_controller_reset(ctx->i2s_dev); + device_unlock(ctx->i2s_dev); + + tt_lvgl_lock(portMAX_DELAY); + ctx->state = STATE_IDLE; + update_ui(ctx); + tt_lvgl_unlock(); + + ctx->task_handle = NULL; + vTaskDelete(NULL); +} + +/* ─── Callbacks ─── */ +static void on_record_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + if (ctx->state != STATE_IDLE) return; + if (ctx->i2s_dev == NULL) return; + + ctx->state = STATE_RECORDING; + update_ui(ctx); + + xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle); +} + +static void on_play_pause_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + if (ctx->selected_index < 0) return; + + if (ctx->state == STATE_IDLE) { + ctx->state = STATE_PLAYING; + update_ui(ctx); + xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle); + } else if (ctx->state == STATE_PLAYING) { + ctx->state = STATE_PAUSED; + update_ui(ctx); + } else if (ctx->state == STATE_PAUSED) { + ctx->state = STATE_PLAYING; + update_ui(ctx); + } +} + +static void on_stop_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + if (ctx->state == STATE_IDLE) return; + + ctx->state = STATE_IDLE; + update_ui(ctx); +} + +static void on_delete_click(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + if (ctx->state != STATE_IDLE) return; + if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) return; + + char filepath[256]; + snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", ctx->memos[ctx->selected_index]); + + ESP_LOGI(TAG, "Deleting: %s", filepath); + unlink(filepath); + + ctx->selected_index = -1; + refresh_memo_list(ctx); + update_ui(ctx); +} + +static void on_memo_selected(lv_event_t* e) { + AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e); + lv_obj_t* btn = lv_event_get_target(e); + + // Clear check states from other entries + uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos); + for (uint32_t i = 0; i < child_cnt; i++) { + lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i); + lv_obj_clear_state(child, LV_STATE_CHECKED); + } + + // Apply check state to the clicked memo + lv_obj_add_state(btn, LV_STATE_CHECKED); + + const char* text = lv_list_get_btn_text(ctx->lst_memos, btn); + for (int i = 0; i < ctx->memo_count; i++) { + if (strcmp(ctx->memos[i], text) == 0) { + ctx->selected_index = i; + break; + } + } + + update_ui(ctx); +} + +/* ─── UI Refresh & Sync ─── */ +static void refresh_memo_list(AppCtx* ctx) { + ctx->memo_count = 0; + lv_obj_clean(ctx->lst_memos); + + DIR* dir = opendir("/sdcard/memos"); + if (dir == NULL) { + ESP_LOGE(TAG, "Failed to open folder /sdcard/memos"); + return; + } + + struct dirent* entry; + while ((entry = readdir(dir)) != NULL && ctx->memo_count < MAX_MEMOS) { + if (entry->d_name[0] == '.') continue; + + size_t len = strlen(entry->d_name); + if (len > 4 && strcasecmp(entry->d_name + len - 4, ".wav") == 0) { + strncpy(ctx->memos[ctx->memo_count], entry->d_name, MAX_FILENAME_LEN - 1); + ctx->memos[ctx->memo_count][MAX_FILENAME_LEN - 1] = '\0'; + ctx->memo_count++; + } + } + closedir(dir); + + // Simple bubble sort + for (int i = 0; i < ctx->memo_count - 1; i++) { + for (int j = 0; j < ctx->memo_count - i - 1; j++) { + if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) { + char temp[MAX_FILENAME_LEN]; + strcpy(temp, ctx->memos[j]); + strcpy(ctx->memos[j], ctx->memos[j + 1]); + strcpy(ctx->memos[j + 1], temp); + } + } + } + + // Populate UI List + for (int i = 0; i < ctx->memo_count; i++) { + lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]); + lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx); + if (i == ctx->selected_index) { + lv_obj_add_state(btn, LV_STATE_CHECKED); + } + } +} + +static void update_ui(AppCtx* ctx) { + if (ctx->lbl_status == NULL) return; + + switch (ctx->state) { + case STATE_RECORDING: + lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); + lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); + lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(ctx->btn_delete, LV_STATE_DISABLED); + break; + + case STATE_PLAYING: + lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); + lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); + lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE); + lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(ctx->btn_delete, LV_STATE_DISABLED); + break; + + case STATE_PAUSED: + lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED); + lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); + lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY); + lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(ctx->btn_delete, LV_STATE_DISABLED); + break; + + case STATE_IDLE: + default: + lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED); + + if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) { + lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED); + lv_obj_clear_state(ctx->btn_delete, LV_STATE_DISABLED); + + char status_buf[128]; + snprintf(status_buf, sizeof(status_buf), "Selected: %s", ctx->memos[ctx->selected_index]); + lv_label_set_text(ctx->lbl_status, status_buf); + } else { + lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED); + lv_obj_add_state(ctx->btn_delete, LV_STATE_DISABLED); + lv_label_set_text(ctx->lbl_status, "Select a memo or press Rec"); + } + + lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY); + lv_obj_add_state(ctx->btn_stop, LV_STATE_DISABLED); + lv_bar_set_value(ctx->bar_progress, 0, LV_ANIM_OFF); + break; + } +} + +/* ─── App Lifecycle ─── */ +static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { + memset(&g_ctx, 0, sizeof(g_ctx)); + g_ctx.volume = 80; + g_ctx.selected_index = -1; + + // Create the memos storage directory if missing + mkdir("/sdcard/memos", 0755); + + // Allocate audio buffer + g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE); + if (g_ctx.audio_buf == NULL) { + ESP_LOGE(TAG, "Failed to allocate audio buffer"); + } + + // Locate I2S hardware channel + g_ctx.i2s_dev = device_find_by_name("i2s0"); + if (g_ctx.i2s_dev == NULL) { + ESP_LOGE(TAG, "I2S device 'i2s0' not found!"); + } + + // ─── UI Setup ─── + lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + // Premium Player Card + lv_obj_t* card = lv_obj_create(parent); + lv_obj_set_size(card, lv_pct(95), lv_pct(88)); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 12); + lv_obj_set_style_radius(card, 15, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base + lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0 + lv_obj_set_style_border_width(card, 2, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_style_pad_gap(card, 8, 0); + + // Status Area + g_ctx.lbl_status = lv_label_create(card); + lv_obj_set_width(g_ctx.lbl_status, lv_pct(95)); + lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); // mocha text + lv_label_set_text(g_ctx.lbl_status, "Scanning memos..."); + + // Progress Bar + g_ctx.bar_progress = lv_bar_create(card); + lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8); + lv_bar_set_range(g_ctx.bar_progress, 0, 100); + lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); // mocha surface1 + lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); // mocha blue + + // Memos List + g_ctx.lst_memos = lv_list_create(card); + lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85); + lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); // mocha mantle + lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0); + lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0); + lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0); + + // Controls Box + lv_obj_t* ctrl_box = lv_obj_create(card); + lv_obj_remove_style_all(ctrl_box); + lv_obj_set_size(ctrl_box, lv_pct(95), 40); + lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + // Rec Button + g_ctx.btn_record = lv_btn_create(ctrl_box); + lv_obj_set_size(g_ctx.btn_record, 45, 32); + lv_obj_set_style_radius(g_ctx.btn_record, 8, 0); + lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red + lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0); + lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record); + lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO); + lv_obj_center(lbl_rec); + lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx); + + // Play/Pause Button + g_ctx.btn_play_pause = lv_btn_create(ctrl_box); + lv_obj_set_size(g_ctx.btn_play_pause, 45, 32); + lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0); + lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue + lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0); + lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause); + lv_label_set_text(lbl_play, LV_SYMBOL_PLAY); + lv_obj_center(lbl_play); + lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx); + + // Stop Button + g_ctx.btn_stop = lv_btn_create(ctrl_box); + lv_obj_set_size(g_ctx.btn_stop, 45, 32); + lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0); + lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1 + lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0); + lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop); + lv_label_set_text(lbl_stop, LV_SYMBOL_STOP); + lv_obj_center(lbl_stop); + lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx); + + // Delete Button + g_ctx.btn_delete = lv_btn_create(ctrl_box); + lv_obj_set_size(g_ctx.btn_delete, 45, 32); + lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0); + lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire + lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0); + lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete); + lv_label_set_text(lbl_del, LV_SYMBOL_TRASH); + lv_obj_center(lbl_del); + lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx); + + // Initialize list and control state + if (g_ctx.i2s_dev == NULL) { + lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S 'i2s0' missing"); + lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_delete, LV_STATE_DISABLED); + } else if (g_ctx.audio_buf == NULL) { + lv_label_set_text(g_ctx.lbl_status, "ERROR: Out of Memory"); + lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED); + lv_obj_add_state(g_ctx.btn_delete, LV_STATE_DISABLED); + } else { + g_ctx.state = STATE_IDLE; + refresh_memo_list(&g_ctx); + update_ui(&g_ctx); + } +} + +static void onHideApp(AppHandle app, void* data) { + if (g_ctx.state != STATE_IDLE) { + g_ctx.state = STATE_IDLE; + } + + // Wait for task completion + while (g_ctx.task_handle != NULL) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + + // Reset I2S to ensure DMA channel is stopped + if (g_ctx.i2s_dev != NULL) { + device_lock(g_ctx.i2s_dev); + i2s_controller_reset(g_ctx.i2s_dev); + device_unlock(g_ctx.i2s_dev); + } + + if (g_ctx.audio_buf != NULL) { + free(g_ctx.audio_buf); + g_ctx.audio_buf = NULL; + } +} + +int main(int argc, char* argv[]) { + tt_app_register((AppRegistration) { + .onShow = onShowApp, + .onHide = onHideApp + }); + return 0; +}