From 5a911619f7ea01cd79ea767ad8d98e773ed92a0a Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:42:55 +1000 Subject: [PATCH] GBC sound (T-Deck Max), mesh stays live in-game, fast waveform at 12 frames, wrap fix SOUND (Max, ES8311; Pro stays silent) - minigb_apu vendored from Meck-P4 (MIT), run at 22050 Hz: at 44100 it cost 3.6-4.0 ms of the 16.7 ms frame and the game fell to 0.98x, which drained the DMA queue and glitched. Per-frame timings (core / apu / i2s wait) now print with the speed line. - Bring-up reuses the notification-tone path: shared Audio object owns I2S port 0 (with MCLK), XL9555 routing and amp, then codec init after the clocks are running. Raw PCM written per frame; a repeated sample every sixth frame keeps production a hair above the DAC so its clock paces the game instead of the queue running dry. - Software gain 1/8 (codec DAC sits at 0 dB); GBC_AUDIO_SHIFT to tune. - 950 ms delay line (PSRAM) so sound lands with the picture, which reaches the glass ~1 s after its frame; GBC_AUDIO_DELAY_MS to tune. - Games start muted; mic key toggles the codec mute in both keyboard drivers' raw mode; footer shows the current hint. Amp off on quit. - Notification tones skipped while a game runs (game owns the codec). MESH LIVE DURING PLAY - The OTA-style radio pause is gone (GBC_PAUSE_RADIO=1 restores it): the mesh runs on the other core and already tolerates the panel blocking the loop. Game header now matches the home screen's -- the cumulative unread channel count where the node name goes, clock, battery percent. "New:" toasts suppressed only while a ROM is running (Snake and Minesweeper keep them). DISPLAY - Fast register waveform set to 12 frames per push (FAST_LUT_FRAMES): judged the best balance of UI speed and contrast. Interface only; the game keeps the factory waveform. - Windowed game refresh dropped: measured to save nothing, and it left the footer row unrefreshed. Normal partial refresh, change-detected. - Adafruit GFX auto-wrap turned off in the e-ink driver (all e-ink builds): it dropped a glyph onto the next line's margin when a line ran a few pixels past the edge (seen with Larger Noto in compose). Compose wrap width also allows for the driver's 2-unit x offset. - Game start does one full refresh (tried as a ghosting fix; NPC ghosting in Pokemon Gold is the game + dither + factory partial waveform and shows on the stock build too). Files: examples/companion_radio/{main.cpp, ui-new/GBCEmulatorScreen.{h,cpp}, ui-new/UITask.cpp, ui-new/minigb_apu.{c,h}}, src/helpers/ui/GxEPDDisplay.cpp, src/helpers/ui/GxEPD2_310_GDEQ031T10_FAST.cpp, variants/lilygo_tdeck_{max,pro}/TCA8418Keyboard.h --- examples/companion_radio/main.cpp | 8 + .../ui-new/GBCEmulatorScreen.cpp | 282 +++++++- examples/companion_radio/ui-new/UITask.cpp | 10 +- examples/companion_radio/ui-new/minigb_apu.c | 605 ++++++++++++++++++ examples/companion_radio/ui-new/minigb_apu.h | 49 ++ variants/lilygo_tdeck_max/TCA8418Keyboard.h | 14 +- variants/lilygo_tdeck_pro/TCA8418Keyboard.h | 14 +- 7 files changed, 959 insertions(+), 23 deletions(-) create mode 100644 examples/companion_radio/ui-new/minigb_apu.c create mode 100644 examples/companion_radio/ui-new/minigb_apu.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d0b8a474..1f9589e9 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3171,6 +3171,14 @@ void loop() { bool audioBusy = (alarmScr && alarmScr->isRinging()) || (alarmScr && alarmScr->isAlarmAudioActive()) || (abPlayer && abPlayer->isAudioActive()); +#if defined(LilyGo_TDeck_Pro) + // A running Game Boy game owns the codec and I2S port: skip the tone + // (vibration and the toast still happen). + { + GBCEmulatorScreen* gbcScr = (GBCEmulatorScreen*)ui_task.getGBCScreen(); + if (gbcScr && gbcScr->isRunning()) audioBusy = true; + } +#endif if (!audioBusy && file[0] != '\0') { // Lazy-init Audio object diff --git a/examples/companion_radio/ui-new/GBCEmulatorScreen.cpp b/examples/companion_radio/ui-new/GBCEmulatorScreen.cpp index 0b8c2b86..dff2a992 100644 --- a/examples/companion_radio/ui-new/GBCEmulatorScreen.cpp +++ b/examples/companion_radio/ui-new/GBCEmulatorScreen.cpp @@ -36,10 +36,7 @@ // and copies the 6480-byte result into the shared snapshot buffer inside a // short critical section. render() copies the snapshot out under the same // lock and draws it with GxEPDDisplay::drawXbmRaw, whose CRC tracking means -// the panel only refreshes when the picture actually changed. The first -// picture after launch is a normal full-screen partial refresh (it has -// to clear the ROM list); every picture after that refreshes only the -// 216-line game window, so the panel moves less ink per picture. When a game +// the panel only refreshes when the picture actually changed. When a game // quits the next refresh is a full one (the black/white flash) so the // residue partial refreshes leave behind does not follow the user back // to the ROM list and home screen. There is no periodic full refresh @@ -58,11 +55,12 @@ // while a game runs a GxEPD2 busy callback keeps draining the keyboard // through the refresh, so input never waits for the panel. // -// - While a ROM runs the LoRa radio is put into standby and the mesh loop -// paused, reusing the OTA update's otaPauseRadio()/otaResumeRadio(). That -// keeps the shared SPI bus (LoRa, e-ink, SD) free for the panel and the -// save file, and keeps the mesh loop out of the frame budget. The node -// receives nothing while a game is running. +// - The LoRa radio and mesh loop keep running while a ROM runs (the mesh +// is on the other core, and the loop already lives with the panel +// blocking it during every refresh). Notification tones are suppressed +// while a game runs because the game owns the codec; toasts, buzzer and +// vibration behave as usual. GBC_PAUSE_RADIO=1 restores the OTA-style +// pause that earlier builds used. // // - Saves: raw cart RAM as a .sav sidecar next to the ROM, size from the // cartridge header (MBC2 carts declare 0 but carry 512 bytes). Loaded @@ -88,7 +86,15 @@ // driver back to the factory OTP waveform (dither needs its drive) and // restores fast on quit. // -// - No sound yet (build 3). +// - Sound (T-Deck Max): minigb_apu (MIT, vendored, patched to 44100 Hz as +// in Meck-P4, at 22050 Hz here) is fed by the core's audio_read/ +// audio_write hooks; once per emulated frame the task renders 369 stereo +// samples, passes them through a GBC_AUDIO_DELAY_MS delay line so they +// land when the picture does, and writes them to the I2S driver the +// shared Audio object installed on port 0, after the +// ES8311 has been brought up the way the notification tones do it. Games +// start muted; the mic key toggles the codec's hard mute. The Pro runs +// silent for now. // // - Telemetry: the task prints a speed line every 5 s (frames in the // window, fps, and the cumulative average against 59.73) so whether the @@ -99,6 +105,7 @@ #include "GBCEmulatorScreen.h" #include "UITask.h" +#include "../MyMesh.h" // the_mesh.getRTCClock() for the header clock #include #include "TCA8418Keyboard.h" @@ -113,13 +120,39 @@ #include #include +// Sound: T-Deck Max (ES8311 codec) only in this build. The Pro's PCM5102A +// path is different and stays silent for now. +#if defined(HAS_ES8311_AUDIO) +#define GBC_SOUND 1 +#else +#define GBC_SOUND 0 +#endif + +#if GBC_SOUND +// The APU declarations must precede the core: with ENABLE_SOUND the core +// calls audio_read/audio_write directly. minigb_apu.h has no C++ linkage +// guard of its own, and minigb_apu.c is compiled as C. +extern "C" { +#include "minigb_apu.h" +} +#include "variant.h" // BOARD_I2S_* pins +#include "target.h" // board (amplifier off on quit) +#include "Audio.h" // ESP32-audioI2S: owns the I2S driver on port 0 +#include "ES8311.h" // es8311_set_mute +#include "driver/i2s.h" // i2s_write / i2s_set_sample_rates on that driver +extern Audio* audio; // main.cpp: shared Audio object +extern void meck_audio_route_amp(); // target.cpp: AUDIO_SEL -> ES8311, amp on +extern void meck_audio_codec_init(); // target.cpp: ES8311 register init, once per boot +#endif + #define PEANUT_FULL_GBC_SUPPORT 1 #define ENABLE_LCD 1 -#define ENABLE_SOUND 0 +#define ENABLE_SOUND GBC_SOUND #include "peanut_gb.h" // ---- Firmware hooks --------------------------------------------------------- extern TCA8418Keyboard keyboard; // main.cpp +extern MyMesh the_mesh; // main.cpp #if defined(LilyGo_TDeck_Pro_Max) extern void toggleKeyboardBacklight(); // main.cpp (both-shifts chord, Max keyboard backlight) #endif @@ -145,6 +178,12 @@ extern void otaResumeRadio(); // main.cpp #define GBC_SAVE_QUIET_MS 2000 // cart RAM untouched this long -> write .sav #define GBC_STOP_WAIT_MS 200 // max wait for the task to park on quit #define GBC_TASK_STACK 8192 // bytes, static, reserved at boot +// The LoRa radio and mesh loop keep running while a game plays (they live on +// the other core; the loop already tolerates the panel blocking it during +// every refresh). Set GBC_PAUSE_RADIO=1 to restore the OTA-style pause. +#ifndef GBC_PAUSE_RADIO +#define GBC_PAUSE_RADIO 0 +#endif #define GBC_TASK_PRIO 2 #define GBC_TASK_CORE 0 @@ -176,6 +215,29 @@ static volatile bool s_kbd_bl_req = false; // both-shifts chord seen by t static volatile unsigned long s_frames = 0; static unsigned s_pictures = 0; // pictures drawn this game (first one is full-screen) static TaskHandle_t s_task = NULL; +#if GBC_SOUND +// One emulated frame of stereo s16 APU output: 44100/59.73 = 738 sample pairs. +#define GBC_AUDIO_FRAME_BYTES (AUDIO_SAMPLES * 2 * sizeof(int16_t)) +// Software gain: the codec's DAC sits at 0 dB (audiobooks scale their own +// samples first), so the APU's full-scale output is divided by 2^SHIFT. +#ifndef GBC_AUDIO_SHIFT +#define GBC_AUDIO_SHIFT 3 // /8, about -18 dB +#endif +static bool s_audio_on = false; // codec and I2S taken for this game +static int16_t *s_audio_buf = NULL; // internal RAM (+1 stereo pair for the pad) +static bool s_muted = false; +// Delay line between the APU and the codec so sound lines up with a picture +// that reaches the glass ~0.7-1 s after the frame it shows. PSRAM ring of +// stereo pairs; sequential access only. +#ifndef GBC_AUDIO_DELAY_MS +#define GBC_AUDIO_DELAY_MS 950 // tuned by ear on the Max +#endif +#define GBC_DELAY_PAIRS ((uint32_t)AUDIO_SAMPLE_RATE * GBC_AUDIO_DELAY_MS / 1000) +#define GBC_RING_PAIRS (GBC_DELAY_PAIRS + 4 * AUDIO_SAMPLES) +static int16_t *s_delay_ring = NULL; // PSRAM, GBC_RING_PAIRS stereo pairs +static uint32_t s_ring_w = 0, s_ring_r = 0, s_ring_fill = 0; +#endif +static volatile uint32_t s_us_core = 0, s_us_apu = 0, s_us_i2s = 0; // per-window totals (all builds) static StackType_t s_task_stack[GBC_TASK_STACK / sizeof(StackType_t)]; // .bss, internal RAM static StaticTask_t s_task_tcb; @@ -285,7 +347,52 @@ static void gbc_task(void *arg) { while (!s_stop) { // Peanut-GB's joypad register is active-low. s_gb->direct.joypad = (uint8_t)~keyboard.rawJoypad(); + const int64_t tc0 = esp_timer_get_time(); gb_run_frame(s_gb); + const int64_t tc1 = esp_timer_get_time(); + s_us_core += (uint32_t)(tc1 - tc0); +#if GBC_SOUND + if (s_audio_on) { + // This frame's APU output, scaled down, through the delay line, to the + // codec. The APU renders a fixed 369 stereo pairs per frame at 22050 + // (= 22040/s, a shade under the DAC's 22050/s), so every sixth frame + // the last pair is sent twice: 369.17 per frame keeps the DMA queue + // (~370 ms at this rate) full, and i2s_write then blocks briefly each + // frame -- the audio clock pacing the game at exactly 1.00x rather + // than the queue draining into gaps. + audio_callback(NULL, (uint8_t*)s_audio_buf, (int)GBC_AUDIO_FRAME_BYTES); + for (unsigned i = 0; i < AUDIO_SAMPLES * 2; i++) s_audio_buf[i] >>= GBC_AUDIO_SHIFT; + uint32_t pairs = AUDIO_SAMPLES; + if ((s_frames % 6) == 0) { + s_audio_buf[AUDIO_SAMPLES * 2] = s_audio_buf[AUDIO_SAMPLES * 2 - 2]; + s_audio_buf[AUDIO_SAMPLES * 2 + 1] = s_audio_buf[AUDIO_SAMPLES * 2 - 1]; + pairs++; + } + // Push this frame into the ring, pop the same number of pairs from + // GBC_AUDIO_DELAY_MS ago (silence until the ring has filled that far). + for (uint32_t i = 0; i < pairs; i++) { + s_delay_ring[s_ring_w * 2] = s_audio_buf[i * 2]; + s_delay_ring[s_ring_w * 2 + 1] = s_audio_buf[i * 2 + 1]; + if (++s_ring_w >= GBC_RING_PAIRS) s_ring_w = 0; + } + s_ring_fill += pairs; + if (s_ring_fill >= GBC_DELAY_PAIRS + pairs) { + for (uint32_t i = 0; i < pairs; i++) { + s_audio_buf[i * 2] = s_delay_ring[s_ring_r * 2]; + s_audio_buf[i * 2 + 1] = s_delay_ring[s_ring_r * 2 + 1]; + if (++s_ring_r >= GBC_RING_PAIRS) s_ring_r = 0; + } + s_ring_fill -= pairs; + } else { + memset(s_audio_buf, 0, pairs * 2 * sizeof(int16_t)); + } + const int64_t ta1 = esp_timer_get_time(); + s_us_apu += (uint32_t)(ta1 - tc1); + size_t put = 0; + i2s_write(I2S_NUM_0, s_audio_buf, pairs * 2 * sizeof(int16_t), &put, pdMS_TO_TICKS(100)); + s_us_i2s += (uint32_t)(esp_timer_get_time() - ta1); + } +#endif s_frames = s_frames + 1; f_win++; { @@ -293,8 +400,12 @@ static void gbc_task(void *arg) { if (t - t_win >= 5000000) { const float win_fps = (float)f_win * 1000000.0f / (float)(t - t_win); const float avg_fps = (float)s_frames * 1000000.0f / (float)(t - t_start); - Serial.printf("[GBC] speed: %.1f fps last 5 s, %.1f fps average (%.2fx real-time)\n", - win_fps, avg_fps, avg_fps / 59.73f); + Serial.printf("[GBC] speed: %.1f fps last 5 s, %.1f fps average (%.2fx real-time); per frame: core %lu us, apu %lu us, i2s wait %lu us\n", + win_fps, avg_fps, avg_fps / 59.73f, + (unsigned long)(f_win ? s_us_core / f_win : 0), + (unsigned long)(f_win ? s_us_apu / f_win : 0), + (unsigned long)(f_win ? s_us_i2s / f_win : 0)); + s_us_core = s_us_apu = s_us_i2s = 0; t_win = t; f_win = 0; } @@ -575,9 +686,54 @@ bool GBCEmulatorScreen::launch(int idx) { Serial.printf("[GBC] cgbMode=%d mbc=%d rom=%u bytes save=%u bytes\n", (int)s_gb->cgb.cgbMode, (int)s_gb->mbc, (unsigned)s_rom_size, (unsigned)s_save_size); +#if GBC_SOUND + // Sound. Same bring-up the notification tones and audiobooks use, minus + // the decoder: the shared Audio object installs and clocks the I2S driver + // on port 0 (with MCLK, which the ES8311 needs), routing and amp via the + // XL9555, then -- with the clocks already running, which the codec insists + // on -- the ES8311 register init. The game then writes raw stereo PCM into + // that driver every frame. If any step fails the game runs silent. + s_audio_on = false; + // Internal RAM: the APU makes four read-modify-write passes over this + // buffer per frame, which PSRAM handles badly. One extra stereo pair for + // the every-third-frame pad. + if (!s_audio_buf) s_audio_buf = (int16_t*)heap_caps_malloc(GBC_AUDIO_FRAME_BYTES + 2 * sizeof(int16_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!s_audio_buf) s_audio_buf = (int16_t*)heap_caps_malloc(GBC_AUDIO_FRAME_BYTES + 2 * sizeof(int16_t), + MALLOC_CAP_SPIRAM); + if (!s_delay_ring) s_delay_ring = (int16_t*)heap_caps_malloc(GBC_RING_PAIRS * 2 * sizeof(int16_t), MALLOC_CAP_SPIRAM); + if (s_delay_ring) { s_ring_w = s_ring_r = s_ring_fill = 0; memset(s_delay_ring, 0, GBC_RING_PAIRS * 2 * sizeof(int16_t)); } + if (s_audio_buf && s_delay_ring) { + if (!audio) audio = new Audio(); + audio->stopSong(); // an audiobook or tone in progress loses the codec + meck_audio_route_amp(); + bool ok = audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, + I2S_PIN_NO_CHANGE, BOARD_I2S_MCLK); + if (ok) { + i2s_set_sample_rates(I2S_NUM_0, AUDIO_SAMPLE_RATE); + memset(s_audio_buf, 0, GBC_AUDIO_FRAME_BYTES); + for (int i = 0; i < 12; i++) { // ~200 ms of silence: clocks live, no pop + size_t put = 0; + i2s_write(I2S_NUM_0, s_audio_buf, GBC_AUDIO_FRAME_BYTES, &put, pdMS_TO_TICKS(100)); + } + meck_audio_codec_init(); + es8311_set_mute(true); // games start muted; the mic key unmutes + s_muted = true; + audio_init(); // reset the APU + s_audio_on = true; + Serial.printf("[GBC] audio on (muted): %u Hz, %u samples/frame, gain 1/%u, delay %u ms, mic key = unmute\n", + (unsigned)AUDIO_SAMPLE_RATE, (unsigned)AUDIO_SAMPLES, 1u << GBC_AUDIO_SHIFT, (unsigned)GBC_AUDIO_DELAY_MS); + } else { + Serial.println("[GBC] audio: setPinout failed -- running silent"); + } + } else { + Serial.println("[GBC] audio buffer alloc failed -- running silent"); + } +#endif + // ---- Hand the keyboard and the radio over, then start the task ---- keyboard.setRawJoypad(true); -#ifdef MECK_OTA_UPDATE +#if GBC_PAUSE_RADIO && defined(MECK_OTA_UPDATE) otaPauseRadio(); #endif // The dithered game picture needs the factory waveform's longer drive; @@ -604,7 +760,7 @@ bool GBCEmulatorScreen::launch(int idx) { if (s_task == NULL) { Serial.println("[GBC] task create failed"); keyboard.setRawJoypad(false); -#ifdef MECK_OTA_UPDATE +#if GBC_PAUSE_RADIO && defined(MECK_OTA_UPDATE) otaResumeRadio(); #endif gbc_release_core(); @@ -631,10 +787,19 @@ void GBCEmulatorScreen::finishStop() { // navigation afterwards -- one impatient Q became four screens of "back". _releaseKbAfterDraw = true; _browserDrawn = false; -#ifdef MECK_OTA_UPDATE +#if GBC_PAUSE_RADIO && defined(MECK_OTA_UPDATE) otaResumeRadio(); #endif s_rom_size = 0; // arena retained +#if GBC_SOUND + if (s_audio_on) { + s_audio_on = false; + es8311_set_mute(false); // leave the codec unmuted for the next user + i2s_zero_dma_buffer(I2S_NUM_0); + board.amplifierDisable(); // the next tone or book re-enables it + Serial.println("[GBC] audio off"); + } +#endif gbc_release_core(); _playing = -1; _mode = BROWSER; @@ -671,6 +836,15 @@ void GBCEmulatorScreen::poll() { s_cram_dirty = false; gbc_write_save(); } +#if GBC_SOUND + if (keyboard.rawMutePressed() && s_audio_on) { + s_muted = !s_muted; + es8311_set_mute(s_muted); + Serial.printf("[GBC] %s\n", s_muted ? "muted" : "unmuted"); + } +#else + keyboard.rawMutePressed(); // consume; no sound on this build +#endif if (keyboard.rawExitPressed() || s_stop) { s_stop = true; _mode = STOPPING; @@ -734,6 +908,65 @@ bool GBCEmulatorScreen::handleInput(char c) { } } +// ---- Game-screen header ----------------------------------------------------- +// The home screen's header, with the cumulative unread channel-message count +// ("3 Unread", the same figure as the home screen's MSG strip) where the +// node name goes, since the mesh keeps running during a game. Same text +// size, colours and row (HOME_HDR_Y = -3 on the T-Deck) as the home screen; +// battery as text-only percent at the right, which is what the home screen +// shows for the Larger and custom-font styles; clock centred and kept +// clear of the label, shown only once the time is valid. +static void draw_game_header(DisplayDriver& display, UITask* task) { + NodePrefs* prefs = task->getNodePrefs(); + const int hdrY = -3; + char label[16]; + snprintf(label, sizeof(label), "%d Unread", task->getUnreadMsgCount()); + + display.setTextSize(prefs->smallTextSize()); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, hdrY); + display.print(label); + + uint8_t pct = 0; +#if HAS_BQ27220 + pct = task->getBatteryPercent(); +#else + { + const uint16_t mv = task->getBattMilliVolts(); + if (mv > 0) { + int p = ((int)mv - 3000) * 100 / (4200 - 3000); + if (p < 0) p = 0; + if (p > 100) p = 100; + pct = (uint8_t)p; + } + } +#endif + char pctStr[6]; + snprintf(pctStr, sizeof(pctStr), "%d%%", (int)pct); + const uint16_t pw = display.getTextWidth(pctStr); + display.setCursor(display.width() - pw - 2, hdrY); + display.print(pctStr); + + const uint32_t now = the_mesh.getRTCClock()->getCurrentTime(); + if (now > 1700000000) { + // now is past 2023 and the offset is at most +/-14 h, so local cannot + // go negative; unsigned keeps the arithmetic simple. + const uint32_t local = now + (uint32_t)((int32_t)prefs->utc_offset_hours * 3600); + const unsigned hrs = (unsigned)((local / 3600) % 24); + const unsigned mins = (unsigned)((local / 60) % 60); + char timeBuf[8]; + snprintf(timeBuf, sizeof(timeBuf), "%02u:%02u", hrs, mins); + display.setColor(DisplayDriver::LIGHT); + const uint16_t tw = display.getTextWidth(timeBuf); + int clockX = (display.width() - tw) / 2; + const int labelRight = display.getTextWidth(label) + 4; + if (clockX < labelRight) clockX = labelRight; + display.setCursor(clockX, hdrY); + display.print(timeBuf); + } + display.setTextSize(1); +} + // ---- Render ----------------------------------------------------------------- int GBCEmulatorScreen::render(DisplayDriver& display) { if (_mode == BROWSER || _mode == LOADING) return renderBrowser(display); @@ -808,6 +1041,7 @@ int GBCEmulatorScreen::renderGame(DisplayDriver& display) { _busyHooked = true; } display.startFrame(); + draw_game_header(display, _task); // Ask the emulator task for a fresh picture for next time, and draw the // one it prepared last time. @@ -818,11 +1052,19 @@ int GBCEmulatorScreen::renderGame(DisplayDriver& display) { const uint16_t fg = d.rawFgColor(); d.drawXbmRaw(GBC_IMG_X, GBC_IMG_Y, s_mono_ui, OUT_W, OUT_H, fg); +#if GBC_SOUND + d.drawTextRaw(2, 308, (_mode == STOPPING) ? "Saving..." + : (!s_audio_on ? "Q: Quit" + : (s_muted ? "Q: Quit Press Mic key to unmute" : "Q: Quit Mic: Mute")), fg); +#else d.drawTextRaw(2, 308, (_mode == STOPPING) ? "Saving..." : "Q: Quit", fg); - if (_mode == PLAYING) { - if (s_pictures > 0) d.requestWindowRefresh(GBC_IMG_X, GBC_IMG_Y, OUT_W, OUT_H); - s_pictures++; - } +#endif + // No windowed refresh: it was measured to save nothing (the panel's + // waveform time is the same for any window), and it left the footer row + // outside the window, so the mute hint never reached the glass. The + // driver's normal partial refresh covers the whole panel and skips + // refreshes when the frame has not changed. + if (_mode == PLAYING) s_pictures++; return 100; // the UI loop's 800 ms e-ink floor sets the real cadence } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 24264ed3..fbe1fa2d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1918,7 +1918,15 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i // Don't interrupt user with popup - just show brief notification // Messages are stored in channel history, accessible via tile/key // Suppress toasts for room server messages (bulk sync would spam toasts) - if (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif) { +#if defined(LilyGo_TDeck_Pro) + // No toasts over a running Game Boy game either: on a busy mesh they would + // be constant. The header on that screen shows the unread count instead. + // Snake and Minesweeper are other screens and keep their toasts. + const bool gbcPlaying = isOnGBCScreen() && ((GBCEmulatorScreen*)gbc_screen)->isRunning(); +#else + const bool gbcPlaying = false; +#endif + if (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif && !gbcPlaying) { char alertBuf[40]; snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name); showAlert(alertBuf, 2000); diff --git a/examples/companion_radio/ui-new/minigb_apu.c b/examples/companion_radio/ui-new/minigb_apu.c new file mode 100644 index 00000000..74eb7d06 --- /dev/null +++ b/examples/companion_radio/ui-new/minigb_apu.c @@ -0,0 +1,605 @@ +/** + * minigb_apu is released under the terms listed within the LICENSE file. + * + * minigb_apu emulates the audio processing unit (APU) of the Game Boy. This + * project is based on MiniGBS by Alex Baines: https://github.com/baines/MiniGBS + */ + +#include +#include +#include + +#include "minigb_apu.h" + +#define DMG_CLOCK_FREQ_U ((unsigned)DMG_CLOCK_FREQ) +#define AUDIO_NSAMPLES (AUDIO_SAMPLES * 2u) + +#define AUDIO_MEM_SIZE (0xFF3F - 0xFF10 + 1) +#define AUDIO_ADDR_COMPENSATION 0xFF10 + +#define MAX(a, b) ( a > b ? a : b ) +#define MIN(a, b) ( a <= b ? a : b ) + +#define VOL_INIT_MAX (INT16_MAX/8) +#define VOL_INIT_MIN (INT16_MIN/8) + +/* Handles time keeping for sound generation. + * FREQ_INC_REF must be equal to, or larger than AUDIO_SAMPLE_RATE in order + * to avoid a division by zero error. + * Using a square of 2 simplifies calculations. */ +#define FREQ_INC_REF (AUDIO_SAMPLE_RATE * 16) + +#define MAX_CHAN_VOLUME 15 + +/** + * Memory holding audio registers between 0xFF10 and 0xFF3F inclusive. + */ +static uint8_t audio_mem[AUDIO_MEM_SIZE]; + +struct chan_len_ctr { + uint8_t load; + unsigned enabled : 1; + uint32_t counter; + uint32_t inc; +}; + +struct chan_vol_env { + uint8_t step; + unsigned up : 1; + uint32_t counter; + uint32_t inc; +}; + +struct chan_freq_sweep { + uint16_t freq; + uint8_t rate; + uint8_t shift; + unsigned up : 1; + uint32_t counter; + uint32_t inc; +}; + +static struct chan { + unsigned enabled : 1; + unsigned powered : 1; + unsigned on_left : 1; + unsigned on_right : 1; + unsigned muted : 1; + + uint8_t volume; + uint8_t volume_init; + + uint16_t freq; + uint32_t freq_counter; + uint32_t freq_inc; + + int_fast16_t val; + + struct chan_len_ctr len; + struct chan_vol_env env; + struct chan_freq_sweep sweep; + + union { + struct { + uint8_t duty; + uint8_t duty_counter; + } square; + struct { + uint16_t lfsr_reg; + uint8_t lfsr_wide; + uint8_t lfsr_div; + } noise; + struct { + uint8_t sample; + } wave; + }; +} chans[4]; + +static int32_t vol_l, vol_r; + +static void set_note_freq(struct chan *c, const uint32_t freq) +{ + /* Lowest expected value of freq is 64. */ + c->freq_inc = freq * (uint32_t)(FREQ_INC_REF / AUDIO_SAMPLE_RATE); +} + +static void chan_enable(const uint_fast8_t i, const bool enable) +{ + uint8_t val; + + chans[i].enabled = enable; + val = (audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] & 0x80) | + (chans[3].enabled << 3) | (chans[2].enabled << 2) | + (chans[1].enabled << 1) | (chans[0].enabled << 0); + + audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] = val; + //audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] |= 0x80 | ((uint8_t)enable) << i; +} + +static void update_env(struct chan *c) +{ + c->env.counter += c->env.inc; + + while (c->env.counter > FREQ_INC_REF) { + if (c->env.step) { + c->volume += c->env.up ? 1 : -1; + if (c->volume == 0 || c->volume == MAX_CHAN_VOLUME) { + c->env.inc = 0; + } + c->volume = MAX(0, MIN(MAX_CHAN_VOLUME, c->volume)); + } + c->env.counter -= FREQ_INC_REF; + } +} + +static void update_len(struct chan *c) +{ + if (!c->len.enabled) + return; + + c->len.counter += c->len.inc; + if (c->len.counter > FREQ_INC_REF) { + chan_enable(c - chans, 0); + c->len.counter = 0; + } +} + +static bool update_freq(struct chan *c, uint32_t *pos) +{ + uint32_t inc = c->freq_inc - *pos; + c->freq_counter += inc; + + if (c->freq_counter > FREQ_INC_REF) { + *pos = c->freq_inc - (c->freq_counter - FREQ_INC_REF); + c->freq_counter = 0; + return true; + } else { + *pos = c->freq_inc; + return false; + } +} + +static void update_sweep(struct chan *c) +{ + c->sweep.counter += c->sweep.inc; + + while (c->sweep.counter > FREQ_INC_REF) { + if (c->sweep.shift) { + uint16_t inc = (c->sweep.freq >> c->sweep.shift); + if (!c->sweep.up) + inc *= -1; + + c->freq += inc; + if (c->freq > 2047) { + c->enabled = 0; + } else { + set_note_freq(c, + DMG_CLOCK_FREQ_U / ((2048 - c->freq)<< 5)); + c->freq_inc *= 8; + } + } else if (c->sweep.rate) { + c->enabled = 0; + } + c->sweep.counter -= FREQ_INC_REF; + } +} + +static void update_square(int16_t* samples, const bool ch2) +{ + uint32_t freq; + struct chan* c = chans + ch2; + + if (!c->powered || !c->enabled) + return; + + freq = DMG_CLOCK_FREQ_U / ((2048 - c->freq) << 5); + set_note_freq(c, freq); + c->freq_inc *= 8; + + for (uint_fast16_t i = 0; i < AUDIO_NSAMPLES; i += 2) { + update_len(c); + + if (!c->enabled) + continue; + + update_env(c); + if (!ch2) + update_sweep(c); + + uint32_t pos = 0; + uint32_t prev_pos = 0; + int32_t sample = 0; + + while (update_freq(c, &pos)) { + c->square.duty_counter = (c->square.duty_counter + 1) & 7; + sample += ((pos - prev_pos) / c->freq_inc) * c->val; + c->val = (c->square.duty & (1 << c->square.duty_counter)) ? + VOL_INIT_MAX / MAX_CHAN_VOLUME : + VOL_INIT_MIN / MAX_CHAN_VOLUME; + prev_pos = pos; + } + + if (c->muted) + continue; + + sample += c->val; + sample *= c->volume; + sample /= 4; + + samples[i + 0] += sample * c->on_left * vol_l; + samples[i + 1] += sample * c->on_right * vol_r; + } +} + +static uint8_t wave_sample(const unsigned int pos, const unsigned int volume) +{ + uint8_t sample; + + sample = audio_mem[(0xFF30 + pos / 2) - AUDIO_ADDR_COMPENSATION]; + if (pos & 1) { + sample &= 0xF; + } else { + sample >>= 4; + } + return volume ? (sample >> (volume - 1)) : 0; +} + +static void update_wave(int16_t *samples) +{ + uint32_t freq; + struct chan *c = chans + 2; + + if (!c->powered || !c->enabled) + return; + + freq = (DMG_CLOCK_FREQ_U / 64) / (2048 - c->freq); + set_note_freq(c, freq); + + c->freq_inc *= 32; + + for (uint_fast16_t i = 0; i < AUDIO_NSAMPLES; i += 2) { + update_len(c); + + if (!c->enabled) + continue; + + uint32_t pos = 0; + uint32_t prev_pos = 0; + int32_t sample = 0; + + c->wave.sample = wave_sample(c->val, c->volume); + + while (update_freq(c, &pos)) { + c->val = (c->val + 1) & 31; + sample += ((pos - prev_pos) / c->freq_inc) * + ((int)c->wave.sample - 8) * (INT16_MAX/64); + c->wave.sample = wave_sample(c->val, c->volume); + prev_pos = pos; + } + + sample += ((int)c->wave.sample - 8) * (int)(INT16_MAX/64); + + if (c->volume == 0) + continue; + + { + /* First element is unused. */ + int16_t div[] = { INT16_MAX, 1, 2, 4 }; + sample = sample / (div[c->volume]); + } + + if (c->muted) + continue; + + sample /= 4; + + samples[i + 0] += sample * c->on_left * vol_l; + samples[i + 1] += sample * c->on_right * vol_r; + } +} + +static void update_noise(int16_t *samples) +{ + struct chan *c = chans + 3; + + if (!c->powered) + return; + + { + const uint32_t lfsr_div_lut[] = { + 8, 16, 32, 48, 64, 80, 96, 112 + }; + uint32_t freq; + + freq = DMG_CLOCK_FREQ_U / (lfsr_div_lut[c->noise.lfsr_div] << c->freq); + set_note_freq(c, freq); + } + + if (c->freq >= 14) + c->enabled = 0; + + for (uint_fast16_t i = 0; i < AUDIO_NSAMPLES; i += 2) { + update_len(c); + + if (!c->enabled) + continue; + + update_env(c); + + uint32_t pos = 0; + uint32_t prev_pos = 0; + int32_t sample = 0; + + while (update_freq(c, &pos)) { + c->noise.lfsr_reg = (c->noise.lfsr_reg << 1) | + (c->val >= VOL_INIT_MAX/MAX_CHAN_VOLUME); + + if (c->noise.lfsr_wide) { + c->val = !(((c->noise.lfsr_reg >> 14) & 1) ^ + ((c->noise.lfsr_reg >> 13) & 1)) ? + VOL_INIT_MAX / MAX_CHAN_VOLUME : + VOL_INIT_MIN / MAX_CHAN_VOLUME; + } else { + c->val = !(((c->noise.lfsr_reg >> 6) & 1) ^ + ((c->noise.lfsr_reg >> 5) & 1)) ? + VOL_INIT_MAX / MAX_CHAN_VOLUME : + VOL_INIT_MIN / MAX_CHAN_VOLUME; + } + + sample += ((pos - prev_pos) / c->freq_inc) * c->val; + prev_pos = pos; + } + + if (c->muted) + continue; + + sample += c->val; + sample *= c->volume; + sample /= 4; + + samples[i + 0] += sample * c->on_left * vol_l; + samples[i + 1] += sample * c->on_right * vol_r; + } +} + +/** + * SDL2 style audio callback function. + */ +void audio_callback(void *userdata, uint8_t *stream, int len) +{ + int16_t *samples = (int16_t *)stream; + + /* Appease unused variable warning. */ + (void)userdata; + + memset(stream, 0, len); + + update_square(samples, 0); + update_square(samples, 1); + update_wave(samples); + update_noise(samples); +} + +static void chan_trigger(uint_fast8_t i) +{ + struct chan *c = chans + i; + + chan_enable(i, 1); + c->volume = c->volume_init; + + // volume envelope + { + uint8_t val = + audio_mem[(0xFF12 + (i * 5)) - AUDIO_ADDR_COMPENSATION]; + + c->env.step = val & 0x07; + c->env.up = val & 0x08 ? 1 : 0; + c->env.inc = c->env.step ? + (FREQ_INC_REF * 64ul) / ((uint32_t)c->env.step * AUDIO_SAMPLE_RATE) : + (8ul * FREQ_INC_REF) / AUDIO_SAMPLE_RATE ; + c->env.counter = 0; + } + + // freq sweep + if (i == 0) { + uint8_t val = audio_mem[0xFF10 - AUDIO_ADDR_COMPENSATION]; + + c->sweep.freq = c->freq; + c->sweep.rate = (val >> 4) & 0x07; + c->sweep.up = !(val & 0x08); + c->sweep.shift = (val & 0x07); + c->sweep.inc = c->sweep.rate ? + ((128 * FREQ_INC_REF) / (c->sweep.rate * AUDIO_SAMPLE_RATE)) : 0; + c->sweep.counter = FREQ_INC_REF; + } + + int len_max = 64; + + if (i == 2) { // wave + len_max = 256; + c->val = 0; + } else if (i == 3) { // noise + c->noise.lfsr_reg = 0xFFFF; + c->val = VOL_INIT_MIN / MAX_CHAN_VOLUME; + } + + c->len.inc = (256 * FREQ_INC_REF) / (AUDIO_SAMPLE_RATE * (len_max - c->len.load)); + c->len.counter = 0; +} + +/** + * Read audio register. + * \param addr Address of audio register. Must be 0xFF10 <= addr <= 0xFF3F. + * This is not checked in this function. + * \return Byte at address. + */ +uint8_t audio_read(const uint16_t addr) +{ + static const uint8_t ortab[] = { + 0x80, 0x3f, 0x00, 0xff, 0xbf, + 0xff, 0x3f, 0x00, 0xff, 0xbf, + 0x7f, 0xff, 0x9f, 0xff, 0xbf, + 0xff, 0xff, 0x00, 0x00, 0xbf, + 0x00, 0x00, 0x70, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + return audio_mem[addr - AUDIO_ADDR_COMPENSATION] | + ortab[addr - AUDIO_ADDR_COMPENSATION]; +} + +/** + * Write audio register. + * \param addr Address of audio register. Must be 0xFF10 <= addr <= 0xFF3F. + * This is not checked in this function. + * \param val Byte to write at address. + */ +void audio_write(const uint16_t addr, const uint8_t val) +{ + /* Find sound channel corresponding to register address. */ + uint_fast8_t i; + + if(addr == 0xFF26) + { + audio_mem[addr - AUDIO_ADDR_COMPENSATION] = val & 0x80; + /* On APU power off, clear all registers apart from wave + * RAM. */ + if((val & 0x80) == 0) + { + memset(audio_mem, 0x00, 0xFF26 - AUDIO_ADDR_COMPENSATION); + chans[0].enabled = false; + chans[1].enabled = false; + chans[2].enabled = false; + chans[3].enabled = false; + } + + return; + } + + /* Ignore register writes if APU powered off. */ + if(audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] == 0x00) + return; + + audio_mem[addr - AUDIO_ADDR_COMPENSATION] = val; + i = (addr - AUDIO_ADDR_COMPENSATION) / 5; + + switch (addr) { + case 0xFF12: + case 0xFF17: + case 0xFF21: { + chans[i].volume_init = val >> 4; + chans[i].powered = (val >> 3) != 0; + + // "zombie mode" stuff, needed for Prehistorik Man and probably + // others + if (chans[i].powered && chans[i].enabled) { + if ((chans[i].env.step == 0 && chans[i].env.inc != 0)) { + if (val & 0x08) { + chans[i].volume++; + } else { + chans[i].volume += 2; + } + } else { + chans[i].volume = 16 - chans[i].volume; + } + + chans[i].volume &= 0x0F; + chans[i].env.step = val & 0x07; + } + } break; + + case 0xFF1C: + chans[i].volume = chans[i].volume_init = (val >> 5) & 0x03; + break; + + case 0xFF11: + case 0xFF16: + case 0xFF20: { + const uint8_t duty_lookup[] = { 0x10, 0x30, 0x3C, 0xCF }; + chans[i].len.load = val & 0x3f; + chans[i].square.duty = duty_lookup[val >> 6]; + break; + } + + case 0xFF1B: + chans[i].len.load = val; + break; + + case 0xFF13: + case 0xFF18: + case 0xFF1D: + chans[i].freq &= 0xFF00; + chans[i].freq |= val; + break; + + case 0xFF1A: + chans[i].powered = (val & 0x80) != 0; + chan_enable(i, val & 0x80); + break; + + case 0xFF14: + case 0xFF19: + case 0xFF1E: + chans[i].freq &= 0x00FF; + chans[i].freq |= ((val & 0x07) << 8); + /* Intentional fall-through. */ + case 0xFF23: + chans[i].len.enabled = val & 0x40 ? 1 : 0; + if (val & 0x80) + chan_trigger(i); + + break; + + case 0xFF22: + chans[3].freq = val >> 4; + chans[3].noise.lfsr_wide = !(val & 0x08); + chans[3].noise.lfsr_div = val & 0x07; + break; + + case 0xFF24: + { + vol_l = ((val >> 4) & 0x07); + vol_r = (val & 0x07); + break; + } + + case 0xFF25: + for (uint_fast8_t j = 0; j < 4; j++) { + chans[j].on_left = (val >> (4 + j)) & 1; + chans[j].on_right = (val >> j) & 1; + } + break; + } +} + +void audio_init(void) +{ + /* Initialise channels and samples. */ + memset(chans, 0, sizeof(chans)); + chans[0].val = chans[1].val = -1; + + /* Initialise IO registers. */ + { + const uint8_t regs_init[] = { 0x80, 0xBF, 0xF3, 0xFF, 0x3F, + 0xFF, 0x3F, 0x00, 0xFF, 0x3F, + 0x7F, 0xFF, 0x9F, 0xFF, 0x3F, + 0xFF, 0xFF, 0x00, 0x00, 0x3F, + 0x77, 0xF3, 0xF1 }; + + for(uint_fast8_t i = 0; i < sizeof(regs_init); ++i) + audio_write(0xFF10 + i, regs_init[i]); + } + + /* Initialise Wave Pattern RAM. */ + { + const uint8_t wave_init[] = { 0xac, 0xdd, 0xda, 0x48, + 0x36, 0x02, 0xcf, 0x16, + 0x2c, 0x04, 0xe5, 0x2c, + 0xac, 0xdd, 0xda, 0x48 }; + + for(uint_fast8_t i = 0; i < sizeof(wave_init); ++i) + audio_write(0xFF30 + i, wave_init[i]); + } +} diff --git a/examples/companion_radio/ui-new/minigb_apu.h b/examples/companion_radio/ui-new/minigb_apu.h new file mode 100644 index 00000000..9bae7d43 --- /dev/null +++ b/examples/companion_radio/ui-new/minigb_apu.h @@ -0,0 +1,49 @@ +/** + * minigb_apu is released under the terms listed within the LICENSE file. + * + * minigb_apu emulates the audio processing unit (APU) of the Game Boy. This + * project is based on MiniGBS by Alex Baines: https://github.com/baines/MiniGBS + */ + +#pragma once + +#include + +/* MECK PATCH: upstream runs the APU at the Game Boy's native 32768 Hz, + * which the ES8311 codec's clock table on the T-Display P4 does not + * support ("search _clock_coeff_list fail" in the bus driver, codec left + * unclocked, silent playback). 44100 Hz is the audiobook player's proven + * rate on this codec. Every APU timing quantity derives from this macro + * (FREQ_INC_REF = rate * 16, all integer maths), so the synthesis stays + * exact; the per-frame sample count becomes 738. */ +// Meck (T-Deck): 22050, half the P4's 44100. Halves the APU's per-frame +// cost on the ESP32-S3 (measured 3.6-4.0 ms at 44100 against a 16.7 ms +// frame); the 4-bit Game Boy sound loses nothing audible at 22 kHz. +#define AUDIO_SAMPLE_RATE 22050 + +#define DMG_CLOCK_FREQ 4194304.0 +#define SCREEN_REFRESH_CYCLES 70224.0 +#define VERTICAL_SYNC (DMG_CLOCK_FREQ/SCREEN_REFRESH_CYCLES) + +#define AUDIO_SAMPLES ((unsigned)(AUDIO_SAMPLE_RATE / VERTICAL_SYNC)) + +/** + * Fill allocated buffer "data" with "len" number of 32-bit floating point + * samples (native endian order) in stereo interleaved format. + */ +void audio_callback(void *ptr, uint8_t *data, int len); + +/** + * Read audio register at given address "addr". + */ +uint8_t audio_read(const uint16_t addr); + +/** + * Write "val" to audio register at given address "addr". + */ +void audio_write(const uint16_t addr, const uint8_t val); + +/** + * Initialise audio driver. + */ +void audio_init(void); \ No newline at end of file diff --git a/variants/lilygo_tdeck_max/TCA8418Keyboard.h b/variants/lilygo_tdeck_max/TCA8418Keyboard.h index 4dae0fee..1566c74d 100644 --- a/variants/lilygo_tdeck_max/TCA8418Keyboard.h +++ b/variants/lilygo_tdeck_max/TCA8418Keyboard.h @@ -53,6 +53,7 @@ private: bool _rawJoypad; volatile uint8_t _rawMask; // Peanut-GB direct.joypad bit layout, set = held volatile bool _rawExit; // Q or Shift+Backspace press-edge latch + volatile bool _rawMute; // Mic key press-edge latch (game sound mute toggle) bool _rawShiftL; // left Shift (35) physically held bool _rawShiftR; // right Shift (31) physically held @@ -73,6 +74,10 @@ private: if (pressed) _rawExit = true; return 0; } + if (keyCode == 34) { // Mic = mute toggle + if (pressed) _rawMute = true; + return 0; + } if (keyCode == 11) { // Backspace: Shift+Backspace = quit if (pressed && (_rawShiftL || _rawShiftR)) _rawExit = true; return 0; @@ -211,7 +216,7 @@ public: : _addr(addr), _wire(wire), _initialized(false), _shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _leftShiftHeld(false), _rightShiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0), _enterHeld(false), _enterPressTime(0), - _rawJoypad(false), _rawMask(0), _rawExit(false), _rawShiftL(false), _rawShiftR(false) {} + _rawJoypad(false), _rawMask(0), _rawExit(false), _rawMute(false), _rawShiftL(false), _rawShiftR(false) {} bool begin() { // Check if device responds @@ -461,6 +466,7 @@ public: _rawJoypad = on; _rawMask = 0; _rawExit = false; + _rawMute = false; _rawShiftL = _rawShiftR = false; _shiftActive = _shiftHeld = _leftShiftHeld = _rightShiftHeld = false; _shiftUsedWhileHeld = _shiftConsumed = false; @@ -475,6 +481,12 @@ public: _rawExit = false; return e; } + // Mic key press-edge latch, consumed on read. + bool rawMutePressed() { + bool m = _rawMute; + _rawMute = false; + return m; + } bool isMicHeld() const { return _micHeld; } // Check if shift was pressed within the last N milliseconds diff --git a/variants/lilygo_tdeck_pro/TCA8418Keyboard.h b/variants/lilygo_tdeck_pro/TCA8418Keyboard.h index 33f8e633..06d08e21 100644 --- a/variants/lilygo_tdeck_pro/TCA8418Keyboard.h +++ b/variants/lilygo_tdeck_pro/TCA8418Keyboard.h @@ -49,6 +49,7 @@ private: bool _rawJoypad; volatile uint8_t _rawMask; // Peanut-GB direct.joypad bit layout, set = held volatile bool _rawExit; // Q or Shift+Backspace press-edge latch + volatile bool _rawMute; // Mic key press-edge latch (game sound mute toggle) bool _rawShiftL; // left Shift (35) physically held bool _rawShiftR; // right Shift (31) physically held @@ -66,6 +67,10 @@ private: if (pressed) _rawExit = true; return; } + if (keyCode == 34) { // Mic = mute toggle + if (pressed) _rawMute = true; + return; + } if (keyCode == 11) { // Backspace: Shift+Backspace = quit if (pressed && (_rawShiftL || _rawShiftR)) _rawExit = true; return; @@ -203,7 +208,7 @@ public: : _addr(addr), _wire(wire), _initialized(false), _shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0), _enterHeld(false), _enterPressTime(0), - _rawJoypad(false), _rawMask(0), _rawExit(false), _rawShiftL(false), _rawShiftR(false) {} + _rawJoypad(false), _rawMask(0), _rawExit(false), _rawMute(false), _rawShiftL(false), _rawShiftR(false) {} bool begin() { // Check if device responds @@ -431,6 +436,7 @@ public: _rawJoypad = on; _rawMask = 0; _rawExit = false; + _rawMute = false; _rawShiftL = _rawShiftR = false; _shiftActive = _shiftHeld = false; _shiftUsedWhileHeld = _shiftConsumed = false; @@ -445,6 +451,12 @@ public: _rawExit = false; return e; } + // Mic key press-edge latch, consumed on read. + bool rawMutePressed() { + bool m = _rawMute; + _rawMute = false; + return m; + } bool isMicHeld() const { return _micHeld; } // Check if shift was pressed within the last N milliseconds