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
This commit is contained in:
pelgraine committed 2026-09-15 18:42:55 +10:00
1 parent 1bad37d1ad
commit 5a911619f7
7 files changed
+959 -23

No files matched your search

+8
View File
@@ -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
@@ -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 <helpers/ui/GxEPDDisplay.h>
#include "TCA8418Keyboard.h"
@@ -113,13 +120,39 @@
#include <strings.h>
#include <stdio.h>
// 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
}
+9 -1
View File
@@ -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);
@@ -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 <stdbool.h>
#include <stdint.h>
#include <string.h>
#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]);
}
}
@@ -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 <stdint.h>
/* 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);
+13 -1
View File
@@ -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
+13 -1
View File
@@ -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