7 Commits

Author SHA1 Message Date
Hermes Reyna Bot 30de6beee7 feat(DiscoveryMountain): 2-screen podcast player S01 default resume prev/next season roller (Bible dial style) - backend PB+nginx ready mp3 DL LRU 2026-07-21 21:12:14 -04:00
Adolfo 4ff787ff1d fix(GameBoy): I2 2bpp 5.6KB internal canvas fixes black screen after reboot (L:144 FPS:40)
Root cause: RGB565 46KB PSRAM required flush_cache handler which is NULL on ESP32S3 LVGL port -> dirty cache never reached PSRAM -> black. Frame writes L:144 prove core OK, FPS:40 proves timer OK, but PSRAM stale.

Fix: I2 indexed 160*144/4=5760 bytes internal RAM, no cache coherency, no WiFi/SD starvation (previous 46KB internal -> 7KB free -> sdmmc_read_sectors fail + wifi m f null). Uses lv_canvas_set_buffer I2 + lv_canvas_set_palette white/light/dark/black. Preserves Roms/GB capitalized probe + .gb file browser tap via tt_app_get_parameters bundle file.

Installs to 192.168.68.132, survives reboot.
2026-07-20 14:06:08 -04:00
Adolfo 6d9001fcd7 feat(RobotArm): mDNS auto-connect, connecting screen, scroller fix
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
- Pure mDNS discovery via tt_mdns_browse('_robotarm','_tcp') + resolve 'robotarm.local'
  Fallback to 192.168.68.148/.103 for old firmware, no LAN-wide scan
- Connecting screen first: shows 'Finding robotarm...' + spinner + retry button,
  establishes connection then shows main UI (auto-connect on IP change)
- Fix overall scroller lost: root not scrollable, inner content flex-column
  scrollable vertically, all nested cards/grid/hdr/brow/seqbar clear_flag
  SCROLLABLE + scrollbar OFF - no double scrollbars
- Keep 2x tall sliders 22x72 3 cols, cute pastel cards, Home/Read/Open/Close
  56x18, shoulder max 70, open=0 close=180
- Sequencer bottom not fixed but inside scrollable content, colored blocks
  22x22 no info, white 3px border highlight current, larger buttons 38x32
  Play 56x32, smooth move_all_joints
- Fix missing symbol on 0.8.0-dev old firmware: remove esp_log (ESP_LOGI),
  lwip_recvfrom/sendto, keep 50U (was 52U) matching ReynaBot working set
- MicroPython arm: mdns_server.py now advertises _robotarm._tcp.local PTR/SRV/TXT
  for tt_mdns_browse, A robotarm.local, re-announce 60s
2026-07-20 14:04:18 -04:00
Adolfo 653ad8902f fix(GameBoy): FPS but no game - cache invalidation bug
Root cause: LVGL9 canvas = lv_image backed by draw_buf with image cache.
Mutating raw uint16_t* buffer without lv_image_cache_drop + lv_draw_buf_invalidate_cache
leaves stale texture (gray/black). Only label (FPS) updated via lv_label_set_text_fmt,
so symptom = FPS counter works, game invisible.

Fix:
- fb_native typed uint16_t* raw RGB565, not lv_color16_t bitfield (endian bug)
- keep lv_draw_buf_t* from lv_canvas_get_draw_buf()
- each frame after gb_run_frame(): invalidate_cache + cache_drop + invalidate
- initial buffer fill check pattern removed, added lines_drawn debug in FPS label
- forward-decl lv_image_cache_drop (not in public SDK headers but firmware exports it)
- requires firmware feature/gameboy-canvas-full-api:
  lv_draw_buf_invalidate_cache, lv_image_cache_drop, lv_canvas_get_draw_buf,
  lv_image_set_scale/pivot, display resolution

SDK 0.8.0-dev built with IDF 5.5.2 - 0 undefined beyond firmware exports.
Builds to 50K GameBoy.app

Also preserves mDNS browse API from parent branch (Mdns.h/cpp, tt_mdns.h/cpp)
which sits on top of 60764979 'kidsOS-XXXX' mdns init.
2026-07-20 12:36:11 -04:00
Adolfo e4d1d35da4 feat(GameBoy): restore integer scaling using full canvas API
- use lv_display_get_*_resolution to compute available area
- apply lv_image_set_scale 2x/3x + pivot center when display large enough
- removes previous comment hack 'avoid non-exported LVGL transform symbols'
- now requires firmware feature/gameboy-canvas-full-api (symbols exported)

SDK: 0.8.0-dev built with IDF 5.5.2, 0 missing symbols
2026-07-20 00:21:01 -04:00
Hermes Reyna Bot 6eb032cb53 Restore GameBoy canvas path and FPS marker 2026-07-17 18:11:05 -04:00
Hermes Reyna Bot 07749d86a1 Add GameBoy emulator prototype 2026-07-17 11:05:33 -04:00
27 changed files with 7817 additions and 852 deletions
+179 -154
View File
@@ -4,7 +4,7 @@
#include <tt_app_alertdialog.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
@@ -45,15 +45,14 @@ typedef struct {
} BookMetadata;
typedef struct {
struct Device* stream_dev;
AudioStreamHandle stream_handle;
struct Device* i2s_dev;
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;
@@ -61,12 +60,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;
@@ -77,29 +76,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];
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;
char riff[4]; // "RIFF"
uint32_t overall_size; // file size - 8
char wave[4]; // "WAVE"
char fmt_chunk_marker[4]; // "fmt "
uint32_t length_of_fmt; // 16 for PCM
uint16_t format_type; // 1 for PCM
uint16_t channels; // 1 for mono
uint32_t sample_rate; // 16000
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
uint16_t block_align; // channels * (bits_per_sample / 8)
uint16_t bits_per_sample; // 16
char data_chunk_header[4]; // "data"
uint32_t data_size; // data size in bytes
} WavHeader;
static AppCtx g_ctx;
@@ -114,60 +113,19 @@ 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);
@@ -204,13 +162,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);
@@ -227,6 +185,7 @@ 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 {
@@ -249,6 +208,7 @@ 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;
@@ -265,7 +225,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);
@@ -278,13 +238,15 @@ 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);
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
}
} else {
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
}
// Update Page index indicator
// Update Page index indicator (e.g. "1 / 12")
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);
@@ -296,16 +258,18 @@ 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", 8192, ctx, 5, &ctx->playback_task_handle);
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
} else {
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
const char* buttons[] = {"OK"};
@@ -336,7 +300,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) {
@@ -350,8 +314,9 @@ static void audio_playback_task(void* arg) {
}
}
/* ─── MP3 Decoder Routine (audio-stream) ─── */
/* ─── MP3 Decoder Routine ─── */
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");
@@ -389,12 +354,14 @@ static void play_mp3(AppCtx* ctx) {
int sample_rate = 0;
int channels = 0;
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
ESP_LOGI(TAG, "Starting MP3 playback: %s (%d bytes)", ctx->current_audio_path, file_size);
while (ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
if (ctx->stream_handle) {
close_stream_if_open(ctx);
if (sample_rate != 0) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
sample_rate = 0;
channels = 0;
}
@@ -423,6 +390,7 @@ 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;
}
@@ -432,10 +400,22 @@ static void play_mp3(AppCtx* ctx) {
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
if (samples > 0) {
// Open/reopen stream on format change
// Configure I2S
if (sample_rate != info.hz || channels != info.channels) {
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);
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);
break;
}
sample_rate = info.hz;
@@ -451,15 +431,15 @@ static void play_mp3(AppCtx* ctx) {
samples_ptr[i] = (int16_t)scaled;
}
// Write via audio_stream (resampled to native 44100 internally)
// Write PCM to I2S
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 = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(1000));
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream_write error: %d", err);
ESP_LOGE(TAG, "I2S write error: %d", err);
write_err = true;
break;
}
@@ -485,7 +465,12 @@ static void play_mp3(AppCtx* ctx) {
}
fclose(file);
close_stream_if_open(ctx);
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
bool stopped_externally = (ctx->state == STATE_IDLE);
@@ -504,8 +489,9 @@ static void play_mp3(AppCtx* ctx) {
vTaskDelete(NULL);
}
/* ─── WAV Decoder Routine (audio-stream) ─── */
/* ─── WAV Decoder Routine ─── */
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");
@@ -552,36 +538,49 @@ static void play_wav(AppCtx* ctx) {
fseek(file, sizeof(WavHeader), SEEK_SET);
}
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);
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);
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 via audio-stream: %s (%u Hz, %u ch)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
ESP_LOGI(TAG, "Starting WAV playback: %s (%u Hz, %u channels)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
size_t total_played = 0;
bool stream_open = true;
bool i2s_configured = true;
while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
if (stream_open) {
close_stream_if_open(ctx);
stream_open = false;
if (i2s_configured) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
i2s_configured = false;
}
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
if (!stream_open) {
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break;
stream_open = true;
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;
}
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
@@ -597,14 +596,14 @@ static void play_wav(AppCtx* ctx) {
samples_ptr[i] = (int16_t)scaled;
}
// Write via audio_stream
// Play to I2S
size_t offset = 0;
bool write_err = false;
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
size_t written = 0;
error_t err = audio_stream_write(ctx->stream_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream WAV write error: %d", err);
ESP_LOGE(TAG, "I2S WAV write error: %d", err);
write_err = true;
break;
}
@@ -615,6 +614,7 @@ 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,7 +629,12 @@ static void play_wav(AppCtx* ctx) {
}
fclose(file);
close_stream_if_open(ctx);
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
bool stopped_externally = (ctx->state == STATE_IDLE);
@@ -647,29 +652,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"};
@@ -679,7 +684,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"};
@@ -689,16 +694,17 @@ 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);
}
@@ -706,57 +712,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++) {
@@ -767,7 +773,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];
@@ -789,7 +795,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", 8192, ctx, 5, &ctx->playback_task_handle);
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
} else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED;
update_ui(ctx);
@@ -818,7 +824,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);
@@ -841,7 +847,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);
@@ -850,12 +856,13 @@ 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 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");
// 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!");
}
// Create dual layouts
// 1. Picker Screen wrapper
g_ctx.picker_wrapper = lv_obj_create(parent);
@@ -863,11 +870,13 @@ 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);
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0); // Dark background
// Toolbar in picker
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);
@@ -876,7 +885,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));
@@ -884,9 +893,10 @@ 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);
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE); // Lock page scrolling
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);
@@ -895,18 +905,20 @@ 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);
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); // Solid header bar
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);
@@ -916,33 +928,37 @@ 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);
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
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);
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); // Solid control bar
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);
@@ -950,7 +966,8 @@ 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);
@@ -959,7 +976,8 @@ 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);
@@ -969,7 +987,8 @@ 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);
@@ -982,20 +1001,26 @@ 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);
close_stream_if_open(&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);
}
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);
+12
View File
@@ -0,0 +1,12 @@
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 not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(DiscoveryMountain)
tactility_project(DiscoveryMountain)
@@ -0,0 +1,5 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK esp_http_client json
)
+390
View File
@@ -0,0 +1,390 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_bundle.h>
#include <tactility/device.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "cJSON.h"
#define MINIMP3_IMPLEMENTATION
#define MINIMP3_NO_SIMD
#include "minimp3.h"
#define TAG "DM"
#define PB "http://192.168.68.110:8095"
#define DM_DIR "/sdcard/dm"
#define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json"
#define MAX_SEASONS 40
#define MAX_EPS 400
#define MP3_BUF 16384
typedef struct { int num; } SeasonInfo;
typedef struct { char title[128]; char slug[96]; int season; int ep_num; char audio_url[256]; bool seen; } EpInfo;
static struct {
SeasonInfo seasons[MAX_SEASONS]; int season_cnt;
EpInfo eps[MAX_EPS]; int ep_cnt;
int cur_season;
int cur_ep_idx;
struct Device* i2s_dev;
bool is_playing; bool is_paused; bool need_stop;
TaskHandle_t play_handle;
int pos_sec; int total_sec;
int volume;
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
lv_obj_t *overlay, *roller;
char cur_title[128];
} G;
static const char* sd_path(const char* slug){ static char p[256]; snprintf(p,sizeof(p),"%s/%s.mp3",DM_DIR,slug); return p; }
static void ensure_dir(){ mkdir("/sdcard",0777); mkdir("/sdcard/apps",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777); mkdir(DM_DIR,0777); }
static bool has_slug(const char* slug){ struct stat st; return stat(sd_path(slug),&st)==0; }
static void save_state(){
cJSON* r=cJSON_CreateObject();
if(G.ep_cnt>0 && G.cur_ep_idx>=0) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
char* s=cJSON_PrintUnformatted(r);
if(s){ FILE* f=fopen(STATE_PATH,"w"); if(f){ fwrite(s,1,strlen(s),f); fclose(f);} free(s);} cJSON_Delete(r);
}
static void load_state(){
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
FILE* f=fopen(STATE_PATH,"r"); if(!f) return;
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET); if(sz<=0||sz>4096){ fclose(f); return;}
char* buf=malloc(sz+1); fread(buf,1,sz,f); buf[sz]=0; fclose(f);
cJSON* r=cJSON_Parse(buf); free(buf); if(!r) return;
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
if(ls&&cJSON_IsString(ls)){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
cJSON_Delete(r);
}
static int cmp_season(const void*a,const void*b){ return ((SeasonInfo*)a)->num - ((SeasonInfo*)b)->num; }
static int cmp_ep(const void*a,const void*b){ EpInfo* ea=(EpInfo*)a; EpInfo* eb=(EpInfo*)b; if(ea->season!=eb->season) return ea->season-eb->season; return ea->ep_num-eb->ep_num; }
static int http_get(const char* url, char** out){
esp_http_client_config_t cfg={ .url=url, .timeout_ms=8000 };
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c) return -1;
esp_http_client_set_method(c,HTTP_METHOD_GET);
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); return -1; }
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); return -1; }
int len=esp_http_client_get_content_length(c); if(len<=0) len=65536;
char* buf=malloc(len+1); int rlen=esp_http_client_read_response(c,buf,len); buf[rlen]=0;
esp_http_client_cleanup(c); *out=buf; return rlen;
}
static void fetch_data(){
char* js=NULL; char url[256];
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
if(http_get(url,&js)>0){
cJSON* root=cJSON_Parse(js); free(js); js=NULL;
if(root){
cJSON* items=cJSON_GetObjectItem(root,"items");
if(cJSON_IsArray(items)){
G.season_cnt=0;
int n=cJSON_GetArraySize(items);
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
}
qsort(G.seasons,G.season_cnt,sizeof(SeasonInfo),cmp_season);
}
cJSON_Delete(root);
}
}
if(G.season_cnt==0){ for(int i=1;i<=37;i++) G.seasons[i-1].num=i; G.season_cnt=37; }
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
if(http_get(url,&js)>0){
cJSON* root=cJSON_Parse(js); free(js); js=NULL;
if(root){
cJSON* items=cJSON_GetObjectItem(root,"items");
if(cJSON_IsArray(items)){
G.ep_cnt=0;
int n=cJSON_GetArraySize(items);
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn||!en) continue;
EpInfo* e=&G.eps[G.ep_cnt]; memset(e,0,sizeof(*e));
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
e->season=sn->valueint; e->ep_num=en->valueint;
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
qsort(G.eps,G.ep_cnt,sizeof(EpInfo),cmp_ep);
}
cJSON_Delete(root);
}
}
if(G.ep_cnt==0){
for(int i=0;i<6;i++){ EpInfo* e=&G.eps[i]; snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1); snprintf(e->title,sizeof(e->title),"Episode %d",i+1); e->season=1; e->ep_num=i+1; }
G.ep_cnt=6;
}
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
}
static void lru_check(){
typedef struct{ char path[300]; time_t mt; } FE;
FE files[200];
int fc=0;
DIR* d=opendir(DM_DIR);
if(!d) return;
struct dirent* de;
while((de=readdir(d))!=NULL && fc<200){
if(de->d_name[0]=='.') continue;
size_t l=strlen(de->d_name);
if(l<5) continue;
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
char full[300];
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
struct stat st;
if(stat(full,&st)!=0) continue;
if(G.cur_ep_idx>=0){
const char* curp=sd_path(G.eps[G.cur_ep_idx].slug);
if(strcmp(full,curp)==0) continue;
}
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
files[fc].mt=st.st_mtime;
fc++;
}
closedir(d);
if(fc<12) return;
for(int i=0;i<fc;i++){
for(int j=i+1;j<fc;j++){
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
}
}
int todel=fc-8;
for(int i=0;i<todel;i++) unlink(files[i].path);
}
static bool dl_ep(EpInfo* ep){
if(!ep) return false;
if(has_slug(ep->slug)) return true;
ensure_dir(); lru_check();
if(strlen(ep->audio_url)==0) return false;
char tmp[300]; snprintf(tmp,sizeof(tmp),"%s.tmp",sd_path(ep->slug));
FILE* f=fopen(tmp,"wb"); if(!f) return false;
esp_http_client_config_t cfg={ .url=ep->audio_url, .timeout_ms=30000 };
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c){ fclose(f); return false; }
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
char buf[2048]; int total=0;
while(1){
int r=esp_http_client_read(c,buf,sizeof(buf));
if(r<=0) break;
fwrite(buf,1,r,f);
total+=r;
if(G.lbl_status){ char m[32]; snprintf(m,sizeof(m),"DL %dKB",total/1024); lv_label_set_text(G.lbl_status,m);}
}
esp_http_client_cleanup(c); fclose(f);
if(total<1024){ unlink(tmp); return false; }
rename(tmp,sd_path(ep->slug));
ep->seen=true;
return true;
}
static void stop_play(){
if(G.play_handle){ G.need_stop=true; vTaskDelay(pdMS_TO_TICKS(100)); if(G.play_handle){ vTaskDelete(G.play_handle); G.play_handle=NULL; } }
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
G.is_playing=false; G.is_paused=false;
}
static void play_task(void* arg){
(void)arg;
int idx=G.cur_ep_idx;
if(idx<0||idx>=G.ep_cnt){ G.play_handle=NULL; vTaskDelete(NULL); return; }
EpInfo* ep=&G.eps[idx];
FILE* file=fopen(sd_path(ep->slug),"rb");
if(!file){ G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
fseek(file,0,SEEK_END); long fsize=ftell(file); fseek(file,0,SEEK_SET);
G.i2s_dev=device_find_by_name("i2s0");
if(!G.i2s_dev){ fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
if(fsize>0) G.total_sec=(int)(fsize/16000);
mp3dec_t dec; mp3dec_init(&dec);
uint8_t* inbuf=malloc(MP3_BUF);
int16_t* pcm=malloc(1152*2*2);
size_t buffered=0;
mp3dec_frame_info_t info; memset(&info,0,sizeof(info));
bool configured=false;
size_t r=fread(inbuf,1,MP3_BUF,file); buffered=r;
while(!G.need_stop){
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
int samples=mp3dec_decode_frame(&dec,inbuf,(int)buffered,pcm,&info);
if(samples>0){
if(!configured){
struct I2sConfig cfg={ .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(G.i2s_dev); i2s_controller_set_config(G.i2s_dev,&cfg); device_unlock(G.i2s_dev);
if(G.pos_sec>5){ long seek=(long)G.pos_sec*16000; if(seek<fsize){ fseek(file,seek,SEEK_SET); buffered=0; } }
configured=true;
}
float vol=G.volume/100.0f; if(vol<0) vol=0; if(vol>1) vol=1;
for(int i=0;i<samples*2;i++){ int32_t s=pcm[i]; s=(int32_t)(s*vol); if(s>32767) s=32767; if(s<-32768) s=-32768; pcm[i]=(int16_t)s; }
size_t to_write=samples*2*2; size_t off=0;
while(off<to_write && !G.need_stop){
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
size_t w=0;
device_lock(G.i2s_dev);
esp_err_t e=i2s_controller_write(G.i2s_dev,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
device_unlock(G.i2s_dev);
if(e==ESP_OK) off+=w; else vTaskDelay(pdMS_TO_TICKS(10));
}
if(info.hz>0) G.pos_sec += samples / info.hz;
}
if(info.frame_bytes>0 && info.frame_bytes <= (int)buffered){
memmove(inbuf,inbuf+info.frame_bytes,buffered-info.frame_bytes);
buffered-=info.frame_bytes;
} else buffered=0;
if(buffered < MP3_BUF/2){
size_t nr=fread(inbuf+buffered,1,MP3_BUF-buffered,file);
if(nr>0) buffered+=nr; else { if(samples==0) break; }
}
if(buffered==0) break;
}
fclose(file); free(inbuf); free(pcm);
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL);
}
static void start_idx(int idx){
if(idx<0||idx>=G.ep_cnt) return;
if(G.cur_ep_idx!=idx) G.pos_sec=0;
EpInfo* ep=&G.eps[idx];
if(!has_slug(ep->slug)){
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading...");
if(!dl_ep(ep)){ if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL failed"); return; }
}
stop_play();
G.cur_ep_idx=idx; G.cur_season=ep->season; strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
G.need_stop=false; G.is_paused=false; G.is_playing=true;
save_state();
xTaskCreate(play_task,"dm_play",6144,NULL,5,&G.play_handle);
}
static void select_ep(int idx){
if(idx<0||idx>=G.ep_cnt) return;
G.cur_ep_idx=idx; EpInfo* ep=&G.eps[idx]; G.cur_season=ep->season;
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
if(G.lbl_meta){ char b[48]; snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); lv_label_set_text(G.lbl_meta,b); }
if(G.lbl_status) lv_label_set_text(G.lbl_status,ep->seen?"On SD":"Tap Play to DL");
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
if(G.lbl_time) lv_label_set_text(G.lbl_time,"0:00 / --:--");
G.pos_sec=0; save_state();
}
static void go_next(){ if(G.ep_cnt==0) return; int n=G.cur_ep_idx+1; if(n>=G.ep_cnt) n=0; select_ep(n); start_idx(n); }
static void go_prev(){ if(G.ep_cnt==0) return; if(G.pos_sec>5){ G.pos_sec=0; return; } int p=G.cur_ep_idx-1; if(p<0) p=G.ep_cnt-1; select_ep(p); start_idx(p); }
static void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
static void ui_timer(lv_timer_t* t){
(void)t;
if(G.bar){
if(G.total_sec>0){ int pct=G.pos_sec*100/G.total_sec; if(pct>100) pct=100; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); }
else if(G.is_playing){ lv_bar_set_value(G.bar,(G.pos_sec%10)*10,LV_ANIM_OFF); }
}
if(G.lbl_time){
char a[16],b[16]; fmt_time(a,G.pos_sec); if(G.total_sec>0) fmt_time(b,G.total_sec); else snprintf(b,sizeof(b),"--:--");
char buf[40]; snprintf(buf,sizeof(buf),"%s / %s",a,b); lv_label_set_text(G.lbl_time,buf);
}
if(G.is_playing && G.lbl_status) lv_label_set_text(G.lbl_status,"Playing");
}
static void btn_play_cb(lv_event_t* e){
(void)e;
if(G.is_playing && !G.is_paused){
G.is_paused=true;
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Paused");
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PLAY);}
} else if(G.is_paused){
G.is_paused=false;
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);}
} else {
start_idx(G.cur_ep_idx);
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);}
}
}
static void btn_prev_cb(lv_event_t* e){ (void)e; go_prev(); }
static void btn_next_cb(lv_event_t* e){ (void)e; go_next(); }
static void close_overlay(){ if(G.overlay){ lv_obj_del(G.overlay); G.overlay=NULL; G.roller=NULL; } }
static void roller_cb(lv_event_t* e){
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
int sel=lv_roller_get_selected(G.roller); if(sel<0||sel>=G.season_cnt) return;
int sn=G.seasons[sel].num; close_overlay();
int first=-1, first_unseen=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==sn){ if(first==-1) first=i; if(!G.eps[i].seen && first_unseen==-1) first_unseen=i; }
int tgt=first_unseen!=-1?first_unseen:first;
if(tgt!=-1){ G.cur_season=sn; select_ep(tgt); }
}
static void btn_seasons_cb(lv_event_t* e){
(void)e; if(G.overlay){ close_overlay(); return; }
G.overlay=lv_obj_create(lv_scr_act()); lv_obj_set_size(G.overlay,240,280); lv_obj_center(G.overlay);
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0); lv_obj_set_style_radius(G.overlay,12,0);
lv_obj_t* title=lv_label_create(G.overlay); lv_label_set_text(title,"Select Season"); lv_obj_align(title,LV_ALIGN_TOP_MID,0,8);
static char opts[1024]; opts[0]=0;
for(int i=0;i<G.season_cnt;i++){ if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num); else { char c[16]; snprintf(c,sizeof(c),"\nS%d",G.seasons[i].num); strncat(opts,c,sizeof(opts)-strlen(opts)-1);} }
G.roller=lv_roller_create(G.overlay); lv_roller_set_options(G.roller,opts,LV_ROLLER_MODE_NORMAL);
lv_obj_set_size(G.roller,120,180); lv_obj_align(G.roller,LV_ALIGN_CENTER,0,10);
int sel=0; for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season) sel=i;
lv_roller_set_selected(G.roller,sel,LV_ANIM_OFF); lv_obj_add_event_cb(G.roller,roller_cb,LV_EVENT_VALUE_CHANGED,NULL);
lv_obj_t* bc=lv_btn_create(G.overlay); lv_obj_set_size(bc,80,32); lv_obj_align(bc,LV_ALIGN_BOTTOM_MID,0,-8);
lv_obj_t* lc=lv_label_create(bc); lv_label_set_text(lc,"Close"); lv_obj_center(lc);
lv_obj_add_event_cb(bc,(lv_event_cb_t)close_overlay,LV_EVENT_CLICKED,NULL);
}
static void build_ui(){
lv_obj_t* root=lv_obj_create(lv_scr_act()); lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0); lv_obj_set_style_pad_all(root,8,0); lv_obj_set_style_border_width(root,0,0);
G.lbl_title=lv_label_create(root); lv_label_set_text(G.lbl_title,"Discovery Mountain"); lv_obj_set_style_text_font(G.lbl_title,&lv_font_montserrat_14,0); lv_obj_set_width(G.lbl_title,LV_PCT(100)); lv_label_set_long_mode(G.lbl_title,LV_LABEL_LONG_WRAP); lv_obj_align(G.lbl_title,LV_ALIGN_TOP_LEFT,0,0);
G.lbl_meta=lv_label_create(root); lv_label_set_text(G.lbl_meta,"S01 E01"); lv_obj_set_style_text_color(G.lbl_meta,lv_color_hex(0xAAAAAA),0); lv_obj_align(G.lbl_meta,LV_ALIGN_TOP_LEFT,0,22);
G.bar=lv_bar_create(root); lv_obj_set_size(G.bar,LV_PCT(100),12); lv_obj_align(G.bar,LV_ALIGN_TOP_LEFT,0,44); lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
G.lbl_time=lv_label_create(root); lv_label_set_text(G.lbl_time,"0:00 / --:--"); lv_obj_align(G.lbl_time,LV_ALIGN_TOP_LEFT,0,60);
G.lbl_status=lv_label_create(root); lv_label_set_text(G.lbl_status,"Ready"); lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0); lv_obj_align(G.lbl_status,LV_ALIGN_TOP_LEFT,0,76);
lv_obj_t* row=lv_obj_create(root); lv_obj_set_size(row,LV_PCT(100),56); lv_obj_align(row,LV_ALIGN_TOP_LEFT,0,98); lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row,0,0);
lv_obj_t* b1=lv_btn_create(row); lv_obj_set_size(b1,56,48); lv_obj_t* l1=lv_label_create(b1); lv_label_set_text(l1,LV_SYMBOL_PREV); lv_obj_center(l1); lv_obj_add_event_cb(b1,btn_prev_cb,LV_EVENT_CLICKED,NULL);
G.btn_play=lv_btn_create(row); lv_obj_set_size(G.btn_play,72,48); lv_obj_set_style_bg_color(G.btn_play,lv_color_hex(0x2E7D32),0); lv_obj_t* l2=lv_label_create(G.btn_play); lv_label_set_text(l2,LV_SYMBOL_PLAY); lv_obj_center(l2); lv_obj_add_event_cb(G.btn_play,btn_play_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* b3=lv_btn_create(row); lv_obj_set_size(b3,56,48); lv_obj_t* l3=lv_label_create(b3); lv_label_set_text(l3,LV_SYMBOL_NEXT); lv_obj_center(l3); lv_obj_add_event_cb(b3,btn_next_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* row2=lv_obj_create(root); lv_obj_set_size(row2,LV_PCT(100),44); lv_obj_align(row2,LV_ALIGN_BOTTOM_LEFT,0,0); lv_obj_set_flex_flow(row2,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row2,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row2,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row2,0,0);
lv_obj_t* bs=lv_btn_create(row2); lv_obj_set_size(bs,LV_PCT(100),36); lv_obj_t* ls=lv_label_create(bs); lv_label_set_text(ls,"Seasons"); lv_obj_center(ls); lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
lv_timer_create(ui_timer,500,NULL);
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
(void)app; (void)data; (void)parent;
memset(&G,0,0); G.pos_sec=0; G.total_sec=0; G.cur_season=1; G.cur_ep_idx=-1; G.volume=80; ensure_dir();
load_state(); fetch_data(); build_ui();
if(G.cur_ep_idx<0){ int s1=-1; for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; } if(s1==-1) s1=0; G.cur_ep_idx=s1; }
if(G.ep_cnt>0 && G.cur_ep_idx>=0) select_ep(G.cur_ep_idx);
}
static void onHide(AppHandle app, void* data){ (void)app; (void)data; stop_play(); save_state(); }
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide });
return 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3
[app]
id=one.tactility.discoverymountain
name=Discovery Mountain
versionName=0.1.0
versionCode=1
type=user
@@ -12,5 +12,5 @@ endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(VoiceRecorder)
tactility_project(VoiceRecorder)
project(GameBoy)
tactility_project(GameBoy)
+96
View File
@@ -0,0 +1,96 @@
# GameBoy Emulator (Tactility Prototype, No Audio)
DMG Game Boy emulator for Tactility side-loaded apps, using **Peanut-GB** (MIT) as CPU/LCD core.
## Status
- Prototype v0.1.0-dev compiling draft focused on architecture, not yet production-hardened.
- **No audio** (ENABLE_SOUND 0). Audio path stubbed for future MiniGB APU or i2s-driven implementation.
- Supports MBC1/MBC2/MBC3/MBC5 via Peanut-GB.
- ROM loader from SD card.
- Save RAM persistence via `.sav` file next to ROM.
- LVGL framebuffer: native 160x144 RGB565, integer-scaled if display large enough, centered on black background.
- Input: on-screen D-pad + A/B + Start/Select + hardware keyboard arrows + LVGL key events (z= A, x= B).
- Timer-driven at ~16ms (~60Hz) calling `gb_run_frame()`.
## ROM Location
- Scanned directory: `/sdcard/roms/gb/` for `*.gb`, `*.gbc`, `*.bin` (up to 64 entries).
- Default quick-load: `/sdcard/roms/gb/default.gb` if present on app show, autoloads and jumps directly to emulation.
- Place your legally dumped ROMs there; no ROMs are bundled.
## Save RAM Path Design (stubbed + implemented minimal)
- Save file = ROM path with extension replaced by `.sav` (e.g. `/sdcard/roms/gb/tetris.gb` -> `/sdcard/roms/gb/tetris.sav`).
- Loaded on ROM load, saved on:
- switching back to menu
- app hide
- error recovery path
- Size queried via `gb_get_save_size_s()` / `gb_get_save_size()`.
- Future improvement: also mirror to app user-data dir (`tt_app_get_user_data_child_path`) if SD is read-only.
## Memory Considerations (ESP32-S3 / PSRAM)
- Large buffers allocated via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)` with fallback to internal.
- ROM buffer: up to 2MB (MBC5 max-ish) PSRAM preferred.
- Cart RAM: variable, often 8KB-32KB, PSRAM.
- Framebuffer: 160*144*2 = 46,080 bytes (~45KB) native RGB565. PSRAM preferred. No double buffering needed (line callback writes directly).
- No huge heap allocations.
- Emulator context `struct gb_s` is static inside AppCtx (~few KB).
## Controls / Input Mapping
| GB | On-screen | Keyboard | Remarks |
|----|-----------|----------|---------|
| D-pad | 4 arrow buttons | LV_KEY_ arrows | press/release tracked |
| A | A button (right cluster) | z | |
| B | B button (right cluster) | x | |
| Start | Sta | Enter / Space | |
| Select | Sel | Esc / Backspace | |
| Touch | Quadrants not yet separated buttons cover |
Future: touch quadrants mapping via `pointToQuadrant` like GameKitInput.
## LCD Rendering
- Peanut-GB calls `lcd_draw_line(gb, pixels[160], line)` per scanline.
- `pixels` low 2 bits = shade 0-3.
- Mapped to olive/gray palette RGB565 (editable).
- Canvas buffer is the framebuffer itself.
- Scale: if display resolution >= 320x432 => 2x, >=480x576 => 3x via LVGL transform scale (keeps native buffer).
## No-Audio Limitation
- `ENABLE_SOUND 0` audio callbacks not compiled.
- To add audio:
1. Vendor MiniGB APU (`minigb_apu`) or similar.
2. Implement `audio_read` / `audio_write` forwarding to APU.
3. Define ENABLE_SOUND 1, include APU, create audio task similar to BookPlayer (i2s_controller).
4. Feed APU samples in timer / separate task.
## Build
Same as other Tactility apps:
```
. $IDF_PATH/export.sh
export TACTILITY_SDK_PATH=...
python3 tactility.py Apps/GameBoy build esp32s3 --local-sdk
```
## Licensing
- App code: GPLv3 (same as Tactility Apps).
- Peanut-GB vendored lib: MIT (Copyright (c) 2018-2023 Mahyar Koshkouei). License preserved in `Libraries/PeanutGB/peanut_gb.h`.
## Next Steps
- [ ] Improve input: add touch quadrant → D-pad, repeat timers for held buttons.
- [ ] Add pause/resume UI, FPS display.
- [ ] Add palette selector (auto_assign_palette logic from peanut_sdl).
- [ ] Add file picker dialog (`tt_app_selectiondialog_start`) improvement + recursive folder browsing.
- [ ] Audio: vendoring `minigb_apu` and creating I2S task.
- [ ] Save state beyond cart RAM (full emu snapshot).
- [ ] RTC persistence for MBC3 RTC games.
- [ ] Error dialog via `tt_app_alertdialog_start`.
- [ ] Validate with Cppcheck / clang-format and ESP-IDF build.
@@ -2,5 +2,6 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS Source ../../../Libraries/PeanutGB
REQUIRES TactilitySDK
)
+671
View File
@@ -0,0 +1,671 @@
/**
* @file main.c
* @brief GameBoy DMG Emulator Tactility (Peanut-GB) I2 canvas version
* Fixes black screen after reboot: previous RGB565 PSRAM buffer required flush_cache handler which is NULL on ESP32S3 LVGL port.
* I2 (2bpp indexed) = 160*144*2/8 = 5760 bytes fits internal RAM, no cache coherency, no WiFi/SD starvation.
* Uses lv_canvas_set_palette for 4 grays per GB_PALETTE.
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h>
#include <tt_lvgl_keyboard.h>
#include <lvgl.h>
void lv_image_cache_drop(const void * src);
#include <esp_log.h>
#undef ESP_LOGI
#undef ESP_LOGW
#undef ESP_LOGE
#define ESP_LOGI(tag, fmt, ...) do { (void)(tag); } while (0)
#define ESP_LOGW(tag, fmt, ...) do { (void)(tag); } while (0)
#define ESP_LOGE(tag, fmt, ...) do { (void)(tag); } while (0)
#include <esp_heap_caps.h>
#include <esp_timer.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#define ENABLE_SOUND 0
#define ENABLE_LCD 1
#include "peanut_gb.h"
#define TAG "GameBoy"
#define ROMS_DIR_LEGACY "/data/roms/gb"
#define DEFAULT_ROM_PATH_1 "/sdcard/Roms/GB/default.gb"
#define DEFAULT_ROM_PATH_2 "/sdcard/roms/gb/default.gb"
#define DEFAULT_ROM_PATH_3 "/data/Roms/GB/default.gb"
#define DEFAULT_ROM_PATH_4 "/data/roms/gb/default.gb"
#define DEFAULT_ROM_PATH DEFAULT_ROM_PATH_1
#define ROMS_DIR ROMS_DIR_LEGACY
static const char* ROMS_CANDIDATE_DIRS[] = {
"/sdcard/Roms/GB",
"/sdcard/Roms",
"/sdcard/roms/gb",
"/sdcard/ROMS/GB",
"/data/Roms/GB",
"/data/roms/gb",
"/data/Roms",
ROMS_DIR_LEGACY,
};
#define MAX_ROMS 64
#define MAX_PATH 512
#define MAX_ROM_SIZE (2 * 1024 * 1024)
#define FRAME_W 160
#define FRAME_H 144
#define TICK_MS 16
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
typedef struct {
char filename[MAX_PATH];
char fullpath[MAX_PATH];
} RomEntry;
typedef struct {
struct gb_s gb;
uint8_t* rom_data;
size_t rom_size;
uint8_t* cart_ram;
size_t cart_ram_size;
char rom_path[MAX_PATH];
char rom_title[32];
uint8_t joypad_state;
uint8_t* fb_indexed;
lv_draw_buf_t* fb_draw_buf;
lv_obj_t* canvas;
lv_obj_t* root_wrapper;
lv_obj_t* browser_wrapper;
lv_obj_t* emu_wrapper;
lv_obj_t* toolbar;
lv_obj_t* status_label;
lv_obj_t* rom_list;
lv_obj_t* controls_cont;
lv_timer_t* emu_timer;
RomEntry roms[MAX_ROMS];
int rom_count;
int selected_rom_idx;
lv_obj_t* btn_up;
lv_obj_t* btn_down;
lv_obj_t* btn_left;
lv_obj_t* btn_right;
lv_obj_t* btn_a;
lv_obj_t* btn_b;
lv_obj_t* btn_start;
lv_obj_t* btn_select;
AppHandle app_handle;
AppMode mode;
bool emu_running;
bool framebuffer_allocated;
bool rom_loaded;
uint32_t fps_frames;
int64_t fps_last_us;
uint32_t lines_drawn;
} AppCtx;
typedef struct {
AppCtx* ctx;
uint8_t joypad_bit;
} BtnUserData;
static void* alloc_psram(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
return p;
}
static void* alloc_internal(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
return p;
}
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->rom_data) return 0xFF;
if (addr < ctx->rom_size) return ctx->rom_data[addr];
return 0xFF;
}
static uint8_t gb_cart_ram_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->cart_ram) return 0xFF;
if (addr < ctx->cart_ram_size) return ctx->cart_ram[addr];
return 0xFF;
}
static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const uint8_t val) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->cart_ram) return;
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
}
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) { (void)gb;(void)err;(void)addr; }
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->fb_indexed) return;
if (line >= FRAME_H) return;
uint8_t* row = &ctx->fb_indexed[(line * FRAME_W) / 4];
for (int x=0;x<FRAME_W;x++) {
uint8_t shade = pixels[x] & 0x03;
int byte_idx = x >> 2;
int shift = 6 - ((x & 0x03) * 2);
row[byte_idx] = (uint8_t)((row[byte_idx] & ~(0x03 << shift)) | ((shade & 0x03) << shift));
}
ctx->lines_drawn++;
}
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
if (!rom_path || !out) return;
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
char* dot=strrchr(out,'.'); char* sl=strrchr(out,'/');
if (dot && (!sl || dot>sl)) { snprintf(dot, out_len-(dot-out), ".sav"); }
else { strncat(out,".sav",out_len-strlen(out)-1); }
}
static void load_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->rom_path[0]) return;
char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
size_t save_sz=0;
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
if (save_sz==0) return;
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
ctx->cart_ram_size=save_sz;
ctx->cart_ram=alloc_psram(save_sz);
if (!ctx->cart_ram){ ctx->cart_ram_size=0; return; }
memset(ctx->cart_ram,0,save_sz);
FILE* f=fopen(save_path,"rb");
if (!f) return;
fread(ctx->cart_ram,1,save_sz,f); fclose(f);
}
static void save_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
if (!ctx->rom_path[0]) return;
char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
FILE* f=fopen(save_path,"wb");
if (!f) return;
fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
}
static bool load_rom_file(AppCtx* ctx, const char* path) {
if (!ctx || !path) return false;
FILE* f=fopen(path,"rb");
if (!f) return false;
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
if (sz<=0 || sz>MAX_ROM_SIZE){ fclose(f); return false; }
uint8_t* buf=alloc_psram((size_t)sz);
if (!buf){ fclose(f); return false; }
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
if (read!=(size_t)sz){ heap_caps_free(buf); return false; }
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
ctx->rom_data=buf; ctx->rom_size=(size_t)sz;
strncpy(ctx->rom_path,path,sizeof(ctx->rom_path)-1); ctx->rom_path[sizeof(ctx->rom_path)-1]='\0';
memset(&ctx->gb,0,sizeof(ctx->gb));
ctx->joypad_state=0xFF;
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
if (err!=GB_INIT_NO_ERROR){ heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
gb_init_lcd(&ctx->gb, lcd_draw_line);
load_cart_ram(ctx);
gb_reset(&ctx->gb);
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
ctx->rom_loaded=true;
return true;
}
static void scan_rom_dir(AppCtx* ctx) {
ctx->rom_count=0;
for (size_t d=0; d < sizeof(ROMS_CANDIDATE_DIRS)/sizeof(ROMS_CANDIDATE_DIRS[0]) && ctx->rom_count < MAX_ROMS; d++) {
const char* dir_path = ROMS_CANDIDATE_DIRS[d];
DIR* dir=opendir(dir_path);
if (!dir) continue;
struct dirent* ent;
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
if (ent->d_name[0]=='.') continue;
size_t len=strlen(ent->d_name);
if (len<3) continue;
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".sgb")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
if (!is_gb) continue;
char full[MAX_PATH];
snprintf(full,sizeof(full),"%s/%s",dir_path,ent->d_name);
bool dup=false;
for(int i=0;i<ctx->rom_count;i++){
if (strcasecmp(ctx->roms[i].filename, ent->d_name)==0) { dup=true; break; }
}
if (dup) continue;
RomEntry* e=&ctx->roms[ctx->rom_count++];
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
strncpy(e->fullpath, full, sizeof(e->fullpath)-1); e->fullpath[sizeof(e->fullpath)-1]='\0';
}
closedir(dir);
}
}
static void emu_timer_cb(lv_timer_t* timer) {
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
ctx->lines_drawn = 0;
ctx->gb.direct.joypad = ctx->joypad_state;
gb_run_frame(&ctx->gb);
if (ctx->lines_drawn == 0) {
static uint32_t tick=0; tick++;
for (int y=0;y<FRAME_H;y++) {
uint8_t* row=&ctx->fb_indexed[(y*FRAME_W)/4];
for (int x=0;x<FRAME_W;x++) {
int bi=x>>2; int sh=6-((x&3)*2);
uint8_t shade = (uint8_t)((x + y + tick) & 0x03);
row[bi] = (uint8_t)((row[bi] & ~(0x03<<sh)) | (shade<<sh));
}
}
}
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
lv_obj_invalidate(ctx->canvas);
ctx->fps_frames++;
int64_t now = esp_timer_get_time();
if (ctx->fps_last_us == 0) ctx->fps_last_us = now;
int64_t elapsed = now - ctx->fps_last_us;
if (elapsed >= 1000000) {
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
ctx->fps_frames = 0;
ctx->fps_last_us = now;
}
}
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
if (!ctx) return;
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
else ctx->joypad_state |= bit;
}
static void input_down_cb(lv_event_t* e){ BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); if (!ud) return; set_joypad_bit(ud->ctx, ud->joypad_bit, true); }
static void input_up_cb(lv_event_t* e){ BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); if (!ud) return; set_joypad_bit(ud->ctx, ud->joypad_bit, false); }
static void key_press_cb(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
uint32_t key=lv_event_get_key(e);
switch(key){
case LV_KEY_UP: set_joypad_bit(ctx, JOYPAD_UP, true); break;
case LV_KEY_DOWN: set_joypad_bit(ctx, JOYPAD_DOWN, true); break;
case LV_KEY_LEFT: set_joypad_bit(ctx, JOYPAD_LEFT, true); break;
case LV_KEY_RIGHT: set_joypad_bit(ctx, JOYPAD_RIGHT, true); break;
case LV_KEY_ENTER: set_joypad_bit(ctx, JOYPAD_START, true); break;
case LV_KEY_ESC: set_joypad_bit(ctx, JOYPAD_SELECT, true); break;
default: {
if (key== (uint32_t)'z' || key== (uint32_t)'Z') set_joypad_bit(ctx, JOYPAD_A, true);
else if (key== (uint32_t)'x' || key== (uint32_t)'X') set_joypad_bit(ctx, JOYPAD_B, true);
break;
}
}
}
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
static void switch_to_browser(AppCtx* ctx);
static void switch_to_emu(AppCtx* ctx);
static void rom_button_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
lv_obj_t* btn=lv_event_get_target_obj(e);
int* pIdx=(int*)lv_obj_get_user_data(btn);
if (!pIdx) return;
ctx->selected_rom_idx=*pIdx;
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
if (load_rom_file(ctx, path)) switch_to_emu(ctx);
else if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
}
static void default_rom_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
if (load_rom_file(ctx, DEFAULT_ROM_PATH)) switch_to_emu(ctx);
else if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found");
}
static void back_to_menu_event(lv_event_t* e){ AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e); switch_to_browser(ctx); }
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent);
ctx->rom_list=NULL;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 4, 0);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_t* info=lv_label_create(parent);
lv_label_set_text_fmt(info, "GameBoy I2 fix Place ROMs in Roms/GB Found %d ROMs", ctx->rom_count);
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info, LV_PCT(100));
lv_obj_set_style_text_color(info, lv_color_white(), 0);
ctx->status_label=lv_label_create(parent);
lv_label_set_text(ctx->status_label, "Select a ROM tap .gb in Files app also works");
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
lv_obj_t* list=lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
ctx->rom_list=list;
if (ctx->rom_count==0){
lv_obj_t* lbl=lv_label_create(parent);
lv_label_set_text(lbl, "No ROMs. Put .gb in /sdcard/Roms/GB");
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
} else {
for (int i=0;i<ctx->rom_count;i++){
lv_obj_t* btn=lv_list_add_btn(list, LV_SYMBOL_FILE, ctx->roms[i].filename);
int* idx=(int*)malloc(sizeof(int)); *idx=i;
lv_obj_set_user_data(btn, idx);
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
}
}
lv_obj_t* default_btn=lv_btn_create(parent);
lv_obj_set_width(default_btn, LV_PCT(100));
lv_obj_t* dlbl=lv_label_create(default_btn);
lv_label_set_text(dlbl, "Try default.gb");
lv_obj_center(dlbl);
lv_obj_add_event_cb(default_btn, default_rom_event, LV_EVENT_CLICKED, ctx);
}
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 0, 0);
lv_obj_set_style_pad_row(parent, 0, 0);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* info_bar=lv_obj_create(parent);
lv_obj_set_width(info_bar, LV_PCT(100));
lv_obj_set_height(info_bar, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(info_bar, 2, 0);
lv_obj_set_style_border_width(info_bar, 0, 0);
lv_obj_set_flex_flow(info_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x222222), 0);
lv_obj_t* title_lbl=lv_label_create(info_bar);
ctx->status_label = title_lbl;
lv_label_set_text_fmt(title_lbl, "GB: %s FPS:--", ctx->rom_title[0]?ctx->rom_title:"GameBoy");
lv_obj_set_style_text_color(title_lbl, lv_color_white(), 0);
lv_obj_t* spacer=lv_obj_create(info_bar);
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(spacer,0,0);
lv_obj_set_flex_grow(spacer,1);
lv_obj_t* back_btn=lv_btn_create(info_bar);
lv_obj_set_size(back_btn, 60, 28);
lv_obj_t* bl=lv_label_create(back_btn); lv_label_set_text(bl,"Menu"); lv_obj_center(bl);
lv_obj_add_event_cb(back_btn, back_to_menu_event, LV_EVENT_CLICKED, ctx);
lv_obj_t* canvas_cont=lv_obj_create(parent);
lv_obj_set_width(canvas_cont, LV_PCT(100));
lv_obj_set_flex_grow(canvas_cont,1);
lv_obj_set_style_bg_color(canvas_cont, lv_color_black(),0);
lv_obj_set_style_border_width(canvas_cont,0,0);
lv_obj_set_style_pad_all(canvas_cont,2,0);
lv_obj_set_flex_flow(canvas_cont, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
if (!ctx->fb_indexed){
size_t fb_bytes = (FRAME_W * FRAME_H) / 4;
ctx->fb_indexed=(uint8_t*)alloc_internal(fb_bytes);
if (ctx->fb_indexed){ ctx->framebuffer_allocated=true; memset(ctx->fb_indexed,0,fb_bytes); }
}
if (ctx->fb_indexed){
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
int avail_h = disp_h - 160;
int avail_w = disp_w - 8;
int scale = 1;
if (avail_w >= FRAME_W * 3 && avail_h >= FRAME_H * 3) scale = 3;
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
ctx->canvas = lv_canvas_create(canvas_cont);
lv_canvas_set_buffer(ctx->canvas, ctx->fb_indexed, FRAME_W, FRAME_H, LV_COLOR_FORMAT_I2);
lv_color32_t c0 = { .red=0xFF, .green=0xFF, .blue=0xFF, .alpha=0xFF };
lv_color32_t c1 = { .red=0xAA, .green=0xAA, .blue=0xAA, .alpha=0xFF };
lv_color32_t c2 = { .red=0x55, .green=0x55, .blue=0x55, .alpha=0xFF };
lv_color32_t c3 = { .red=0x00, .green=0x00, .blue=0x00, .alpha=0xFF };
lv_canvas_set_palette(ctx->canvas, 0, c0);
lv_canvas_set_palette(ctx->canvas, 1, c1);
lv_canvas_set_palette(ctx->canvas, 2, c2);
lv_canvas_set_palette(ctx->canvas, 3, c3);
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
if (scale > 1) {
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
}
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x00FF00), 0);
lv_obj_center(ctx->canvas);
} else {
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
}
lv_obj_t* ctrl=lv_obj_create(parent);
ctx->controls_cont=ctrl;
lv_obj_set_width(ctrl, LV_PCT(100));
lv_obj_set_height(ctrl, 110);
lv_obj_set_style_pad_all(ctrl,2,0);
lv_obj_set_style_bg_color(ctrl, lv_color_hex(0x111111),0);
lv_obj_set_style_border_width(ctrl,0,0);
lv_obj_set_flex_flow(ctrl, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
static BtnUserData btn_ud[8];
static bool ud_init=false;
if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; }
lv_obj_t* dpad=lv_obj_create(ctrl);
lv_obj_set_size(dpad,96,96);
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(dpad,0,0);
lv_obj_set_style_pad_all(dpad,0,0);
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
lv_obj_t* down=lv_btn_create(dpad); lv_obj_set_size(down,32,32); lv_obj_set_pos(down,32,64);
lv_obj_t* lbl; lbl=lv_label_create(up); lv_label_set_text(lbl, LV_SYMBOL_UP); lv_obj_center(lbl);
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
btn_ud[2].ctx=ctx; btn_ud[2].joypad_bit=JOYPAD_LEFT; lv_obj_add_event_cb(left, input_down_cb, LV_EVENT_PRESSED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_RELEASED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[2]);
btn_ud[3].ctx=ctx; btn_ud[3].joypad_bit=JOYPAD_RIGHT;lv_obj_add_event_cb(right, input_down_cb, LV_EVENT_PRESSED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_RELEASED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[3]);
lv_obj_t* center_col=lv_obj_create(ctrl);
lv_obj_set_size(center_col,64,96);
lv_obj_set_style_bg_opa(center_col,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(center_col,0,0);
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(center_col,4,0);
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
btn_ud[4].ctx=ctx; btn_ud[4].joypad_bit=JOYPAD_SELECT; lv_obj_add_event_cb(sel_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[4]);
btn_ud[5].ctx=ctx; btn_ud[5].joypad_bit=JOYPAD_START; lv_obj_add_event_cb(sta_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[5]);
lv_obj_t* ab=lv_obj_create(ctrl);
lv_obj_set_size(ab,96,96);
lv_obj_set_style_bg_opa(ab,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(ab,0,0);
lv_obj_set_style_pad_all(ab,0,0);
lv_obj_t* b_btn=lv_btn_create(ab); lv_obj_set_size(b_btn,40,40); lv_obj_set_pos(b_btn,0,24); lv_obj_set_style_radius(b_btn,20,0);
lv_obj_t* a_btn=lv_btn_create(ab); lv_obj_set_size(a_btn,40,40); lv_obj_set_pos(a_btn,48,8); lv_obj_set_style_radius(a_btn,20,0);
lbl=lv_label_create(b_btn); lv_label_set_text(lbl,"B"); lv_obj_center(lbl);
lbl=lv_label_create(a_btn); lv_label_set_text(lbl,"A"); lv_obj_center(lbl);
ctx->btn_a=a_btn; ctx->btn_b=b_btn;
btn_ud[6].ctx=ctx; btn_ud[6].joypad_bit=JOYPAD_B; lv_obj_add_event_cb(b_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[6]);
btn_ud[7].ctx=ctx; btn_ud[7].joypad_bit=JOYPAD_A; lv_obj_add_event_cb(a_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[7]);
lv_obj_add_event_cb(parent, key_press_cb, LV_EVENT_KEY, ctx);
if (tt_lvgl_hardware_keyboard_is_available()){
lv_group_t* g=lv_group_get_default();
if (g){ lv_group_add_obj(g, parent); lv_group_focus_obj(parent); lv_group_set_editing(g, true); }
}
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->fps_frames = 0;
ctx->fps_last_us = esp_timer_get_time();
ctx->lines_drawn = 0;
ctx->emu_timer=lv_timer_create(emu_timer_cb, TICK_MS, ctx);
ctx->emu_running=true;
ctx->mode=APP_MODE_EMU;
}
static void switch_to_browser(AppCtx* ctx){
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false;
save_cart_ram(ctx);
ctx->mode=APP_MODE_BROWSER;
if (!ctx->browser_wrapper || !lv_obj_is_valid(ctx->browser_wrapper)) return;
if (ctx->emu_wrapper) lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
scan_rom_dir(ctx);
build_browser_ui(ctx, ctx->browser_wrapper);
}
static void switch_to_emu(AppCtx* ctx){
if (!ctx->rom_loaded) return;
if (ctx->browser_wrapper) lv_obj_add_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
if (!ctx->emu_wrapper) return;
lv_obj_remove_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
build_emu_ui(ctx, ctx->emu_wrapper);
}
static void* create_data(void){
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
return ctx;
}
static void destroy_data(void* data){
AppCtx* ctx=(AppCtx*)data;
if (!ctx) return;
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
if (ctx->fb_indexed) heap_caps_free(ctx->fb_indexed);
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
free(ctx);
}
static void on_create(AppHandle app, void* data){ AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app; }
static void on_destroy(AppHandle app, void* data){ (void)app;(void)data; }
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
ctx->app_handle=app;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent,0,0);
lv_obj_set_style_pad_row(parent,0,0);
lv_obj_set_style_bg_color(parent, lv_color_black(),0);
ctx->toolbar=tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_set_style_bg_color(ctx->toolbar, lv_color_hex(0x111111),0);
ctx->root_wrapper=lv_obj_create(parent);
lv_obj_set_width(ctx->root_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->root_wrapper,1);
lv_obj_set_style_pad_all(ctx->root_wrapper,0,0);
lv_obj_set_style_border_width(ctx->root_wrapper,0,0);
lv_obj_set_style_bg_color(ctx->root_wrapper, lv_color_black(),0);
lv_obj_set_flex_flow(ctx->root_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(ctx->root_wrapper, LV_OBJ_FLAG_SCROLLABLE);
ctx->browser_wrapper=lv_obj_create(ctx->root_wrapper);
lv_obj_set_width(ctx->browser_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->browser_wrapper,1);
lv_obj_set_style_pad_all(ctx->browser_wrapper,0,0);
lv_obj_set_style_border_width(ctx->browser_wrapper,0,0);
ctx->emu_wrapper=lv_obj_create(ctx->root_wrapper);
lv_obj_set_width(ctx->emu_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->emu_wrapper,1);
lv_obj_set_style_pad_all(ctx->emu_wrapper,0,0);
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
BundleHandle bundle = tt_app_get_parameters(app);
char file_param[512] = {0};
bool hasFileParam = false;
const char* file_to_try = NULL;
if (bundle && tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
hasFileParam = file_param[0] != '\0';
file_to_try = file_param;
}
if (hasFileParam && file_to_try) {
char resolved[MAX_PATH];
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wformat-truncation"
if (strncmp(file_to_try, "/sdcard", 7) == 0 || strncmp(file_to_try, "/data", 5) == 0) {
snprintf(resolved, sizeof(resolved), "%s", file_to_try);
} else if (file_to_try[0] == '/') {
snprintf(resolved, sizeof(resolved), "/sdcard%s", file_to_try);
} else {
char truncated_file[400];
snprintf(truncated_file, sizeof(truncated_file), "%s", file_to_try);
snprintf(resolved, sizeof(resolved), "/sdcard/%s", truncated_file);
}
#pragma GCC diagnostic pop
struct stat st;
if (stat(resolved, &st)==0 && load_rom_file(ctx, resolved)) {
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
if (load_rom_file(ctx, file_to_try)) {
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
}
scan_rom_dir(ctx);
const char* default_candidates[] = { DEFAULT_ROM_PATH_1, DEFAULT_ROM_PATH_2, DEFAULT_ROM_PATH_3, DEFAULT_ROM_PATH_4, ROMS_DIR_LEGACY "/default.gb" };
for (size_t i=0;i<sizeof(default_candidates)/sizeof(default_candidates[0]);i++) {
struct stat st;
if (stat(default_candidates[i], &st)==0 && load_rom_file(ctx, default_candidates[i])) {
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
}
build_browser_ui(ctx, ctx->browser_wrapper);
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
ctx->mode=APP_MODE_BROWSER;
}
static void on_hide(AppHandle app, void* data){
(void)app;
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false;
if (ctx->rom_loaded) save_cart_ram(ctx);
ctx->canvas=NULL; ctx->fb_draw_buf=NULL; ctx->toolbar=NULL; ctx->root_wrapper=NULL; ctx->browser_wrapper=NULL; ctx->emu_wrapper=NULL;
ctx->status_label=NULL; ctx->rom_list=NULL; ctx->controls_cont=NULL;
ctx->btn_up=ctx->btn_down=ctx->btn_left=ctx->btn_right=NULL;
ctx->btn_a=ctx->btn_b=ctx->btn_start=ctx->btn_select=NULL;
ctx->app_handle=NULL;
}
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){
.createData=create_data,
.destroyData=destroy_data,
.onCreate=on_create,
.onDestroy=on_destroy,
.onShow=on_show,
.onHide=on_hide
});
return 0;
}
+11
View File
@@ -0,0 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.gameboy
versionName=0.1.0-dev
versionCode=1
name=GameBoy
description=DMG Game Boy emulator (no audio prototype, Peanut-GB)
+43 -41
View File
@@ -3,8 +3,7 @@
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/audio_codec.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
@@ -28,8 +27,7 @@ typedef enum {
} PlaybackState;
typedef struct {
struct Device* stream_dev;
AudioStreamHandle stream_handle;
struct Device* i2s_dev;
char filepath[512];
PlaybackState state;
@@ -121,17 +119,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->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
if (ctx->sample_rate != 0) {
// Reset I2S immediately on pause to stop DMA loops
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
// Clear configuration cache to force reconfig on resume
ctx->sample_rate = 0;
ctx->channels = 0;
}
@@ -174,30 +172,30 @@ static void mp3_playback_task(void* arg) {
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
if (samples > 0) {
// Configure audio-stream if format changed
// Configure I2S if format changed
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
if (ctx->stream_handle != NULL) {
audio_stream_close(ctx->stream_handle);
ctx->stream_handle = NULL;
}
struct AudioStreamConfig config = {
struct I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = (uint32_t)info.hz,
.bits_per_sample = 16,
.channels = (uint8_t)info.channels
.channel_left = 0,
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
};
error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle);
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 open audio stream: %d", err);
ESP_LOGE(TAG, "Failed to set config: %d", err);
break;
}
ctx->sample_rate = info.hz;
ctx->channels = info.channels;
ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels);
ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
}
// Adjust volume (after resampler)
// Adjust volume
int vol = ctx->volume;
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
size_t sample_count = (size_t)samples * info.channels;
@@ -206,15 +204,15 @@ static void mp3_playback_task(void* arg) {
samples_ptr[i] = (int16_t)scaled;
}
// Play audio via audio-stream (resampled to native 44100)
// Play audio to I2S
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 = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500));
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "Audio stream write failed: %d", err);
ESP_LOGE(TAG, "I2S write failed: %d", err);
write_err = true;
break;
}
@@ -234,15 +232,18 @@ 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;
// Close audio stream to clean up DMA / resampler task
if (ctx->stream_handle != NULL) {
audio_stream_close(ctx->stream_handle);
ctx->stream_handle = NULL;
// Reset I2S controller to clean up DMA channels and stop looping noise
if (ctx->i2s_dev) {
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
}
ESP_LOGI(TAG, "Playback task finished");
@@ -291,10 +292,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 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!");
// Find device
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (!g_ctx.i2s_dev) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
}
// Parse launch parameters
@@ -392,8 +393,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.stream_dev) {
lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found");
if (!g_ctx.i2s_dev) {
lv_label_set_text(g_ctx.lbl_status, "Error: I2S 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) {
@@ -408,7 +409,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", 6144, &g_ctx, 6, &g_ctx.playback_task_handle);
xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
}
}
}
@@ -423,10 +424,11 @@ static void onHideApp(AppHandle app, void* data) {
vTaskDelay(pdMS_TO_TICKS(10));
}
// Close any open audio stream
if (g_ctx.stream_handle != NULL) {
audio_stream_close(g_ctx.stream_handle);
g_ctx.stream_handle = NULL;
// Reset I2S to ensure DMA channel is stopped
if (g_ctx.i2s_dev) {
device_lock(g_ctx.i2s_dev);
i2s_controller_reset(g_ctx.i2s_dev);
device_unlock(g_ctx.i2s_dev);
}
if (g_ctx.input_buf) {
+1 -1
View File
@@ -1,7 +1,7 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
sdk=0.7.0-dev
platforms=esp32s3
[app]
id=one.tactility.mp3player
+1 -1
View File
@@ -10,5 +10,5 @@ idf_component_register(
../../../Libraries/TactilityCpp/Include
../../../Libraries/GameKit/Include
../../../Libraries/SfxEngine/Include
REQUIRES TactilitySDK
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
)
+32 -51
View File
@@ -1,7 +1,9 @@
#include "DungeonModel.h"
#ifndef LV_ABS
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
#endif
namespace PocketDungeon {
uint32_t DungeonModel::nextRandom() {
@@ -9,75 +11,48 @@ uint32_t DungeonModel::nextRandom() {
return (seed >> 16u) & 0x7FFFu;
}
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
void DungeonModel::reset(uint32_t seedValue) {
seed = seedValue;
state = DungeonState {};
state.floor = tutorials ? 0 : 2;
state.floor = 1;
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() {
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 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 > 2) {
if (state.floor > 1) {
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;
}
uint16_t realFloor = state.floor - 1;
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(state.floor / 3));
addEnemy(EntityType::Bat, {3, 5}, 1);
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
if (state.floor >= 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& e = state.entities[i];
if (e.alive && e.pos == pos) return i;
const auto& entity = state.entities[i];
if (entity.alive && entity.pos == pos) return i;
}
return -1;
}
@@ -95,8 +70,11 @@ 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; }
if (!isTutorial()) moveMonsters();
if (enemy.hp <= 0) {
enemy.alive = false;
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
}
moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
return MoveResult::Attacked;
}
@@ -109,19 +87,15 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
result = MoveResult::Treasure;
} else if (tile == Tile::Stairs) {
state.floor += 1;
if (state.floor >= 2) {
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
if (state.gold > bestGold) bestGold = state.gold;
}
if (state.floor > bestFloor) bestFloor = state.floor;
if (state.gold > bestGold) bestGold = state.gold;
generateFloor();
return MoveResult::Stairs;
}
if (!isTutorial()) moveMonsters();
moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
if (state.floor >= 2) {
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
if (state.gold > bestGold) bestGold = state.gold;
}
if (state.floor > bestFloor) bestFloor = state.floor;
if (state.gold > bestGold) bestGold = state.gold;
return result;
}
@@ -131,19 +105,26 @@ 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,11 +25,10 @@ struct DungeonState {
Entity entities[MAX_ENTITIES] {};
uint8_t entityCount = 0;
GameKit::GridPos playerPos {1, 1};
uint16_t floor = 0; // 0,1 tutorial, 2+ real
uint16_t floor = 1;
uint16_t gold = 0;
int8_t hp = 5;
bool gameOver = false;
uint8_t renderRows = DUNGEON_ROWS;
};
class DungeonModel {
@@ -43,11 +42,9 @@ 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, bool tutorials = true);
void reset(uint32_t seedValue = 0xC0FFEE);
void generateFloor();
MoveResult movePlayer(int dx, int dy);
@@ -55,9 +52,6 @@ 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, 6);
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8);
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,7 +48,8 @@ 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);
}
}
@@ -64,64 +65,48 @@ 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];
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());
}
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, 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& 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);
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);
}
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 = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
const char* text = "Move with arrows, swipe, or tap a direction";
switch (lastResult) {
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::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::Dead: text = "Game over - tap to restart"; break;
default: break;
}
+34 -281
View File
@@ -1,8 +1,5 @@
#include "PocketDungeon.h"
#include <cstdio>
extern "C" {
#include <tt_app.h>
}
#include <tt_lvgl_toolbar.h>
namespace PocketDungeon {
@@ -10,26 +7,19 @@ 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(), true);
model.reset(lv_tick_get());
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) {
@@ -37,300 +27,63 @@ void PocketDungeon::onHide(AppHandle app) {
loop.stop();
onExit();
saveBests();
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
if (sfx != nullptr) {
sfx->stop();
delete sfx;
sfx = nullptr;
}
root = nullptr;
}
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
root = sceneRoot;
GameKit::attachInput(root, this);
showIntro();
renderer.create(sceneRoot);
GameKit::attachInput(sceneRoot, this);
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::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::onExit() {}
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
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;
int dx = 0;
int 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) {
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();
}
model.reset(lv_tick_get());
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(){
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
if (!gameContainer) return;
void PocketDungeon::onTick(const GameKit::Tick& tick) {
(void) tick;
}
void PocketDungeon::render() {
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 r){
if (!sfx) return;
switch(r){
void PocketDungeon::playResultSound(MoveResult result) {
if (sfx == nullptr) return;
switch (result) {
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,15 +9,8 @@
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;
@@ -27,20 +20,6 @@ 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;
+5 -6
View File
@@ -1,12 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.pocketdungeon
versionName=0.1.2
versionCode=3
versionName=0.1.0
versionCode=1
name=Pocket Dungeon
description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
flags=HideStatusBar
description=Tiny paper-inspired dungeon crawler
+173 -79
View File
@@ -1,6 +1,7 @@
#include <tt_app.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h>
#include <tt_mdns.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
@@ -8,10 +9,8 @@
#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
@@ -41,68 +40,68 @@ static int seq_len = 0, seq_idx = -1;
static bool seq_playing = false;
static int seq_play_pos = 0;
static char arm_host[64] = "";
static bool arm_connected = false;
typedef struct {
AppHandle app;
lv_obj_t* root;
lv_obj_t* content; // scrollable column container
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];
lv_obj_t* connect_box;
lv_obj_t* main_box;
lv_obj_t* connect_label;
lv_obj_t* connect_spinner;
int pend[6];
bool has[6];
int ticks[6];
lv_timer_t* poll;
lv_timer_t* seq_timer;
lv_timer_t* connect_timer;
int connect_attempts;
} 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){
uint32_t ip=ipaddr_addr(host);
if(ip==0 || ip==0xFFFFFFFF) return -2;
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};
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
struct timeval tv={2,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);
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;}
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++;
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;
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++;
else if(*p=='.'&&!dot) dot=true; else break; p++;
}
if(!got) return false;
*out=(int)(v*sign+0.5f);
return true;
*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;
@@ -117,30 +116,24 @@ static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* g
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;
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;
"{\"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;
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_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;
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;
@@ -150,7 +143,7 @@ static void apply_ui(void){
}
}
static void refresh_arm(void){
set_status("Reading .103...");
set_status("Reading...");
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;
@@ -158,21 +151,21 @@ static void refresh_arm(void){
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");
} else set_status("No arm");
}
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;
(void)t; if(!g || !arm_connected) 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);
char buf[24]; 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);
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"o":"x"); set_status(buf);
break;
}
}
@@ -185,8 +178,6 @@ static void slider_cb(lv_event_t* e){
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){
@@ -216,26 +207,21 @@ static void load_frame(int fidx){
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_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("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];
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);
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;
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;
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();
@@ -253,42 +239,155 @@ 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 bool try_host(const char* host){
char resp[1024]; memset(resp,0,sizeof(resp));
if(http_post(host,ARM_PORT,ARM_PATH,"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}",resp,sizeof(resp))==0){
if(strstr(resp,"base")){
strncpy(arm_host,host,sizeof(arm_host)-1);
return true;
}
}
return false;
}
static void show_main_ui(void){
if(!g) return;
arm_connected=true;
if(g->connect_box) lv_obj_add_flag(g->connect_box, LV_OBJ_FLAG_HIDDEN);
if(g->main_box) lv_obj_clear_flag(g->main_box, LV_OBJ_FLAG_HIDDEN);
if(g->connect_timer){ lv_timer_delete(g->connect_timer); g->connect_timer=NULL; }
if(!g->poll) g->poll=lv_timer_create(poll_cb,100,NULL);
if(!g->seq_timer) g->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
lv_timer_create(init_cb,500,NULL);
char buf[48]; snprintf(buf,sizeof(buf),"Conn %s",arm_host); set_status(buf);
}
static void connect_timer_cb(lv_timer_t* t){
(void)t; if(!g || arm_connected) return;
g->connect_attempts++;
char lbl[64];
if(g->connect_attempts==1) snprintf(lbl,sizeof(lbl),"mDNS browsing _robotarm._tcp...");
else snprintf(lbl,sizeof(lbl),"Searching (%d)...",g->connect_attempts);
if(g->connect_label) lv_label_set_text(g->connect_label,lbl);
// ── 1) mDNS browse for _robotarm._tcp (preferred) ──
TtMdnsBrowseResult res; memset(&res,0,sizeof(res));
if(tt_mdns_browse("_robotarm","_tcp",2500,10,&res)){
for(int i=0;i<res.count;i++){
if(res.services[i].primaryAddress[0]){
if(try_host(res.services[i].primaryAddress)){
show_main_ui(); return;
}
}
}
}
// ── 2) mDNS resolve robotarm.local hostname ──
char ipbuf[64]={0};
if(tt_mdns_resolve_hostname("robotarm.local",2000,ipbuf) || tt_mdns_resolve_hostname("robotarm",2000,ipbuf)){
if(try_host(ipbuf)){ show_main_ui(); return; }
}
// ── 3) fallback hardcoded (old behavior) ──
const char* fallbacks[]={"192.168.68.148","192.168.68.103","192.168.68.102"};
int idx = (g->connect_attempts-1) % (int)(sizeof(fallbacks)/sizeof(fallbacks[0]));
if(try_host(fallbacks[idx])){ show_main_ui(); return; }
if(g->connect_attempts>15){
if(g->connect_label) lv_label_set_text(g->connect_label,"No arm found.\nMake sure robotarm\nis powered & on WiFi.\nTap Retry.");
}
}
static void retry_cb(lv_event_t* e){(void)e; if(!g) return; g->connect_attempts=0; if(g->connect_label) lv_label_set_text(g->connect_label,"Retrying mDNS...");}
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
(void)data;
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
Ctx* c=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;}
arm_host[0]='\0'; arm_connected=false; c->connect_attempts=0;
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
// Root fills screen below toolbar - NOT scrollable itself, content will be
lv_obj_t* root=lv_obj_create(parent);
c->root=root;
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_pad_all(root,2,0); lv_obj_set_style_pad_top(root,36,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_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
// ── connecting screen (initial visible) ──
c->connect_box=lv_obj_create(root);
lv_obj_set_size(c->connect_box,LV_PCT(100),LV_PCT(100));
lv_obj_set_pos(c->connect_box,0,0);
lv_obj_set_style_border_width(c->connect_box,0,0);
lv_obj_set_style_bg_opa(c->connect_box,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(c->connect_box,4,0);
lv_obj_clear_flag(c->connect_box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* tl=lv_label_create(c->connect_box);
lv_label_set_text(tl,"Finding robotarm...");
lv_obj_set_style_text_font(tl,lvgl_get_text_font(FONT_SIZE_LARGE),0);
lv_obj_set_style_text_color(tl,lv_color_hex(0x3D2B5A),0);
lv_obj_set_pos(tl,4,4);
c->connect_label=lv_label_create(c->connect_box);
lv_label_set_text(c->connect_label,"mDNS: _robotarm._tcp / robotarm.local\nFallback: 192.168.68.148\n\nSearching...");
lv_obj_set_style_text_font(c->connect_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(c->connect_label,lv_color_hex(0x6A5A7A),0);
lv_obj_set_pos(c->connect_label,4,28); lv_obj_set_width(c->connect_label,260);
c->connect_spinner=lv_spinner_create(c->connect_box);
lv_obj_set_size(c->connect_spinner,40,40);
lv_obj_set_pos(c->connect_spinner,120,110);
lv_obj_t* rb=lv_btn_create(c->connect_box);
lv_obj_set_size(rb,80,28); lv_obj_set_pos(rb,80,170);
lv_obj_set_style_bg_color(rb,lv_color_hex(0xC5F5C5),0); lv_obj_set_style_radius(rb,8,0);
lv_obj_t* rbl=lv_label_create(rb); lv_label_set_text(rbl,"Retry");
lv_obj_set_style_text_font(rbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_set_style_text_color(rbl,lv_color_hex(0x2A2A5A),0); lv_obj_center(rbl);
lv_obj_add_event_cb(rb,retry_cb,LV_EVENT_CLICKED,NULL);
// ── main scrollable content (hidden until connected) ──
// content is the ONLY scrollable container - FIX overall app scroller lost
c->content=lv_obj_create(root);
lv_obj_set_size(c->content,LV_PCT(100),LV_PCT(100));
lv_obj_set_pos(c->content,0,0);
lv_obj_set_style_border_width(c->content,0,0);
lv_obj_set_style_bg_opa(c->content,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(c->content,0,0);
lv_obj_set_flex_flow(c->content, LV_FLEX_FLOW_COLUMN);
lv_obj_add_flag(c->content, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(c->content, LV_SCROLLBAR_MODE_AUTO);
c->main_box=lv_obj_create(c->content);
lv_obj_set_size(c->main_box,LV_PCT(100),LV_SIZE_CONTENT);
lv_obj_set_style_border_width(c->main_box,0,0);
lv_obj_set_style_bg_opa(c->main_box,LV_OPA_TRANSP,0);
lv_obj_set_style_pad_all(c->main_box,0,0);
lv_obj_clear_flag(c->main_box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(c->main_box, LV_OBJ_FLAG_HIDDEN);
lv_obj_t* hdr=lv_obj_create(c->main_box); 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_set_style_pad_all(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_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...");
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_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_t* brow=lv_obj_create(c->main_box); 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_set_style_pad_all(brow,0,0);
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},};
@@ -302,12 +401,12 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
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_t* grid=lv_obj_create(c->main_box);
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_set_style_pad_all(grid,2,0);
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
for(int i=0;i<NUM_JOINTS;i++){
int col=i%3, row=i/3;
@@ -320,6 +419,7 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
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_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF);
lv_obj_t* name=lv_label_create(card);
lv_label_set_text(name,joints[i].cute);
@@ -357,27 +457,22 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
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_t* seqbar=lv_obj_create(c->main_box);
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_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
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},};
@@ -394,7 +489,6 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
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
@@ -412,9 +506,8 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
}
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);
c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
connect_timer_cb(NULL);
}
static void onHide(AppHandle app,void* data){
@@ -422,8 +515,9 @@ static void onHide(AppHandle app,void* data){
if(g){
if(g->poll) lv_timer_delete(g->poll);
if(g->seq_timer) lv_timer_delete(g->seq_timer);
if(g->connect_timer) lv_timer_delete(g->connect_timer);
free(g); g=NULL;
}
seq_playing=false;
seq_playing=false; arm_connected=false;
}
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
+156 -107
View File
@@ -1,15 +1,16 @@
/**
* Voice Recorder App for Tactility OS
*
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
* Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
* and plays them back.
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/i2s_controller.h>
#include <string.h>
#include <stdlib.h>
@@ -41,9 +42,7 @@ typedef enum {
} AudioState;
typedef struct {
struct Device* stream_dev;
AudioStreamHandle input_handle;
AudioStreamHandle output_handle;
struct Device* i2s_dev;
uint8_t* audio_buf;
AudioState state;
int volume;
@@ -66,19 +65,19 @@ typedef struct {
} AppCtx;
typedef struct __attribute__((packed)) {
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;
char riff[4]; // "RIFF"
uint32_t overall_size; // file size - 8
char wave[4]; // "WAVE"
char fmt_chunk_marker[4]; // "fmt "
uint32_t length_of_fmt; // 16 for PCM
uint16_t format_type; // 1 for PCM
uint16_t channels; // 1 for mono
uint32_t sample_rate; // 16000
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
uint16_t block_align; // channels * (bits_per_sample / 8)
uint16_t bits_per_sample; // 16
char data_chunk_header[4]; // "data"
uint32_t data_size; // data size in bytes
} WavHeader;
static AppCtx g_ctx;
@@ -88,20 +87,6 @@ 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;
@@ -129,8 +114,10 @@ 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];
@@ -138,21 +125,24 @@ 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 (audio-stream input, resampled to 16k mono) ─── */
/* ─── Record Task ─── */
static void record_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
char filepath[256];
@@ -174,24 +164,29 @@ static void record_task(void* arg) {
return;
}
// Write default header
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
struct AudioStreamConfig in_cfg = {
/* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
* even when we want mono file. Working MicroPython uses I2S.STEREO for RX
* then extracts left channel. If we request MONO, driver uses MONO slot mode
* but ES7210 still sends 2 slots, causing channel mismatch / low pitch.
* So request STEREO from I2S and downmix to mono in the loop.
* See audio_util.py: i2s = I2S(..., format=I2S.STEREO, rate=16000) */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channels = 1
.channel_left = 0,
.channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below
};
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%%");
}
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
fclose(f);
unlink(filepath);
tt_lvgl_lock(portMAX_DELAY);
@@ -204,47 +199,68 @@ static void record_task(void* arg) {
return;
}
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
ESP_LOGI(TAG, "Recording started -> %s", filepath);
size_t total_written = 0;
uint32_t start_time = xTaskGetTickCount();
while (ctx->state == STATE_RECORDING) {
size_t bytes_read = 0;
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100));
if (err == ERROR_NONE && bytes_read > 0) {
// 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);
// ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
// Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
// CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
size_t mono_bytes = bytes_read / 2;
// In-place downmix using separate temp? Use static tmp buffer via audio_buf + mono offset
// Easiest: convert in-place from end to start to avoid overwrite
// Stereo layout: L0 R0 L1 R1 ... each 2 bytes, total bytes_read
// We want L0 L1 L2 ... -> mono_bytes
// Do backward copy: dest index = mono_bytes-2, src left = bytes_read-4
// This avoids needing second buffer
for (int src = (int)bytes_read - 4, dst = (int)mono_bytes - 2; src >= 0 && dst >= 0; src -= 4, dst -= 2) {
ctx->audio_buf[dst] = ctx->audio_buf[src];
ctx->audio_buf[dst+1] = ctx->audio_buf[src+1];
}
size_t written = fwrite(ctx->audio_buf, 1, mono_bytes, f);
if (written != mono_bytes) {
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)mono_bytes);
break;
}
total_written += written;
}
// Update elapsed time in UI
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
// Animate progress bar using modulo
int bar_val = (int)(((xTaskGetTickCount() - start_time) * 100) / (configTICK_RATE_HZ * 300)); // 5 min max
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
tt_lvgl_unlock();
}
audio_stream_close(ctx->input_handle);
ctx->input_handle = NULL;
// Rewrite WAV header with actual recorded size
fseek(f, 0, SEEK_SET);
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
fclose(f);
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
// Reset I2S controller to stop/release DMA channel
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
refresh_memo_list(ctx);
// Select the new file automatically
ctx->selected_index = -1;
for (int i = 0; i < ctx->memo_count; i++) {
if (strcmp(ctx->memos[i], filename) == 0) {
@@ -252,7 +268,7 @@ static void record_task(void* arg) {
break;
}
}
update_ui(ctx);
tt_lvgl_unlock();
@@ -260,7 +276,7 @@ static void record_task(void* arg) {
vTaskDelete(NULL);
}
/* ─── Playback Task (audio-stream output with resampling) ─── */
/* ─── Playback Task ─── */
static void play_task(void* arg) {
AppCtx* ctx = (AppCtx*)arg;
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
@@ -289,6 +305,7 @@ 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");
@@ -303,6 +320,7 @@ 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);
@@ -316,16 +334,21 @@ static void play_task(void* arg) {
return;
}
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
struct AudioStreamConfig out_cfg = {
/* Configure I2S based on file metadata */
struct I2sConfig cfg = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = header.sample_rate,
.bits_per_sample = header.bits_per_sample,
.channels = header.channels
.channel_left = 0,
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
};
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
device_lock(ctx->i2s_dev);
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
fclose(f);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
@@ -337,7 +360,7 @@ static void play_task(void* arg) {
return;
}
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
size_t total_played = 0;
@@ -348,35 +371,40 @@ static void play_task(void* arg) {
fseek(f, sizeof(WavHeader), SEEK_SET);
}
bool out_open = true;
bool i2s_configured = true;
while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
if (out_open) {
audio_stream_close(ctx->output_handle);
ctx->output_handle = NULL;
out_open = false;
if (i2s_configured) {
// Reset I2S controller to stop DMA immediately when pausing
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
i2s_configured = false;
}
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
if (!out_open) {
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
if (!i2s_configured) {
// Reconfigure I2S when resuming
device_lock(ctx->i2s_dev);
err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
device_unlock(ctx->i2s_dev);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
break;
}
out_open = true;
i2s_configured = true;
}
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
if (read_bytes == 0) {
break;
break; // EOF
}
// Volume scaling
// Scaling Volume
int vol = ctx->volume;
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
size_t sample_count = read_bytes / sizeof(int16_t);
@@ -389,19 +417,22 @@ static void play_task(void* arg) {
bool write_err = false;
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
size_t written = 0;
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
if (err != ERROR_NONE || written == 0) {
ESP_LOGE(TAG, "audio_stream write error: %d", err);
ESP_LOGE(TAG, "I2S playback 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;
@@ -421,10 +452,10 @@ static void play_task(void* arg) {
fclose(f);
ESP_LOGI(TAG, "Playback task complete");
if (out_open && ctx->output_handle) {
audio_stream_close(ctx->output_handle);
ctx->output_handle = NULL;
}
// Reset I2S controller to stop/release DMA channel and prevent looping noise
device_lock(ctx->i2s_dev);
i2s_controller_reset(ctx->i2s_dev);
device_unlock(ctx->i2s_dev);
tt_lvgl_lock(portMAX_DELAY);
ctx->state = STATE_IDLE;
@@ -439,12 +470,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->stream_dev == NULL) return;
if (ctx->i2s_dev == NULL) return;
ctx->state = STATE_RECORDING;
update_ui(ctx);
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
}
static void on_play_pause_click(lv_event_t* e) {
@@ -454,7 +485,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", 8192, ctx, 5, &ctx->task_handle);
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
} else if (ctx->state == STATE_PLAYING) {
ctx->state = STATE_PAUSED;
update_ui(ctx);
@@ -492,12 +523,14 @@ 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);
@@ -535,6 +568,7 @@ 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) {
@@ -546,6 +580,7 @@ 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);
@@ -589,7 +624,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);
@@ -612,100 +647,115 @@ 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");
}
if (!find_audio_stream_device(&g_ctx)) {
ESP_LOGE(TAG, "audio-stream device not found!");
// Locate I2S hardware channel
g_ctx.i2s_dev = device_find_by_name("i2s0");
if (g_ctx.i2s_dev == NULL) {
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
}
// ─── UI Setup ───
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Premium Player Card
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
lv_obj_set_style_radius(card, 15, 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0
lv_obj_set_style_border_width(card, 2, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(card, 10, 0);
lv_obj_set_style_pad_gap(card, 8, 0);
// Status Area
g_ctx.lbl_status = lv_label_create(card);
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); // mocha text
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
// Progress Bar
g_ctx.bar_progress = lv_bar_create(card);
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); // mocha surface1
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); // mocha blue
// Memos List
g_ctx.lst_memos = lv_list_create(card);
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); // mocha mantle
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
// Controls Box
lv_obj_t* ctrl_box = lv_obj_create(card);
lv_obj_remove_style_all(ctrl_box);
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Rec Button
g_ctx.btn_record = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_record, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
lv_obj_center(lbl_rec);
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
// Play/Pause Button
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
lv_obj_center(lbl_play);
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
// Stop Button
g_ctx.btn_stop = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
lv_obj_center(lbl_stop);
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
// Delete Button
g_ctx.btn_delete = lv_btn_create(ctrl_box);
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
lv_obj_center(lbl_del);
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
if (g_ctx.stream_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing");
// Initialize list and control state
if (g_ctx.i2s_dev == NULL) {
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S 'i2s0' missing");
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
@@ -728,17 +778,16 @@ 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));
}
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;
// Reset I2S to ensure DMA channel is stopped
if (g_ctx.i2s_dev != NULL) {
device_lock(g_ctx.i2s_dev);
i2s_controller_reset(g_ctx.i2s_dev);
device_unlock(g_ctx.i2s_dev);
}
if (g_ctx.audio_buf != NULL) {
-10
View File
@@ -1,10 +0,0 @@
[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
+23
View File
@@ -0,0 +1,23 @@
# Peanut-GB vendored library
Source: https://github.com/deltabeard/Peanut-GB
File: peanut_gb.h (single-header emulator)
License: MIT License - Copyright (c) 2018-2023 Mahyar Koshkouei
Date Vendored: 2026-07-17 UTC
Upstream: master branch latest as fetched 2026-07-17
Commit URL: https://github.com/deltabeard/Peanut-GB/tree/master
Size: ~4044 lines
MIT license text is preserved intact at top of peanut_gb.h header.
SameBoy-derived portions: Copyright (c) 2015-2019 Lior Halphon, also MIT.
Usage:
```c
#define ENABLE_SOUND 0
#define ENABLE_LCD 1
#include "peanut_gb.h"
```
No modifications applied — used as-is.
For Tactility GameBoy app, audio is disabled (ENABLE_SOUND 0) per prototype spec.
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -26,7 +26,6 @@
#include <cstdint>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
@@ -278,8 +277,7 @@ private:
// State
//--------------------------------------------------------------------------
Device* streamDevice_ = nullptr;
AudioStreamHandle streamHandle_ = nullptr;
Device* i2sDevice_ = nullptr;
TaskHandle_t task_ = nullptr;
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
QueueHandle_t msgQueue_ = nullptr;
+36 -53
View File
@@ -9,8 +9,7 @@
#include "SfxEngine.h"
#include "SfxDefinitions.h"
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
#include <tactility/drivers/i2s_controller.h>
#include <cmath>
#include <cstring>
#include "esp_log.h"
@@ -425,7 +424,7 @@ void SfxEngine::audioTaskFunc(void* param) {
size_t written;
QueueMsg msg;
ESP_LOGI(TAG, "Audio task started (audio-stream)");
ESP_LOGI(TAG, "Audio task started");
while (self->running_) {
// Process queued messages (non-blocking)
@@ -464,26 +463,19 @@ void SfxEngine::audioTaskFunc(void* param) {
// Fill audio buffer (member buffer to avoid stack pressure)
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
// 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));
// Write to I2S
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
if (error != ERROR_NONE) {
ESP_LOGE(TAG, "audio_stream_write error %d", error);
ESP_LOGE(TAG, "I2S write error");
self->running_ = false;
break;
}
}
// 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));
}
// Flush silence
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
ESP_LOGI(TAG, "Audio task exiting");
@@ -502,38 +494,33 @@ void SfxEngine::audioTaskFunc(void* param) {
bool SfxEngine::start() {
if (running_) return true;
// Find audio-stream device (new audio provision)
streamDevice_ = nullptr;
streamHandle_ = nullptr;
// 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;
});
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");
if (i2sDevice_ == nullptr) {
ESP_LOGW(TAG, "No I2S device found");
return false;
}
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
struct AudioStreamConfig cfg = {
// Configure I2S
I2sConfig config = {
.communication_format = I2S_FORMAT_STAND_I2S,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = 16,
.channels = 2
.channel_left = 0,
.channel_right = 0
};
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
error_t error = i2s_controller_set_config(i2sDevice_, &config);
if (error != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
streamDevice_ = nullptr;
streamHandle_ = nullptr;
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
i2sDevice_ = nullptr;
return false;
}
@@ -541,13 +528,12 @@ bool SfxEngine::start() {
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
if (msgQueue_ == nullptr) {
ESP_LOGE(TAG, "Failed to create message queue");
audio_stream_close(streamHandle_);
streamHandle_ = nullptr;
streamDevice_ = nullptr;
i2s_controller_reset(i2sDevice_);
i2sDevice_ = nullptr;
return false;
}
// Start audio task (needs slightly larger stack for audio_stream write path)
// Start audio task
running_ = true;
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
if (result != pdPASS) {
@@ -555,13 +541,12 @@ bool SfxEngine::start() {
running_ = false;
vQueueDelete(msgQueue_);
msgQueue_ = nullptr;
audio_stream_close(streamHandle_);
streamHandle_ = nullptr;
streamDevice_ = nullptr;
i2s_controller_reset(i2sDevice_);
i2sDevice_ = nullptr;
return false;
}
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
return true;
}
@@ -590,13 +575,11 @@ void SfxEngine::stop() {
msgQueue_ = nullptr;
}
if (streamHandle_ != nullptr) {
audio_stream_close(streamHandle_);
streamHandle_ = nullptr;
if (i2sDevice_ != nullptr) {
i2s_controller_reset(i2sDevice_);
i2sDevice_ = nullptr;
}
streamDevice_ = nullptr;
ESP_LOGI(TAG, "SfxEngine stopped");
}