Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c3c4ad2fe | |||
| 2a45c98fb3 | |||
| b2e9046438 | |||
| f07d347fd9 | |||
| 8f46e03070 | |||
| 7121009d0d |
+154
-179
@@ -4,7 +4,7 @@
|
||||
#include <tt_app_alertdialog.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -45,14 +45,15 @@ typedef struct {
|
||||
} BookMetadata;
|
||||
|
||||
typedef struct {
|
||||
struct Device* i2s_dev;
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle stream_handle;
|
||||
PlaybackState state;
|
||||
int volume;
|
||||
|
||||
|
||||
// Book picker list data
|
||||
BookMetadata books[MAX_BOOKS];
|
||||
int book_count;
|
||||
|
||||
|
||||
// Currently loaded book details
|
||||
char current_book_slug[MAX_PATH];
|
||||
cJSON* manifest_root;
|
||||
@@ -60,12 +61,12 @@ typedef struct {
|
||||
int page_count;
|
||||
int current_page;
|
||||
int last_pct;
|
||||
|
||||
|
||||
// UI elements
|
||||
AppHandle app;
|
||||
lv_obj_t* picker_wrapper;
|
||||
lv_obj_t* lst_books;
|
||||
|
||||
|
||||
lv_obj_t* player_wrapper;
|
||||
lv_obj_t* header_bar;
|
||||
lv_obj_t* ctrl_bar;
|
||||
@@ -76,29 +77,29 @@ typedef struct {
|
||||
lv_obj_t* btn_play_pause;
|
||||
lv_obj_t* btn_prev;
|
||||
lv_obj_t* btn_next;
|
||||
|
||||
|
||||
// Audio State
|
||||
char current_audio_path[512];
|
||||
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
|
||||
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
|
||||
|
||||
|
||||
TaskHandle_t playback_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
|
||||
char riff[4];
|
||||
uint32_t overall_size;
|
||||
char wave[4];
|
||||
char fmt_chunk_marker[4];
|
||||
uint32_t length_of_fmt;
|
||||
uint16_t format_type;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint32_t byterate;
|
||||
uint16_t block_align;
|
||||
uint16_t bits_per_sample;
|
||||
char data_chunk_header[4];
|
||||
uint32_t data_size;
|
||||
} WavHeader;
|
||||
|
||||
static AppCtx g_ctx;
|
||||
@@ -113,19 +114,60 @@ static void play_mp3(AppCtx* ctx);
|
||||
static void play_wav(AppCtx* ctx);
|
||||
static void scan_books(AppCtx* ctx);
|
||||
|
||||
/* ─── Audio-stream helpers ─── */
|
||||
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||
// Prefer device_find_first_by_type but keep compatibility with name lookup
|
||||
struct Device* dev = device_find_by_name("audio-stream");
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void close_stream_if_open(AppCtx* ctx) {
|
||||
if (ctx->stream_handle) {
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static bool open_output_stream(AppCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits) {
|
||||
close_stream_if_open(ctx);
|
||||
if (!ctx->stream_dev) return false;
|
||||
struct AudioStreamConfig cfg = {
|
||||
.sample_rate = sample_rate,
|
||||
.bits_per_sample = bits,
|
||||
.channels = channels
|
||||
};
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &cfg, &ctx->stream_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "audio_stream_open_output failed: %d (rate=%u ch=%u)", err, (unsigned)sample_rate, channels);
|
||||
ctx->stream_handle = NULL;
|
||||
return false;
|
||||
}
|
||||
ESP_LOGI(TAG, "audio_stream output opened: %u Hz %u ch %u-bit", (unsigned)sample_rate, channels, bits);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ─── UI Helper to update status labels & button states ─── */
|
||||
static void update_ui(AppCtx* ctx) {
|
||||
if (!ctx->btn_play_pause) return;
|
||||
|
||||
lv_obj_t* lbl_play = lv_obj_get_child(ctx->btn_play_pause, 0);
|
||||
|
||||
|
||||
// Prev button state
|
||||
if (ctx->current_page <= 0) {
|
||||
lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
|
||||
// Next button state
|
||||
if (ctx->current_page >= ctx->page_count - 1) {
|
||||
lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||
@@ -162,13 +204,13 @@ static char* read_full_file(const char* filepath) {
|
||||
return NULL;
|
||||
}
|
||||
fseek(file, 0, SEEK_SET);
|
||||
|
||||
|
||||
char* buf = malloc(size + 1);
|
||||
if (!buf) {
|
||||
fclose(file);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
size_t read_bytes = fread(buf, 1, size, file);
|
||||
buf[read_bytes] = '\0';
|
||||
fclose(file);
|
||||
@@ -185,7 +227,6 @@ static void on_auto_advance_timer(lv_timer_t* timer) {
|
||||
|
||||
static void handle_audio_finished(AppCtx* ctx) {
|
||||
if (ctx->current_page + 1 < ctx->page_count) {
|
||||
// Create a one-shot timer with repeat count 1 to run on the GUI thread
|
||||
lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
|
||||
lv_timer_set_repeat_count(timer, 1);
|
||||
} else {
|
||||
@@ -208,7 +249,6 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx) {
|
||||
|
||||
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
||||
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
// 1. Stop current audio if running and wait for thread to finish
|
||||
wait_for_playback_task_to_exit(ctx);
|
||||
|
||||
if (page_index < 0 || page_index >= ctx->page_count) return;
|
||||
@@ -225,7 +265,7 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
if (img_item && img_item->valuestring) {
|
||||
char img_path[512];
|
||||
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
|
||||
|
||||
|
||||
FILE* img_file = fopen(img_path, "rb");
|
||||
if (img_file) {
|
||||
fclose(img_file);
|
||||
@@ -238,15 +278,13 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
lv_image_set_src(ctx->img_page, lv_img_path);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||
}
|
||||
} else {
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Update Page index indicator (e.g. "1 / 12")
|
||||
// Update Page index indicator
|
||||
char ind_buf[32];
|
||||
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
|
||||
lv_label_set_text(ctx->lbl_indicator, ind_buf);
|
||||
@@ -258,18 +296,16 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
ctx->current_audio_path[0] = '\0';
|
||||
}
|
||||
|
||||
// Update UI states (like Prev/Next buttons)
|
||||
update_ui(ctx);
|
||||
|
||||
// Start playback if requested and valid
|
||||
if (start_audio && ctx->current_audio_path[0] != '\0') {
|
||||
// Verify audio file exists
|
||||
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
|
||||
if (audio_file) {
|
||||
fclose(audio_file);
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
|
||||
const char* buttons[] = {"OK"};
|
||||
@@ -300,7 +336,7 @@ static void return_to_picker(AppCtx* ctx) {
|
||||
/* ─── Task Selection (MP3 or WAV) ─── */
|
||||
static void audio_playback_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
|
||||
|
||||
size_t len = strlen(ctx->current_audio_path);
|
||||
bool is_wav = false;
|
||||
if (len > 4 && strcasecmp(ctx->current_audio_path + len - 4, ".wav") == 0) {
|
||||
@@ -314,9 +350,8 @@ static void audio_playback_task(void* arg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── MP3 Decoder Routine ─── */
|
||||
/* ─── MP3 Decoder Routine (audio-stream) ─── */
|
||||
static void play_mp3(AppCtx* ctx) {
|
||||
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
|
||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||
@@ -354,14 +389,12 @@ static void play_mp3(AppCtx* ctx) {
|
||||
int sample_rate = 0;
|
||||
int channels = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||
|
||||
while (ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (sample_rate != 0) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
if (ctx->stream_handle) {
|
||||
close_stream_if_open(ctx);
|
||||
sample_rate = 0;
|
||||
channels = 0;
|
||||
}
|
||||
@@ -390,7 +423,6 @@ static void play_mp3(AppCtx* ctx) {
|
||||
|
||||
if (info.frame_bytes <= 0) {
|
||||
if (eof) break;
|
||||
// Resync
|
||||
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
|
||||
continue;
|
||||
}
|
||||
@@ -400,22 +432,10 @@ static void play_mp3(AppCtx* ctx) {
|
||||
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
|
||||
|
||||
if (samples > 0) {
|
||||
// Configure I2S
|
||||
// Open/reopen stream on format change
|
||||
if (sample_rate != info.hz || channels != info.channels) {
|
||||
struct I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = (uint32_t)info.hz,
|
||||
.bits_per_sample = 16,
|
||||
.channel_left = 0,
|
||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to configure I2S: %d", err);
|
||||
if (!open_output_stream(ctx, (uint32_t)info.hz, (uint8_t)info.channels, 16)) {
|
||||
ESP_LOGE(TAG, "Failed to open audio stream for MP3: %d Hz %d ch", info.hz, info.channels);
|
||||
break;
|
||||
}
|
||||
sample_rate = info.hz;
|
||||
@@ -431,15 +451,15 @@ static void play_mp3(AppCtx* ctx) {
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
|
||||
// Write PCM to I2S
|
||||
// Write via audio_stream (resampled to native 44100 internally)
|
||||
size_t offset = 0;
|
||||
size_t data_size = sample_count * sizeof(int16_t);
|
||||
bool write_err = false;
|
||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
||||
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(1000));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "I2S write error: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream_write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
@@ -465,12 +485,7 @@ static void play_mp3(AppCtx* ctx) {
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
|
||||
if (ctx->i2s_dev) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
close_stream_if_open(ctx);
|
||||
|
||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||
|
||||
@@ -489,9 +504,8 @@ static void play_mp3(AppCtx* ctx) {
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ─── WAV Decoder Routine ─── */
|
||||
/* ─── WAV Decoder Routine (audio-stream) ─── */
|
||||
static void play_wav(AppCtx* ctx) {
|
||||
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
|
||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||
@@ -538,49 +552,36 @@ static void play_wav(AppCtx* ctx) {
|
||||
fseek(file, sizeof(WavHeader), SEEK_SET);
|
||||
}
|
||||
|
||||
struct I2sConfig config = {
|
||||
.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, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to configure WAV I2S: %d", err);
|
||||
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) {
|
||||
ESP_LOGE(TAG, "Failed to open audio stream for WAV: %u Hz %u ch", (unsigned)header.sample_rate, header.channels);
|
||||
fclose(file);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
update_ui(ctx);
|
||||
tt_lvgl_unlock();
|
||||
ctx->playback_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Starting WAV playback: %s (%u Hz, %u channels)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
||||
ESP_LOGI(TAG, "Starting WAV via audio-stream: %s (%u Hz, %u ch)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
||||
|
||||
size_t total_played = 0;
|
||||
bool i2s_configured = true;
|
||||
bool stream_open = true;
|
||||
|
||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (i2s_configured) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
i2s_configured = false;
|
||||
if (stream_open) {
|
||||
close_stream_if_open(ctx);
|
||||
stream_open = false;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!i2s_configured) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
if (err != ERROR_NONE) break;
|
||||
i2s_configured = true;
|
||||
if (!stream_open) {
|
||||
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break;
|
||||
stream_open = true;
|
||||
}
|
||||
|
||||
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
|
||||
@@ -596,14 +597,14 @@ static void play_wav(AppCtx* ctx) {
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
|
||||
// Play to I2S
|
||||
// Write via audio_stream
|
||||
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));
|
||||
error_t err = audio_stream_write(ctx->stream_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "I2S WAV write error: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream WAV write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
@@ -614,7 +615,6 @@ static void play_wav(AppCtx* ctx) {
|
||||
|
||||
total_played += read_bytes;
|
||||
|
||||
// Update progress bar
|
||||
int pct = (int)((total_played * 100) / data_size);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
@@ -629,12 +629,7 @@ static void play_wav(AppCtx* ctx) {
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
|
||||
if (ctx->i2s_dev) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
close_stream_if_open(ctx);
|
||||
|
||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||
|
||||
@@ -652,29 +647,29 @@ static void play_wav(AppCtx* ctx) {
|
||||
static void on_book_selected(lv_event_t* e) {
|
||||
int index = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
AppCtx* ctx = &g_ctx;
|
||||
|
||||
|
||||
BookMetadata* book = &ctx->books[index];
|
||||
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
|
||||
|
||||
|
||||
char manifest_path[512];
|
||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
|
||||
|
||||
|
||||
char* json_str = read_full_file(manifest_path);
|
||||
if (!json_str) {
|
||||
const char* buttons[] = {"OK"};
|
||||
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ctx->manifest_root = cJSON_Parse(json_str);
|
||||
free(json_str);
|
||||
|
||||
|
||||
if (!ctx->manifest_root) {
|
||||
const char* buttons[] = {"OK"};
|
||||
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
|
||||
if (!ctx->pages_array || !cJSON_IsArray(ctx->pages_array)) {
|
||||
const char* buttons[] = {"OK"};
|
||||
@@ -684,7 +679,7 @@ static void on_book_selected(lv_event_t* e) {
|
||||
ctx->pages_array = NULL;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
|
||||
if (ctx->page_count <= 0) {
|
||||
const char* buttons[] = {"OK"};
|
||||
@@ -694,17 +689,16 @@ static void on_book_selected(lv_event_t* e) {
|
||||
ctx->pages_array = NULL;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
lv_label_set_text(ctx->lbl_book_title, book->title);
|
||||
|
||||
|
||||
// Switch views
|
||||
lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// Load first page (no auto-play initially)
|
||||
|
||||
load_page(ctx, 0, false);
|
||||
}
|
||||
|
||||
@@ -712,57 +706,57 @@ static void on_book_selected(lv_event_t* e) {
|
||||
static void scan_books(AppCtx* ctx) {
|
||||
ctx->book_count = 0;
|
||||
lv_obj_clean(ctx->lst_books);
|
||||
|
||||
|
||||
DIR* dir = opendir("/sdcard/books");
|
||||
if (!dir) {
|
||||
ESP_LOGE(TAG, "Failed to scan books: /sdcard/books folder missing.");
|
||||
lv_list_add_text(ctx->lst_books, "No SD card or books directory found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL && ctx->book_count < MAX_BOOKS) {
|
||||
if (entry->d_name[0] == '.') continue;
|
||||
|
||||
|
||||
char manifest_path[512];
|
||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", entry->d_name);
|
||||
|
||||
|
||||
char* json_str = read_full_file(manifest_path);
|
||||
if (json_str) {
|
||||
cJSON* root = cJSON_Parse(json_str);
|
||||
free(json_str);
|
||||
|
||||
|
||||
if (root) {
|
||||
cJSON* title_item = cJSON_GetObjectItem(root, "title");
|
||||
cJSON* author_item = cJSON_GetObjectItem(root, "author");
|
||||
|
||||
|
||||
BookMetadata* book = &ctx->books[ctx->book_count];
|
||||
strncpy(book->slug, entry->d_name, sizeof(book->slug) - 1);
|
||||
|
||||
|
||||
if (title_item && title_item->valuestring) {
|
||||
strncpy(book->title, title_item->valuestring, sizeof(book->title) - 1);
|
||||
} else {
|
||||
strncpy(book->title, entry->d_name, sizeof(book->title) - 1);
|
||||
}
|
||||
|
||||
|
||||
if (author_item && author_item->valuestring) {
|
||||
strncpy(book->author, author_item->valuestring, sizeof(book->author) - 1);
|
||||
} else {
|
||||
book->author[0] = '\0';
|
||||
}
|
||||
|
||||
|
||||
cJSON_Delete(root);
|
||||
ctx->book_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
|
||||
if (ctx->book_count == 0) {
|
||||
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Bubble sort by title
|
||||
for (int i = 0; i < ctx->book_count - 1; i++) {
|
||||
for (int j = 0; j < ctx->book_count - i - 1; j++) {
|
||||
@@ -773,7 +767,7 @@ static void scan_books(AppCtx* ctx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add items to screen list
|
||||
for (int i = 0; i < ctx->book_count; ++i) {
|
||||
char label_text[512];
|
||||
@@ -795,7 +789,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
||||
if (ctx->state == STATE_IDLE) {
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||
} else if (ctx->state == STATE_PLAYING) {
|
||||
ctx->state = STATE_PAUSED;
|
||||
update_ui(ctx);
|
||||
@@ -824,7 +818,7 @@ static void on_next_click(lv_event_t* e) {
|
||||
static void on_image_click(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
if (!ctx) return;
|
||||
|
||||
|
||||
bool is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
if (is_hidden) {
|
||||
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
@@ -847,7 +841,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
memset(&g_ctx, 0, sizeof(g_ctx));
|
||||
g_ctx.app = app;
|
||||
g_ctx.volume = 80;
|
||||
|
||||
|
||||
// Allocate shared audio buffers
|
||||
#ifdef ESP_PLATFORM
|
||||
g_ctx.audio_buf = (uint8_t*)heap_caps_malloc(MP3_INPUT_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
@@ -856,13 +850,12 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
g_ctx.audio_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||
#endif
|
||||
|
||||
// Find I2S sound device
|
||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
||||
if (!g_ctx.i2s_dev) {
|
||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
||||
|
||||
// Find audio-stream device (provides resampling to native 44100)
|
||||
if (!find_audio_stream_device(&g_ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
|
||||
}
|
||||
|
||||
|
||||
// Create dual layouts
|
||||
// 1. Picker Screen wrapper
|
||||
g_ctx.picker_wrapper = lv_obj_create(parent);
|
||||
@@ -870,13 +863,11 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0); // Dark background
|
||||
|
||||
// Toolbar in picker
|
||||
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
// Picker list
|
||||
|
||||
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
|
||||
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
@@ -885,7 +876,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_set_height(g_ctx.lst_books, parent_height - toolbar_height);
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_books, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_border_color(g_ctx.lst_books, lv_color_hex(0x313244), 0);
|
||||
|
||||
|
||||
// 2. Player Screen wrapper (hidden on start)
|
||||
g_ctx.player_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(g_ctx.player_wrapper, LV_PCT(100), LV_PCT(100));
|
||||
@@ -893,10 +884,9 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_set_style_pad_all(g_ctx.player_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE); // Lock page scrolling
|
||||
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// Page Image (covers the whole screen as background)
|
||||
|
||||
g_ctx.img_page = lv_image_create(g_ctx.player_wrapper);
|
||||
lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
|
||||
@@ -905,20 +895,18 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(g_ctx.img_page, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Custom Player header (Back button, Book Title, Page Indicator)
|
||||
|
||||
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||
lv_obj_t* header_bar = g_ctx.header_bar;
|
||||
lv_obj_set_size(header_bar, LV_PCT(100), 36);
|
||||
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
lv_obj_set_style_radius(header_bar, 0, 0);
|
||||
lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); // Solid header bar
|
||||
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_color(header_bar, lv_color_hex(0x313244), 0);
|
||||
lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// Back button
|
||||
|
||||
lv_obj_t* btn_back = lv_button_create(header_bar);
|
||||
lv_obj_set_size(btn_back, 44, 26);
|
||||
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
@@ -928,37 +916,33 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_label_set_text(lbl_back, LV_SYMBOL_LEFT);
|
||||
lv_obj_center(lbl_back);
|
||||
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Title
|
||||
|
||||
g_ctx.lbl_book_title = lv_label_create(header_bar);
|
||||
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_width(g_ctx.lbl_book_title, 180);
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0);
|
||||
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT); // Static text with dots to prevent dynamic redraw overhead
|
||||
|
||||
// Page Indicator
|
||||
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT);
|
||||
|
||||
g_ctx.lbl_indicator = lv_label_create(header_bar);
|
||||
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
|
||||
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
|
||||
|
||||
// Bottom Controls container (overlaying background image)
|
||||
|
||||
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
|
||||
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
|
||||
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||
lv_obj_set_style_radius(ctrl_bar, 0, 0);
|
||||
lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); // Solid control bar
|
||||
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(ctrl_bar, 0, 0);
|
||||
lv_obj_set_style_pad_all(ctrl_bar, 0, 0);
|
||||
lv_obj_set_style_pad_gap(ctrl_bar, 0, 0);
|
||||
lv_obj_set_flex_flow(ctrl_bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// Progress Bar (thin bar running along the top of control bar)
|
||||
|
||||
g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper);
|
||||
lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4);
|
||||
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
|
||||
@@ -966,8 +950,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
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);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||
|
||||
// Prev Button
|
||||
|
||||
g_ctx.btn_prev = lv_button_create(ctrl_bar);
|
||||
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
|
||||
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
|
||||
@@ -976,8 +959,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_label_set_text(lbl_prev, LV_SYMBOL_PREV);
|
||||
lv_obj_center(lbl_prev);
|
||||
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Play/Pause Button
|
||||
|
||||
g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
|
||||
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
|
||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
|
||||
@@ -987,8 +969,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_label_set_text(lbl_play_btn, LV_SYMBOL_PLAY " Play");
|
||||
lv_obj_center(lbl_play_btn);
|
||||
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Next Button
|
||||
|
||||
g_ctx.btn_next = lv_button_create(ctrl_bar);
|
||||
lv_obj_set_size(g_ctx.btn_next, 54, 30);
|
||||
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
|
||||
@@ -1001,26 +982,20 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
// Initial load
|
||||
g_ctx.state = STATE_IDLE;
|
||||
update_ui(&g_ctx);
|
||||
|
||||
// Scan Books
|
||||
|
||||
scan_books(&g_ctx);
|
||||
}
|
||||
|
||||
static void onHideApp(AppHandle app, void* data) {
|
||||
wait_for_playback_task_to_exit(&g_ctx);
|
||||
|
||||
if (g_ctx.i2s_dev) {
|
||||
device_lock(g_ctx.i2s_dev);
|
||||
i2s_controller_reset(g_ctx.i2s_dev);
|
||||
device_unlock(g_ctx.i2s_dev);
|
||||
}
|
||||
|
||||
close_stream_if_open(&g_ctx);
|
||||
|
||||
if (g_ctx.manifest_root) {
|
||||
cJSON_Delete(g_ctx.manifest_root);
|
||||
g_ctx.manifest_root = NULL;
|
||||
g_ctx.pages_array = NULL;
|
||||
}
|
||||
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
if (g_ctx.audio_buf) {
|
||||
heap_caps_free(g_ctx.audio_buf);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/drivers/audio_codec.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -27,7 +28,8 @@ typedef enum {
|
||||
} PlaybackState;
|
||||
|
||||
typedef struct {
|
||||
struct Device* i2s_dev;
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle stream_handle;
|
||||
char filepath[512];
|
||||
PlaybackState state;
|
||||
|
||||
@@ -119,17 +121,17 @@ static void mp3_playback_task(void* arg) {
|
||||
ctx->eof = false;
|
||||
ctx->sample_rate = 0;
|
||||
ctx->channels = 0;
|
||||
ctx->stream_handle = NULL;
|
||||
|
||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
||||
|
||||
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
|
||||
if (ctx->stream_handle != NULL) {
|
||||
// Close stream immediately on pause to stop DMA
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
// Clear cached format to force re-open on resume
|
||||
ctx->sample_rate = 0;
|
||||
ctx->channels = 0;
|
||||
}
|
||||
@@ -172,30 +174,30 @@ static void mp3_playback_task(void* arg) {
|
||||
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
||||
|
||||
if (samples > 0) {
|
||||
// Configure I2S if format changed
|
||||
// Configure audio-stream if format changed
|
||||
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
||||
struct I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
if (ctx->stream_handle != NULL) {
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
}
|
||||
|
||||
struct AudioStreamConfig config = {
|
||||
.sample_rate = (uint32_t)info.hz,
|
||||
.bits_per_sample = 16,
|
||||
.channel_left = 0,
|
||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
.channels = (uint8_t)info.channels
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to set config: %d", err);
|
||||
ESP_LOGE(TAG, "Failed to open audio stream: %d", err);
|
||||
break;
|
||||
}
|
||||
ctx->sample_rate = info.hz;
|
||||
ctx->channels = info.channels;
|
||||
ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
|
||||
ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels);
|
||||
}
|
||||
|
||||
// Adjust volume
|
||||
// Adjust volume (after resampler)
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
||||
size_t sample_count = (size_t)samples * info.channels;
|
||||
@@ -204,15 +206,15 @@ static void mp3_playback_task(void* arg) {
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
|
||||
// Play audio to I2S
|
||||
// Play audio via audio-stream (resampled to native 44100)
|
||||
size_t offset = 0;
|
||||
size_t data_size = sample_count * sizeof(int16_t);
|
||||
bool write_err = false;
|
||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
||||
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "I2S write failed: %d", err);
|
||||
ESP_LOGE(TAG, "Audio stream write failed: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
@@ -232,18 +234,15 @@ static void mp3_playback_task(void* arg) {
|
||||
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Close audio stream to clean up DMA / resampler task
|
||||
if (ctx->stream_handle != NULL) {
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Playback task finished");
|
||||
@@ -292,10 +291,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||
|
||||
// Find device
|
||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
||||
if (!g_ctx.i2s_dev) {
|
||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
||||
// Find audio-stream device (resampling layer over ES8311 codec)
|
||||
g_ctx.stream_dev = device_find_by_name("audio-stream");
|
||||
if (!g_ctx.stream_dev) {
|
||||
ESP_LOGE(TAG, "Audio-stream device not found!");
|
||||
}
|
||||
|
||||
// Parse launch parameters
|
||||
@@ -393,8 +392,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_center(lbl_stop);
|
||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
if (!g_ctx.i2s_dev) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "Error: I2S Not Found");
|
||||
if (!g_ctx.stream_dev) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found");
|
||||
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
|
||||
@@ -409,7 +408,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
if (g_ctx.filepath[0] != '\0') {
|
||||
g_ctx.state = STATE_PLAYING;
|
||||
update_ui(&g_ctx);
|
||||
xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
|
||||
xTaskCreate(mp3_playback_task, "mp3_play", 6144, &g_ctx, 6, &g_ctx.playback_task_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,11 +423,10 @@ 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);
|
||||
// Close any open audio stream
|
||||
if (g_ctx.stream_handle != NULL) {
|
||||
audio_stream_close(g_ctx.stream_handle);
|
||||
g_ctx.stream_handle = NULL;
|
||||
}
|
||||
|
||||
if (g_ctx.input_buf) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.mp3player
|
||||
|
||||
@@ -10,5 +10,5 @@ idf_component_register(
|
||||
../../../Libraries/TactilityCpp/Include
|
||||
../../../Libraries/GameKit/Include
|
||||
../../../Libraries/SfxEngine/Include
|
||||
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#include "DungeonModel.h"
|
||||
|
||||
#ifndef LV_ABS
|
||||
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
|
||||
#endif
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
uint32_t DungeonModel::nextRandom() {
|
||||
@@ -11,48 +9,75 @@ uint32_t DungeonModel::nextRandom() {
|
||||
return (seed >> 16u) & 0x7FFFu;
|
||||
}
|
||||
|
||||
void DungeonModel::reset(uint32_t seedValue) {
|
||||
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
|
||||
seed = seedValue;
|
||||
state = DungeonState {};
|
||||
state.floor = 1;
|
||||
state.floor = tutorials ? 0 : 2;
|
||||
state.hp = 5;
|
||||
state.gold = 0;
|
||||
state.gameOver = false;
|
||||
generateFloor();
|
||||
}
|
||||
|
||||
void DungeonModel::clearAllTiles() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||
state.tiles[y][x] = Tile::Floor;
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { state.tiles[0][x] = Tile::Wall; state.tiles[DUNGEON_ROWS-1][x] = Tile::Wall; }
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { state.tiles[y][0] = Tile::Wall; state.tiles[y][DUNGEON_COLS-1] = Tile::Wall; }
|
||||
}
|
||||
|
||||
void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
|
||||
if (state.entityCount >= MAX_ENTITIES) return;
|
||||
state.entities[state.entityCount++] = Entity { type, pos, hp, true };
|
||||
}
|
||||
|
||||
void DungeonModel::generateTutorialFloor(uint8_t tId) {
|
||||
clearAllTiles();
|
||||
state.entityCount = 0;
|
||||
state.renderRows = TUTORIAL_ROWS;
|
||||
for (uint8_t y = TUTORIAL_ROWS + 1; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||
state.tiles[y][x] = Tile::Wall;
|
||||
state.playerPos = {1, 1};
|
||||
if (tId == 0) {
|
||||
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||
} else {
|
||||
state.tiles[1][3] = Tile::Treasure;
|
||||
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||
addEnemy(EntityType::Bat, {5, 1}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonModel::generateFloor() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; }
|
||||
state.renderRows = DUNGEON_ROWS;
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1);
|
||||
state.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
|
||||
}
|
||||
}
|
||||
state.playerPos = {1, 1};
|
||||
state.entityCount = 0;
|
||||
state.tiles[2][4] = Tile::Wall;
|
||||
state.tiles[3][4] = Tile::Wall;
|
||||
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
|
||||
state.tiles[5][7] = Tile::Stairs;
|
||||
if (state.floor > 1) {
|
||||
if (state.floor > 2) {
|
||||
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
|
||||
auto y = static_cast<uint8_t>(1 + (nextRandom() % 5));
|
||||
if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall;
|
||||
}
|
||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(state.floor / 3));
|
||||
uint16_t realFloor = state.floor - 1;
|
||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
|
||||
addEnemy(EntityType::Bat, {3, 5}, 1);
|
||||
if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||
}
|
||||
|
||||
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
|
||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||
const auto& entity = state.entities[i];
|
||||
if (entity.alive && entity.pos == pos) return i;
|
||||
const auto& e = state.entities[i];
|
||||
if (e.alive && e.pos == pos) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -70,11 +95,8 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||
if (enemyIdx >= 0) {
|
||||
Entity& enemy = state.entities[enemyIdx];
|
||||
enemy.hp -= 1;
|
||||
if (enemy.hp <= 0) {
|
||||
enemy.alive = false;
|
||||
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
|
||||
}
|
||||
moveMonsters();
|
||||
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
|
||||
if (!isTutorial()) moveMonsters();
|
||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||
return MoveResult::Attacked;
|
||||
}
|
||||
@@ -87,15 +109,19 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||
result = MoveResult::Treasure;
|
||||
} else if (tile == Tile::Stairs) {
|
||||
state.floor += 1;
|
||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
if (state.floor >= 2) {
|
||||
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
}
|
||||
generateFloor();
|
||||
return MoveResult::Stairs;
|
||||
}
|
||||
moveMonsters();
|
||||
if (!isTutorial()) moveMonsters();
|
||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
if (state.floor >= 2) {
|
||||
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -105,26 +131,19 @@ void DungeonModel::moveMonsters() {
|
||||
if (!enemy.alive) continue;
|
||||
const int16_t dx = state.playerPos.x - enemy.pos.x;
|
||||
const int16_t dy = state.playerPos.y - enemy.pos.y;
|
||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) {
|
||||
state.hp -= 1;
|
||||
continue;
|
||||
}
|
||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { state.hp -= 1; continue; }
|
||||
GameKit::GridPos target = enemy.pos;
|
||||
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
|
||||
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
|
||||
const int* dir = dirs[nextRandom() % 4];
|
||||
target.x += dir[0];
|
||||
target.y += dir[1];
|
||||
target.x += dir[0]; target.y += dir[1];
|
||||
} else if (LV_ABS(dx) > LV_ABS(dy)) {
|
||||
target.x += dx > 0 ? 1 : -1;
|
||||
} else if (dy != 0) {
|
||||
target.y += dy > 0 ? 1 : -1;
|
||||
}
|
||||
if (target == state.playerPos) {
|
||||
state.hp -= 1;
|
||||
} else if (isWalkable(target) && findEnemyAt(target) < 0) {
|
||||
enemy.pos = target;
|
||||
}
|
||||
if (target == state.playerPos) state.hp -= 1;
|
||||
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#include <cstdint>
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
static constexpr uint8_t DUNGEON_COLS = 9;
|
||||
static constexpr uint8_t DUNGEON_ROWS = 7;
|
||||
static constexpr uint8_t TUTORIAL_ROWS = 2;
|
||||
static constexpr uint8_t MAX_ENTITIES = 8;
|
||||
|
||||
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
|
||||
@@ -25,10 +25,11 @@ struct DungeonState {
|
||||
Entity entities[MAX_ENTITIES] {};
|
||||
uint8_t entityCount = 0;
|
||||
GameKit::GridPos playerPos {1, 1};
|
||||
uint16_t floor = 1;
|
||||
uint16_t floor = 0; // 0,1 tutorial, 2+ real
|
||||
uint16_t gold = 0;
|
||||
int8_t hp = 5;
|
||||
bool gameOver = false;
|
||||
uint8_t renderRows = DUNGEON_ROWS;
|
||||
};
|
||||
|
||||
class DungeonModel {
|
||||
@@ -42,9 +43,11 @@ class DungeonModel {
|
||||
int findEnemyAt(GameKit::GridPos pos) const;
|
||||
bool isWalkable(GameKit::GridPos pos) const;
|
||||
void moveMonsters();
|
||||
void generateTutorialFloor(uint8_t tId);
|
||||
void clearAllTiles();
|
||||
|
||||
public:
|
||||
void reset(uint32_t seedValue = 0xC0FFEE);
|
||||
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
|
||||
void generateFloor();
|
||||
MoveResult movePlayer(int dx, int dy);
|
||||
|
||||
@@ -52,6 +55,9 @@ public:
|
||||
uint16_t getBestFloor() const { return bestFloor; }
|
||||
uint16_t getBestGold() const { return bestGold; }
|
||||
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
|
||||
bool isTutorial() const { return state.floor < 2; }
|
||||
uint8_t tutorialId() const { return static_cast<uint8_t>(state.floor); }
|
||||
uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; }
|
||||
};
|
||||
|
||||
} // namespace PocketDungeon
|
||||
|
||||
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
status = lv_label_create(root);
|
||||
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
|
||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8);
|
||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
|
||||
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
||||
const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
|
||||
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS);
|
||||
@@ -48,8 +48,7 @@ lv_color_t DungeonRenderer::tileColor(Tile tile) {
|
||||
case Tile::Wall: return lv_color_hex(COLOR_WALL);
|
||||
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
|
||||
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
|
||||
case Tile::Floor:
|
||||
default: return lv_color_hex(COLOR_FLOOR);
|
||||
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,48 +64,64 @@ lv_color_t DungeonRenderer::entityColor(EntityType type) {
|
||||
switch (type) {
|
||||
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
|
||||
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
|
||||
case EntityType::Slime:
|
||||
default: return lv_color_hex(COLOR_SLIME);
|
||||
case EntityType::Slime: default: return lv_color_hex(COLOR_SLIME);
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonRenderer::clearEntityLabels() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
lv_label_set_text(labels[y][x], "");
|
||||
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
|
||||
const DungeonState& state = model.getState();
|
||||
char buf[96];
|
||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||
if (model.isTutorial()) {
|
||||
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||
}
|
||||
lv_label_set_text(status, buf);
|
||||
clearEntityLabels();
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
bool visible = y < state.renderRows;
|
||||
if (!visible) {
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) lv_obj_add_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||
continue;
|
||||
}
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
lv_obj_clear_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||
const Tile tile = state.tiles[y][x];
|
||||
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
|
||||
lv_label_set_text(labels[y][x], tileText(tile));
|
||||
}
|
||||
}
|
||||
if (state.renderRows == TUTORIAL_ROWS) {
|
||||
lv_obj_set_height(board, grid.cellPx * TUTORIAL_ROWS);
|
||||
lv_obj_align(board, LV_ALIGN_CENTER, 0, -10);
|
||||
} else {
|
||||
lv_obj_set_height(board, grid.rows * grid.cellPx);
|
||||
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
|
||||
}
|
||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||
const Entity& entity = state.entities[i];
|
||||
if (!entity.alive) continue;
|
||||
const char* symbol = entity.type == EntityType::Bat ? "b" : "s";
|
||||
lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol);
|
||||
lv_obj_set_style_text_color(labels[entity.pos.y][entity.pos.x], entityColor(entity.type), LV_PART_MAIN);
|
||||
const Entity& e = state.entities[i];
|
||||
if (!e.alive) continue;
|
||||
const char* sym = e.type == EntityType::Bat ? "b" : "s";
|
||||
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
|
||||
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN);
|
||||
}
|
||||
lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@");
|
||||
lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
|
||||
const char* text = "Move with arrows, swipe, or tap a direction";
|
||||
|
||||
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
|
||||
switch (lastResult) {
|
||||
case MoveResult::Blocked: text = "A wall blocks the way"; break;
|
||||
case MoveResult::Attacked: text = "You strike the monster"; break;
|
||||
case MoveResult::Treasure: text = "Treasure found!"; break;
|
||||
case MoveResult::Stairs: text = "Down the stairs..."; break;
|
||||
case MoveResult::Blocked: text = "Wall blocks"; break;
|
||||
case MoveResult::Attacked: text = "You strike!"; break;
|
||||
case MoveResult::Treasure: text = "Treasure! +5"; break;
|
||||
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
|
||||
case MoveResult::Dead: text = "Game over - tap to restart"; break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "PocketDungeon.h"
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <cstdio>
|
||||
extern "C" {
|
||||
#include <tt_app.h>
|
||||
}
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
@@ -7,19 +10,26 @@ static constexpr uint32_t TICK_MS = 200;
|
||||
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
|
||||
static constexpr const char* PREF_BEST_GOLD = "bestGold";
|
||||
|
||||
static constexpr uint32_t COLOR_BG = 0x10111A;
|
||||
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
|
||||
static constexpr uint32_t COLOR_ACCENT = 0x365CF5;
|
||||
static constexpr uint32_t COLOR_OVERLAY = 0x000000;
|
||||
|
||||
void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||
root = parent;
|
||||
(void) tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
model.setBests(static_cast<uint16_t>(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast<uint16_t>(prefs.getInt(PREF_BEST_GOLD, 0)));
|
||||
model.reset(lv_tick_get());
|
||||
model.reset(lv_tick_get(), true);
|
||||
if (sfx == nullptr) {
|
||||
sfx = new SfxEngine();
|
||||
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
||||
}
|
||||
phase = AppPhase::Intro;
|
||||
onEnter(parent);
|
||||
loop.start(TICK_MS, this);
|
||||
(void) app;
|
||||
}
|
||||
|
||||
void PocketDungeon::onHide(AppHandle app) {
|
||||
@@ -27,63 +37,300 @@ void PocketDungeon::onHide(AppHandle app) {
|
||||
loop.stop();
|
||||
onExit();
|
||||
saveBests();
|
||||
if (sfx != nullptr) {
|
||||
sfx->stop();
|
||||
delete sfx;
|
||||
sfx = nullptr;
|
||||
}
|
||||
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
|
||||
root = nullptr;
|
||||
}
|
||||
|
||||
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
|
||||
renderer.create(sceneRoot);
|
||||
GameKit::attachInput(sceneRoot, this);
|
||||
root = sceneRoot;
|
||||
GameKit::attachInput(root, this);
|
||||
showIntro();
|
||||
lastResult = MoveResult::None;
|
||||
}
|
||||
|
||||
void PocketDungeon::onExit() { clearIntro(); clearGame(); }
|
||||
|
||||
void PocketDungeon::attachInputToCurrent() {
|
||||
if (root) GameKit::attachInput(root, this);
|
||||
if (introContainer) {
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
GameKit::attachInput(introContainer, this);
|
||||
}
|
||||
if (gameContainer) {
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
GameKit::attachInput(gameContainer, this);
|
||||
lv_obj_add_event_cb(gameContainer, onGameContainerLongPress, LV_EVENT_LONG_PRESSED, this);
|
||||
}
|
||||
if (pauseOverlay) {
|
||||
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
GameKit::attachInput(pauseOverlay, this);
|
||||
}
|
||||
}
|
||||
|
||||
void PocketDungeon::clearIntro() { if (introContainer) { lv_obj_delete(introContainer); introContainer = nullptr; } }
|
||||
void PocketDungeon::clearGame() {
|
||||
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||
if (gameContainer) { lv_obj_delete(gameContainer); gameContainer = nullptr; }
|
||||
}
|
||||
|
||||
void PocketDungeon::showIntro() {
|
||||
clearGame(); clearIntro();
|
||||
phase = AppPhase::Intro;
|
||||
introContainer = lv_obj_create(root);
|
||||
lv_obj_set_size(introContainer, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(introContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(introContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(introContainer, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||
lv_obj_remove_flag(introContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(introContainer, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(introContainer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(introContainer, 6, LV_PART_MAIN);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
renderIntro();
|
||||
attachInputToCurrent();
|
||||
}
|
||||
|
||||
void PocketDungeon::showGame() {
|
||||
clearIntro(); clearGame();
|
||||
phase = AppPhase::Playing;
|
||||
gameContainer = lv_obj_create(root);
|
||||
lv_obj_set_size(gameContainer, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(gameContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(gameContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(gameContainer, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(gameContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
renderer.create(gameContainer);
|
||||
lv_obj_t* pauseBtn = lv_btn_create(gameContainer);
|
||||
lv_obj_set_size(pauseBtn, 32, 26);
|
||||
lv_obj_set_style_bg_color(pauseBtn, lv_color_hex(0x2A2D40), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(pauseBtn, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(pauseBtn, 0, LV_PART_MAIN);
|
||||
lv_obj_align(pauseBtn, LV_ALIGN_TOP_RIGHT, -6, 4);
|
||||
lv_obj_add_event_cb(pauseBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* pauseLbl = lv_label_create(pauseBtn);
|
||||
lv_label_set_text(pauseLbl, LV_SYMBOL_CLOSE);
|
||||
lv_obj_center(pauseLbl);
|
||||
lastResult = MoveResult::None;
|
||||
attachInputToCurrent();
|
||||
render();
|
||||
if (sfx) sfx->play(SfxId::Warp);
|
||||
}
|
||||
|
||||
void PocketDungeon::showPause() {
|
||||
if (pauseOverlay) return;
|
||||
phase = AppPhase::Paused;
|
||||
pauseOverlay = lv_obj_create(root);
|
||||
lv_obj_set_size(pauseOverlay, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(pauseOverlay, 12, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(pauseOverlay, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(pauseOverlay, lv_color_hex(COLOR_OVERLAY), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(pauseOverlay, LV_OPA_70, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(pauseOverlay, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(pauseOverlay, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(pauseOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(pauseOverlay, 10, LV_PART_MAIN);
|
||||
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_t* box = lv_obj_create(pauseOverlay);
|
||||
lv_obj_set_width(box, LV_PCT(80));
|
||||
lv_obj_set_height(box, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(box, 14, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(box, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(box, 12, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(box, 10, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* t = lv_label_create(box);
|
||||
lv_label_set_text(t, "Paused");
|
||||
lv_obj_set_style_text_color(t, lv_color_white(), LV_PART_MAIN);
|
||||
lv_obj_t* resumeBtn = lv_btn_create(box);
|
||||
lv_obj_set_width(resumeBtn, LV_PCT(100));
|
||||
lv_obj_set_height(resumeBtn, 38);
|
||||
lv_obj_set_style_bg_color(resumeBtn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(resumeBtn, 10, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(resumeBtn, onResumeButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* rl = lv_label_create(resumeBtn);
|
||||
lv_label_set_text(rl, "Resume");
|
||||
lv_obj_center(rl);
|
||||
lv_obj_t* exitBtn = lv_btn_create(box);
|
||||
lv_obj_set_width(exitBtn, LV_PCT(100));
|
||||
lv_obj_set_height(exitBtn, 36);
|
||||
lv_obj_set_style_bg_color(exitBtn, lv_color_hex(0x2E303F), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(exitBtn, 10, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(exitBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* el = lv_label_create(exitBtn);
|
||||
lv_label_set_text(el, "Exit Game");
|
||||
lv_obj_center(el);
|
||||
if (sfx) sfx->play(SfxId::MenuOpen);
|
||||
attachInputToCurrent();
|
||||
}
|
||||
|
||||
void PocketDungeon::hidePause() {
|
||||
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||
phase = AppPhase::Playing;
|
||||
if (sfx) sfx->play(SfxId::MenuClose);
|
||||
attachInputToCurrent();
|
||||
render();
|
||||
}
|
||||
|
||||
void PocketDungeon::onExit() {}
|
||||
void PocketDungeon::exitGame() {
|
||||
saveBests();
|
||||
if (sfx) sfx->play(SfxId::Cancel);
|
||||
tt_app_stop();
|
||||
}
|
||||
|
||||
void PocketDungeon::renderIntro() {
|
||||
if (!introContainer) return;
|
||||
lv_obj_t* titleRow = lv_obj_create(introContainer);
|
||||
lv_obj_set_width(titleRow, LV_PCT(100));
|
||||
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
|
||||
lv_obj_t* title = lv_label_create(titleRow);
|
||||
lv_label_set_text(title, "POCKET DUNGEON");
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
|
||||
lv_obj_t* subtitle = lv_label_create(titleRow);
|
||||
lv_label_set_text(subtitle, "Tiny paper roguelike");
|
||||
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
|
||||
char bestBuf[64];
|
||||
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
|
||||
lv_obj_t* best = lv_label_create(titleRow);
|
||||
lv_label_set_text(best, bestBuf);
|
||||
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* mainRow = lv_obj_create(introContainer);
|
||||
lv_obj_set_width(mainRow, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(mainRow, 1);
|
||||
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* leftCol = lv_obj_create(mainRow);
|
||||
lv_obj_set_flex_grow(leftCol, 1);
|
||||
lv_obj_set_height(leftCol, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
|
||||
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
|
||||
lv_obj_t* card = lv_obj_create(parent);
|
||||
lv_obj_set_width(card, LV_PCT(100));
|
||||
lv_obj_set_height(card, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* t = lv_label_create(card);
|
||||
lv_label_set_text(t, tTitle);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
|
||||
lv_obj_t* b = lv_label_create(card);
|
||||
lv_label_set_text(b, body);
|
||||
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
|
||||
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(b, LV_PCT(100));
|
||||
return card;
|
||||
};
|
||||
|
||||
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
|
||||
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
|
||||
|
||||
lv_obj_t* rightRail = lv_obj_create(mainRow);
|
||||
lv_obj_set_width(rightRail, 44);
|
||||
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
|
||||
lv_obj_t* btn = lv_btn_create(parent);
|
||||
lv_obj_set_size(btn, s, s);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
|
||||
lv_obj_t* lbl = lv_label_create(btn);
|
||||
lv_label_set_text(lbl, symbol);
|
||||
lv_obj_center(lbl);
|
||||
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
|
||||
return btn;
|
||||
};
|
||||
|
||||
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
|
||||
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
|
||||
}
|
||||
|
||||
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
|
||||
|
||||
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
|
||||
int dx = 0;
|
||||
int dy = 0;
|
||||
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
|
||||
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; }
|
||||
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
|
||||
int dx=0, dy=0;
|
||||
switch (input.kind) {
|
||||
case GameKit::InputKind::Up: dy = -1; break;
|
||||
case GameKit::InputKind::Down: dy = 1; break;
|
||||
case GameKit::InputKind::Left: dx = -1; break;
|
||||
case GameKit::InputKind::Right: dx = 1; break;
|
||||
case GameKit::InputKind::Up: dy=-1; break;
|
||||
case GameKit::InputKind::Down: dy=1; break;
|
||||
case GameKit::InputKind::Left: dx=-1; break;
|
||||
case GameKit::InputKind::Right: dx=1; break;
|
||||
default: break;
|
||||
}
|
||||
if (model.getState().gameOver) {
|
||||
model.reset(lv_tick_get());
|
||||
lastResult = MoveResult::None;
|
||||
render();
|
||||
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
|
||||
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dx != 0 || dy != 0) {
|
||||
lastResult = model.movePlayer(dx, dy);
|
||||
if (dx!=0||dy!=0) {
|
||||
lastResult=model.movePlayer(dx,dy);
|
||||
playResultSound(lastResult);
|
||||
if (lastResult == MoveResult::Dead) saveBests();
|
||||
if (lastResult==MoveResult::Dead) saveBests();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
void PocketDungeon::onTick(const GameKit::Tick& tick) {
|
||||
(void) tick;
|
||||
}
|
||||
|
||||
void PocketDungeon::render() {
|
||||
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
|
||||
void PocketDungeon::render(){
|
||||
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
|
||||
if (!gameContainer) return;
|
||||
renderer.render(model, lastResult);
|
||||
}
|
||||
|
||||
void PocketDungeon::saveBests() {
|
||||
void PocketDungeon::saveBests(){
|
||||
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
|
||||
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
|
||||
}
|
||||
|
||||
void PocketDungeon::playResultSound(MoveResult result) {
|
||||
if (sfx == nullptr) return;
|
||||
switch (result) {
|
||||
void PocketDungeon::playResultSound(MoveResult r){
|
||||
if (!sfx) return;
|
||||
switch(r){
|
||||
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
|
||||
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
|
||||
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
enum class AppPhase : uint8_t { Intro, Playing, Paused };
|
||||
|
||||
class PocketDungeon final : public App, public GameKit::Scene {
|
||||
lv_obj_t* root = nullptr;
|
||||
lv_obj_t* introContainer = nullptr;
|
||||
lv_obj_t* gameContainer = nullptr;
|
||||
lv_obj_t* pauseOverlay = nullptr;
|
||||
AppPhase phase = AppPhase::Intro;
|
||||
|
||||
GameKit::Loop loop;
|
||||
GameKit::Prefs prefs {"PocketDungeon"};
|
||||
DungeonModel model;
|
||||
@@ -20,6 +27,20 @@ class PocketDungeon final : public App, public GameKit::Scene {
|
||||
|
||||
void playResultSound(MoveResult result);
|
||||
void saveBests();
|
||||
void showIntro();
|
||||
void showGame();
|
||||
void showPause();
|
||||
void hidePause();
|
||||
void clearIntro();
|
||||
void clearGame();
|
||||
void renderIntro();
|
||||
void attachInputToCurrent();
|
||||
void exitGame();
|
||||
|
||||
static void onStartButtonClicked(lv_event_t* e);
|
||||
static void onResumeButtonClicked(lv_event_t* e);
|
||||
static void onExitButtonClicked(lv_event_t* e);
|
||||
static void onGameContainerLongPress(lv_event_t* e);
|
||||
|
||||
public:
|
||||
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
[app]
|
||||
id=one.tactility.pocketdungeon
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
versionName=0.1.2
|
||||
versionCode=3
|
||||
name=Pocket Dungeon
|
||||
description=Tiny paper-inspired dungeon crawler
|
||||
description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
|
||||
flags=HideStatusBar
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(RobotArm)
|
||||
tactility_project(RobotArm)
|
||||
@@ -0,0 +1,6 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
REQUIRES TactilitySDK lwip
|
||||
)
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function)
|
||||
@@ -0,0 +1,429 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/lvgl_fonts.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <lwip/sockets.h>
|
||||
#include <lwip/inet.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "RobotArm"
|
||||
#define ARM_HOST "192.168.68.103"
|
||||
#define ARM_PORT 80
|
||||
#define ARM_PATH "/api/mcp"
|
||||
#define NUM_JOINTS 6
|
||||
#define SEQ_MAX 16
|
||||
|
||||
typedef struct {
|
||||
const char* name;
|
||||
const char* cute;
|
||||
uint32_t bg;
|
||||
uint32_t accent;
|
||||
int home, rmin, rmax;
|
||||
int value, last_sent;
|
||||
} Joint;
|
||||
|
||||
static Joint joints[NUM_JOINTS] = {
|
||||
{"base","Base",0xFFD6E0,0xFF8FA8,70,0,180,70,-1},
|
||||
{"shoulder","Shldr",0xD6E8FF,0x8FB6FF,40,0,70,40,-1},
|
||||
{"elbow","Elbow",0xFFE8C5,0xFFB86A,20,0,120,20,-1},
|
||||
{"pitch","Pitch",0xD5F0D5,0x88D488,90,30,150,90,-1},
|
||||
{"roll","Roll",0xE8D5FF,0xB088FF,90,30,150,90,-1},
|
||||
{"gripper","Grip",0xFFF0B3,0xFFD060,90,0,180,90,-1},
|
||||
};
|
||||
|
||||
typedef struct { int v[NUM_JOINTS]; } Frame;
|
||||
static Frame seq[SEQ_MAX];
|
||||
static int seq_len = 0, seq_idx = -1;
|
||||
static bool seq_playing = false;
|
||||
static int seq_play_pos = 0;
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
lv_obj_t* status;
|
||||
lv_obj_t* sliders[6];
|
||||
lv_obj_t* vals[6];
|
||||
lv_obj_t* seq_label;
|
||||
lv_obj_t* play_label;
|
||||
lv_obj_t* blocks[SEQ_MAX];
|
||||
int pend[6];
|
||||
bool has[6];
|
||||
int ticks[6];
|
||||
lv_timer_t* poll;
|
||||
lv_timer_t* seq_timer;
|
||||
} Ctx;
|
||||
static Ctx* g = NULL;
|
||||
|
||||
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||
|
||||
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
||||
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||
if(fd<0) return -1;
|
||||
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host);
|
||||
struct timeval tv={5,0};
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
||||
char hdr[256];
|
||||
int bl=strlen(body);
|
||||
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
||||
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
||||
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
||||
int tot=0;
|
||||
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||
out[tot]='\0'; close(fd);
|
||||
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
||||
return tot>0?0:-4;
|
||||
}
|
||||
|
||||
static bool jget(const char* js,const char* key,int* out){
|
||||
const char* p=strstr(js,key);
|
||||
if(!p) return false;
|
||||
p+=strlen(key);
|
||||
while(*p && *p!=':'){
|
||||
p++;
|
||||
if(!*p) return false;
|
||||
}
|
||||
p++;
|
||||
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
||||
int sign=1;
|
||||
if(*p=='-'){sign=-1;p++;}
|
||||
float v=0,frac=0.1f;
|
||||
bool dot=false,got=false;
|
||||
while(*p){
|
||||
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
||||
else if(*p=='.'&&!dot) dot=true;
|
||||
else break;
|
||||
p++;
|
||||
}
|
||||
if(!got) return false;
|
||||
*out=(int)(v*sign+0.5f);
|
||||
return true;
|
||||
}
|
||||
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
int v; bool ok=true;
|
||||
if(jget(r,"base",&v)) *b=v; else ok=false;
|
||||
if(jget(r,"shoulder",&v)) *s=v; else ok=false;
|
||||
if(jget(r,"elbow",&v)) *e=v; else ok=false;
|
||||
if(jget(r,"pitch",&v)) *p=v; else ok=false;
|
||||
if(jget(r,"roll",&v)) *ro=v; else ok=false;
|
||||
if(jget(r,"gripper",&v)) *gr=v; else ok=false;
|
||||
return ok;
|
||||
}
|
||||
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
||||
char resp[2048]; memset(resp,0,sizeof(resp));
|
||||
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||
return parse_state(resp,b,s,e,p,ro,gr);
|
||||
}
|
||||
static bool rpc_move(const char* j,int a){
|
||||
char body[300]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",
|
||||
j,a);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
}
|
||||
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
||||
char body[420]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
||||
b,s,e,p,ro,gr,dur);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
|
||||
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
|
||||
return rc==0;
|
||||
}
|
||||
static bool rpc_home(void){
|
||||
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
||||
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||
}
|
||||
|
||||
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
||||
static void apply_ui(void){
|
||||
if(!g) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
if(g->sliders[i]) lv_slider_set_value(g->sliders[i],joints[i].value,LV_ANIM_OFF);
|
||||
if(g->vals[i]){char buf[8]; snprintf(buf,sizeof(buf),"%d",joints[i].value); lv_label_set_text(g->vals[i],buf);}
|
||||
}
|
||||
}
|
||||
static void refresh_arm(void){
|
||||
set_status("Reading .103...");
|
||||
int b,s,e,p,ro,gr;
|
||||
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
||||
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
||||
joints[3].value=p; joints[4].value=ro; joints[5].value=gr;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
||||
apply_ui();
|
||||
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
||||
} else set_status("No .103");
|
||||
}
|
||||
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void poll_cb(lv_timer_t* t){
|
||||
(void)t; if(!g) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
if(!g->has[i]) continue;
|
||||
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
||||
g->has[i]=false; g->ticks[i]=0;
|
||||
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
||||
joints[i].last_sent=tgt; joints[i].value=tgt;
|
||||
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||
bool ok=rpc_move(joints[i].name,tgt);
|
||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
static void slider_cb(lv_event_t* e){
|
||||
int idx=(int)(intptr_t)lv_event_get_user_data(e);
|
||||
int v=(int)lv_slider_get_value(lv_event_get_target(e));
|
||||
joints[idx].value=v;
|
||||
if(g){
|
||||
if(g->vals[idx]){char b[8]; snprintf(b,sizeof(b),"%d",v); lv_label_set_text(g->vals[idx],b);}
|
||||
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── sequencer ── */
|
||||
static void update_seq_ui(void){
|
||||
if(!g) return;
|
||||
if(g->seq_label){
|
||||
if(seq_len==0) lv_label_set_text(g->seq_label,"empty");
|
||||
else {char b[16]; snprintf(b,sizeof(b),"%d/%d", (seq_idx>=0?seq_idx+1:seq_len), seq_len); lv_label_set_text(g->seq_label,b);}
|
||||
}
|
||||
for(int i=0;i<SEQ_MAX;i++){
|
||||
if(!g->blocks[i]) continue;
|
||||
if(i<seq_len){
|
||||
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_COVER,0);
|
||||
if(i==seq_idx){
|
||||
lv_obj_set_style_border_width(g->blocks[i],3,0);
|
||||
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0xFFFFFF),0);
|
||||
} else {
|
||||
lv_obj_set_style_border_width(g->blocks[i],2,0);
|
||||
lv_obj_set_style_border_color(g->blocks[i],lv_color_hex(0x3A3A3A),0);
|
||||
}
|
||||
} else {
|
||||
lv_obj_set_style_bg_opa(g->blocks[i],LV_OPA_30,0);
|
||||
lv_obj_set_style_border_width(g->blocks[i],0,0);
|
||||
}
|
||||
}
|
||||
}
|
||||
static void load_frame(int fidx){
|
||||
if(fidx<0||fidx>=seq_len) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=seq[fidx].v[i]; joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false;}}
|
||||
apply_ui(); seq_idx=fidx; update_seq_ui();
|
||||
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
||||
}
|
||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||
static void seq_rem_cb(void){
|
||||
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
||||
int rem=seq_idx;
|
||||
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
||||
if(seq_idx>=seq_len) seq_idx=seq_len-1;
|
||||
load_frame(seq_idx);
|
||||
}
|
||||
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
||||
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
||||
|
||||
static void seq_timer_cb(lv_timer_t* t){
|
||||
(void)t; if(!seq_playing||seq_len==0) return;
|
||||
int f=seq_play_pos%seq_len;
|
||||
int* v=seq[f].v;
|
||||
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
||||
set_status("Seq fail"); seq_playing=false;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
|
||||
return;
|
||||
}
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
||||
apply_ui(); seq_idx=f; update_seq_ui();
|
||||
char b[20]; snprintf(b,sizeof(b),"Play %d/%d",f+1,seq_len); set_status(b);
|
||||
seq_play_pos++; if(seq_play_pos>=seq_len) seq_play_pos=0;
|
||||
}
|
||||
static void seq_play_cb(lv_event_t* e){
|
||||
(void)e; if(seq_len==0){set_status("No frames"); return;}
|
||||
seq_playing=!seq_playing;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label, seq_playing?"Pause":"Play");
|
||||
if(seq_playing){seq_play_pos=(seq_idx>=0?seq_idx:0); set_status("Playing");} else set_status("Paused");
|
||||
}
|
||||
static void seq_add_ev(lv_event_t* e){(void)e; seq_add_cb();}
|
||||
static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
|
||||
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
||||
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
||||
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
||||
|
||||
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
||||
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
||||
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
||||
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
||||
|
||||
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
(void)data;
|
||||
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
|
||||
c->app=app; g=c;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
||||
|
||||
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
||||
|
||||
lv_obj_t* root=lv_obj_create(parent);
|
||||
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0);
|
||||
lv_obj_set_style_border_width(root,0,0);
|
||||
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
||||
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
||||
lv_obj_set_scroll_dir(root, LV_DIR_VER);
|
||||
|
||||
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0);
|
||||
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
||||
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
|
||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
|
||||
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0);
|
||||
|
||||
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16);
|
||||
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
||||
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
||||
for(int i=0;i<4;i++){
|
||||
lv_obj_t* b=lv_btn_create(brow); lv_obj_set_size(b,56,18);
|
||||
lv_obj_set_style_bg_color(b,lv_color_hex(btns[i].col),0); lv_obj_set_style_radius(b,8,0);
|
||||
lv_obj_set_pos(b,i*62+2,0);
|
||||
lv_obj_t* l=lv_label_create(b); lv_label_set_text(l,btns[i].t);
|
||||
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(l,lv_color_hex(0x4A356A),0); lv_obj_center(l);
|
||||
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
|
||||
lv_obj_t* grid=lv_obj_create(root);
|
||||
lv_obj_set_size(grid,LV_PCT(100),196);
|
||||
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36);
|
||||
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
int col=i%3, row=i/3;
|
||||
lv_obj_t* card=lv_obj_create(grid);
|
||||
lv_obj_set_size(card,102,96);
|
||||
lv_obj_set_pos(card,col*104+2,row*98);
|
||||
lv_obj_set_style_bg_color(card,lv_color_hex(joints[i].bg),0);
|
||||
lv_obj_set_style_bg_opa(card,LV_OPA_90,0);
|
||||
lv_obj_set_style_radius(card,12,0);
|
||||
lv_obj_set_style_border_width(card,0,0);
|
||||
lv_obj_set_style_pad_all(card,3,0);
|
||||
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* name=lv_label_create(card);
|
||||
lv_label_set_text(name,joints[i].cute);
|
||||
lv_obj_set_style_text_font(name,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(name,lv_color_hex(0x4A356A),0); lv_obj_set_pos(name,2,0);
|
||||
|
||||
c->vals[i]=lv_label_create(card);
|
||||
char vb[8]; snprintf(vb,sizeof(vb),"%d",joints[i].value);
|
||||
lv_label_set_text(c->vals[i],vb);
|
||||
lv_obj_set_style_text_font(c->vals[i],lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->vals[i],lv_color_hex(0xC04060),0); lv_obj_set_pos(c->vals[i],40,0);
|
||||
|
||||
lv_obj_t* rlbl=lv_label_create(card);
|
||||
char rb[12]; snprintf(rb,sizeof(rb),"%d-%d",joints[i].rmin,joints[i].rmax);
|
||||
lv_label_set_text(rlbl,rb);
|
||||
lv_obj_set_style_text_font(rlbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(rlbl,lv_color_hex(0xA090A0),0); lv_obj_set_pos(rlbl,58,0);
|
||||
|
||||
lv_obj_t* sl=lv_slider_create(card);
|
||||
c->sliders[i]=sl;
|
||||
lv_obj_set_size(sl,22,72);
|
||||
lv_obj_set_pos(sl,40,20);
|
||||
lv_slider_set_range(sl,joints[i].rmin,joints[i].rmax);
|
||||
lv_slider_set_value(sl,joints[i].value,LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(sl,LV_OPA_80,LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(sl,10,LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(sl,lv_color_hex(joints[i].accent),LV_PART_INDICATOR);
|
||||
lv_obj_set_style_radius(sl,10,LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(sl,lv_color_hex(0xFFFFFF),LV_PART_KNOB);
|
||||
lv_obj_set_style_border_color(sl,lv_color_hex(joints[i].accent),LV_PART_KNOB);
|
||||
lv_obj_set_style_border_width(sl,2,LV_PART_KNOB);
|
||||
lv_obj_set_style_radius(sl,12,LV_PART_KNOB);
|
||||
lv_obj_set_style_pad_all(sl,3,LV_PART_KNOB);
|
||||
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
||||
}
|
||||
|
||||
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
|
||||
visual: colored blocks, no info, current highlighted, larger buttons */
|
||||
lv_obj_t* seqbar=lv_obj_create(root);
|
||||
lv_obj_set_size(seqbar,316,96);
|
||||
lv_obj_set_pos(seqbar,2,232);
|
||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
||||
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
||||
lv_obj_set_style_radius(seqbar,12,0);
|
||||
lv_obj_set_style_border_width(seqbar,1,0);
|
||||
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
||||
lv_obj_set_style_pad_all(seqbar,4,0);
|
||||
|
||||
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
||||
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
||||
|
||||
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
||||
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
||||
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
||||
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
||||
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
||||
for(int i=0;i<5;i++){
|
||||
lv_obj_t* b=lv_btn_create(seqbar);
|
||||
lv_obj_set_size(b,(i==2)?56:38,32);
|
||||
lv_obj_set_style_bg_color(b,lv_color_hex(sbtns[i].col),0);
|
||||
lv_obj_set_style_radius(b,8,0);
|
||||
int xs[5]={64,110,158,220,260};
|
||||
lv_obj_set_pos(b,xs[i],0);
|
||||
lv_obj_t* l=lv_label_create(b); if(sbtns[i].play) c->play_label=l;
|
||||
lv_label_set_text(l,sbtns[i].t);
|
||||
lv_obj_set_style_text_font(l,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
||||
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
uint32_t blk_cols[16]={
|
||||
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
||||
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
||||
};
|
||||
for(int i=0;i<SEQ_MAX;i++){
|
||||
lv_obj_t* blk=lv_btn_create(seqbar);
|
||||
lv_obj_set_size(blk,22,22);
|
||||
lv_obj_set_pos(blk,(i%8)*24+2,(i/8)*24+38);
|
||||
lv_obj_set_style_bg_color(blk,lv_color_hex(blk_cols[i]),0);
|
||||
lv_obj_set_style_radius(blk,6,0);
|
||||
lv_obj_set_style_border_width(blk,0,0);
|
||||
lv_obj_set_style_bg_opa(blk,LV_OPA_30,0);
|
||||
c->blocks[i]=blk;
|
||||
lv_obj_add_event_cb(blk,block_click_cb,LV_EVENT_CLICKED,(void*)(intptr_t)i);
|
||||
}
|
||||
update_seq_ui();
|
||||
|
||||
c->poll=lv_timer_create(poll_cb,100,NULL);
|
||||
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||
lv_timer_create(init_cb,600,NULL);
|
||||
}
|
||||
|
||||
static void onHide(AppHandle app,void* data){
|
||||
(void)app;(void)data;
|
||||
if(g){
|
||||
if(g->poll) lv_timer_delete(g->poll);
|
||||
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
||||
free(g); g=NULL;
|
||||
}
|
||||
seq_playing=false;
|
||||
}
|
||||
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
||||
@@ -0,0 +1,13 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
|
||||
[app]
|
||||
id=one.tactility.robotarm
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Robot Arm
|
||||
description=Cute controller for MCP robot arm
|
||||
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(VoiceRecorder)
|
||||
tactility_project(VoiceRecorder)
|
||||
@@ -0,0 +1,6 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
@@ -1,16 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
|
||||
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
|
||||
*/
|
||||
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -42,7 +41,9 @@ typedef enum {
|
||||
} AudioState;
|
||||
|
||||
typedef struct {
|
||||
struct Device* i2s_dev;
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
AudioStreamHandle output_handle;
|
||||
uint8_t* audio_buf;
|
||||
AudioState state;
|
||||
int volume;
|
||||
@@ -65,19 +66,19 @@ typedef struct {
|
||||
} 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
|
||||
char riff[4];
|
||||
uint32_t overall_size;
|
||||
char wave[4];
|
||||
char fmt_chunk_marker[4];
|
||||
uint32_t length_of_fmt;
|
||||
uint16_t format_type;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint32_t byterate;
|
||||
uint16_t block_align;
|
||||
uint16_t bits_per_sample;
|
||||
char data_chunk_header[4];
|
||||
uint32_t data_size;
|
||||
} WavHeader;
|
||||
|
||||
static AppCtx g_ctx;
|
||||
@@ -87,6 +88,20 @@ static void update_ui(AppCtx* ctx);
|
||||
static void refresh_memo_list(AppCtx* ctx);
|
||||
static void on_memo_selected(lv_event_t* e);
|
||||
|
||||
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||
struct Device* dev = device_find_by_name("audio-stream");
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ─── 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;
|
||||
@@ -114,10 +129,8 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
||||
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];
|
||||
@@ -125,24 +138,21 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
||||
|
||||
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 ─── */
|
||||
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */
|
||||
static void record_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
char filepath[256];
|
||||
@@ -164,29 +174,24 @@ static void record_task(void* arg) {
|
||||
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,
|
||||
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
|
||||
struct AudioStreamConfig in_cfg = {
|
||||
.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
|
||||
.channels = 1
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
error_t err = audio_stream_open_input(ctx->stream_dev, &in_cfg, &ctx->input_handle);
|
||||
if (err == ERROR_NONE) {
|
||||
// Ensure mic is unmuted and at max gain (ES8311 0..24dB)
|
||||
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
|
||||
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
|
||||
ESP_LOGI(TAG, "Mic unmuted, gain 100%%");
|
||||
}
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
|
||||
fclose(f);
|
||||
unlink(filepath);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
@@ -199,68 +204,47 @@ static void record_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Recording started -> %s", filepath);
|
||||
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
|
||||
|
||||
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));
|
||||
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
|
||||
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);
|
||||
// Data already 16k mono from audio-stream resampler, no downmix needed
|
||||
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f);
|
||||
if (written != bytes_read) {
|
||||
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read);
|
||||
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);
|
||||
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
// Rewrite WAV header with actual recorded size
|
||||
audio_stream_close(ctx->input_handle);
|
||||
ctx->input_handle = NULL;
|
||||
|
||||
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) {
|
||||
@@ -268,7 +252,7 @@ static void record_task(void* arg) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
update_ui(ctx);
|
||||
tt_lvgl_unlock();
|
||||
|
||||
@@ -276,7 +260,7 @@ static void record_task(void* arg) {
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ─── Playback Task ─── */
|
||||
/* ─── Playback Task (audio-stream output with resampling) ─── */
|
||||
static void play_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
||||
@@ -305,7 +289,6 @@ static void play_task(void* arg) {
|
||||
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");
|
||||
@@ -320,7 +303,6 @@ static void play_task(void* arg) {
|
||||
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);
|
||||
@@ -334,21 +316,16 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Configure I2S based on file metadata */
|
||||
struct I2sConfig cfg = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
|
||||
struct AudioStreamConfig out_cfg = {
|
||||
.sample_rate = header.sample_rate,
|
||||
.bits_per_sample = header.bits_per_sample,
|
||||
.channel_left = 0,
|
||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
.channels = header.channels
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
|
||||
fclose(f);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
@@ -360,7 +337,7 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
|
||||
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
|
||||
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
||||
|
||||
size_t total_played = 0;
|
||||
@@ -371,40 +348,35 @@ static void play_task(void* arg) {
|
||||
fseek(f, sizeof(WavHeader), SEEK_SET);
|
||||
}
|
||||
|
||||
bool i2s_configured = true;
|
||||
bool out_open = 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;
|
||||
if (out_open) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
out_open = 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 (!out_open) {
|
||||
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
|
||||
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
|
||||
break;
|
||||
}
|
||||
i2s_configured = true;
|
||||
out_open = 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
|
||||
break;
|
||||
}
|
||||
|
||||
// Scaling Volume
|
||||
// Volume scaling
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
||||
size_t sample_count = read_bytes / sizeof(int16_t);
|
||||
@@ -417,22 +389,19 @@ static void play_task(void* arg) {
|
||||
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));
|
||||
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "I2S playback write error: %d", err);
|
||||
ESP_LOGE(TAG, "audio_stream write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
|
||||
if (write_err) {
|
||||
break;
|
||||
}
|
||||
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;
|
||||
@@ -452,10 +421,10 @@ static void play_task(void* arg) {
|
||||
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);
|
||||
if (out_open && ctx->output_handle) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
}
|
||||
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
@@ -470,12 +439,12 @@ static void play_task(void* arg) {
|
||||
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;
|
||||
if (ctx->stream_dev == NULL) return;
|
||||
|
||||
ctx->state = STATE_RECORDING;
|
||||
update_ui(ctx);
|
||||
|
||||
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
|
||||
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
|
||||
}
|
||||
|
||||
static void on_play_pause_click(lv_event_t* e) {
|
||||
@@ -485,7 +454,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
||||
if (ctx->state == STATE_IDLE) {
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
|
||||
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle);
|
||||
} else if (ctx->state == STATE_PLAYING) {
|
||||
ctx->state = STATE_PAUSED;
|
||||
update_ui(ctx);
|
||||
@@ -523,14 +492,12 @@ 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);
|
||||
@@ -568,7 +535,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
||||
}
|
||||
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) {
|
||||
@@ -580,7 +546,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -624,7 +589,7 @@ static void update_ui(AppCtx* ctx) {
|
||||
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);
|
||||
@@ -647,115 +612,100 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
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!");
|
||||
if (!find_audio_stream_device(&g_ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device 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_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||
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_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
|
||||
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
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||
|
||||
// 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_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
|
||||
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_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
|
||||
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_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
|
||||
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_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
|
||||
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_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
|
||||
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");
|
||||
if (g_ctx.stream_dev == NULL) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream 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);
|
||||
@@ -778,16 +728,17 @@ static void onHideApp(AppHandle app, void* data) {
|
||||
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.input_handle) {
|
||||
audio_stream_close(g_ctx.input_handle);
|
||||
g_ctx.input_handle = NULL;
|
||||
}
|
||||
if (g_ctx.output_handle) {
|
||||
audio_stream_close(g_ctx.output_handle);
|
||||
g_ctx.output_handle = NULL;
|
||||
}
|
||||
|
||||
if (g_ctx.audio_buf != NULL) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.voicerecorder
|
||||
name=Voice Recorder
|
||||
versionName=1.0.0
|
||||
versionCode=1
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
@@ -277,7 +278,8 @@ private:
|
||||
// State
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
Device* i2sDevice_ = nullptr;
|
||||
Device* streamDevice_ = nullptr;
|
||||
AudioStreamHandle streamHandle_ = nullptr;
|
||||
TaskHandle_t task_ = nullptr;
|
||||
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
||||
QueueHandle_t msgQueue_ = nullptr;
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
#include "SfxEngine.h"
|
||||
#include "SfxDefinitions.h"
|
||||
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esp_log.h"
|
||||
@@ -424,7 +425,7 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
size_t written;
|
||||
QueueMsg msg;
|
||||
|
||||
ESP_LOGI(TAG, "Audio task started");
|
||||
ESP_LOGI(TAG, "Audio task started (audio-stream)");
|
||||
|
||||
while (self->running_) {
|
||||
// Process queued messages (non-blocking)
|
||||
@@ -463,19 +464,26 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
// Fill audio buffer (member buffer to avoid stack pressure)
|
||||
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
||||
|
||||
// Write to I2S
|
||||
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
|
||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
|
||||
// Write via audio-stream (resampled to native codec rate, e.g. 44100)
|
||||
if (self->streamHandle_ == nullptr) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
continue;
|
||||
}
|
||||
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
|
||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
|
||||
if (error != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "I2S write error");
|
||||
ESP_LOGE(TAG, "audio_stream_write error %d", error);
|
||||
self->running_ = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush silence
|
||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
|
||||
// Flush silence to avoid pop
|
||||
if (self->streamHandle_ != nullptr) {
|
||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||
size_t dummy = 0;
|
||||
audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Audio task exiting");
|
||||
|
||||
@@ -494,33 +502,38 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
bool SfxEngine::start() {
|
||||
if (running_) return true;
|
||||
|
||||
// Find I2S device
|
||||
i2sDevice_ = nullptr;
|
||||
device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
|
||||
if (!device_is_ready(device)) return true;
|
||||
Device** devicePtr = static_cast<Device**>(context);
|
||||
*devicePtr = device;
|
||||
return false;
|
||||
});
|
||||
// Find audio-stream device (new audio provision)
|
||||
streamDevice_ = nullptr;
|
||||
streamHandle_ = nullptr;
|
||||
|
||||
if (i2sDevice_ == nullptr) {
|
||||
ESP_LOGW(TAG, "No I2S device found");
|
||||
streamDevice_ = device_find_by_name("audio-stream");
|
||||
if (streamDevice_ == nullptr) {
|
||||
// Fallback: find first device of AUDIO_STREAM_TYPE
|
||||
device_for_each_of_type(&AUDIO_STREAM_TYPE, &streamDevice_, [](Device* device, void* context) {
|
||||
if (!device_is_ready(device)) return true;
|
||||
Device** devPtr = static_cast<Device**>(context);
|
||||
*devPtr = device;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (streamDevice_ == nullptr) {
|
||||
ESP_LOGW(TAG, "No audio-stream device found - audio provision missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure I2S
|
||||
I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
|
||||
struct AudioStreamConfig cfg = {
|
||||
.sample_rate = SAMPLE_RATE,
|
||||
.bits_per_sample = 16,
|
||||
.channel_left = 0,
|
||||
.channel_right = 0
|
||||
.channels = 2
|
||||
};
|
||||
|
||||
error_t error = i2s_controller_set_config(i2sDevice_, &config);
|
||||
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
|
||||
if (error != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
|
||||
i2sDevice_ = nullptr;
|
||||
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
|
||||
streamDevice_ = nullptr;
|
||||
streamHandle_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -528,12 +541,13 @@ bool SfxEngine::start() {
|
||||
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
||||
if (msgQueue_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create message queue");
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
streamDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start audio task
|
||||
// Start audio task (needs slightly larger stack for audio_stream write path)
|
||||
running_ = true;
|
||||
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
||||
if (result != pdPASS) {
|
||||
@@ -541,12 +555,13 @@ bool SfxEngine::start() {
|
||||
running_ = false;
|
||||
vQueueDelete(msgQueue_);
|
||||
msgQueue_ = nullptr;
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
streamDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
|
||||
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -575,11 +590,13 @@ void SfxEngine::stop() {
|
||||
msgQueue_ = nullptr;
|
||||
}
|
||||
|
||||
if (i2sDevice_ != nullptr) {
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
if (streamHandle_ != nullptr) {
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
}
|
||||
|
||||
streamDevice_ = nullptr;
|
||||
|
||||
ESP_LOGI(TAG, "SfxEngine stopped");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user