# SDLamp2 - Functional Specification Document ## Document Information | Field | Value | | :------ | :--------- | | Version | 1.0 | | Status | Draft | | Created | 2026-02-10 | | Updated | 2026-02-15 | ## 1. Purpose This document specifies the functional requirements for an SDL2 based media player application written in C, aimed at being used by a pre-teen child. The name is a reference to the Winamp media player, popular in the 90's and inspiration can be drawn from that. ## 2. Goals - Play large m4a audio files, approximately 3 hours each, created from original cassette tapes that contain collections of fairy tales for children - Present a super simple interface, easy to use by a child less than 10 years old, reminiscent of a cassette player: rewind, stop, play / pause, fast forward, load another tape - The playback of the audio files must emulate that of cassette tapes, meaning the position of each file should be stored and remembered and playback should resume from that position even if other files have been played in the mean time - The interface should show the embedded album cover if present in the file ## 3. Software architecture - The program should be written in modern C using GCC or Clang - It should use the SDL2 library for screen rendering, audio playback and input handling - It should use the libav (ffmpeg) suite of libraries to decode m4a files and potentially other formats (e.g. mp3) - It should not call out to an ffmpeg binary, but instead use the libav C API library functions ## 4. Design principles - Version control (git) must be used - Compilation should be performed by a simple Makefile supporting both native and cross-compilation - C source code files should be formatted using "Google" style with an additional change of `ColumnLimit` set to 100 - Less is more, minimize dependencies, avoid pulling in extra libraries, always talk through with owner first - Keep it simple, apply Casey Muratori's `semantic compression` principles, don't refactor too soon or write code that's too clever for its own good - Keep a changelog in this functional specification document ## 5. Target Platforms - **Deployment**: Linux ARM64 (aarch64) on a retro handheld gaming device - **Development/testing**: macOS Apple Silicon ## 6. Changelog ### 2026-02-15 — Transparent controls spritesheet support - **Alpha blending on controls texture**: `SDL_SetTextureBlendMode(controls_texture, SDL_BLENDMODE_BLEND)` enables alpha transparency for the controls spritesheet. Sprite icons now float cleanly on any background color instead of showing white cell backgrounds. - **Transparent skin template**: `gen_skin_template.py` now generates cells with transparent backgrounds (RGBA) instead of white. Gutters use bright magenta (`#FF00FF`) so they're clearly distinguishable from transparent content areas. ### 2026-02-14 — Skin template system and device script reorganization - **Skin template generator**: New `tools/gen_skin_template.py` (requires Pillow) generates a 642x420 PNG template showing the sprite grid layout with labeled gutters. Skin creators can draw over the white 200x200 cells; the 20px gray gutters (never rendered by the app) identify each cell's purpose. - **Separate Prev sprite**: `prev_sprite` now uses the bottom-center cell `{220, 220}` instead of sharing the bottom-right cell with `next_sprite`. This gives Prev and Next distinct sprites in the spritesheet. - **Device scripts moved**: `rg35xx-wrapper.sh` and `rg35xx-screen-monitor.py` moved from `tools/` to `device/rg35xx/`, separating device-specific scripts from dev tools. ### 2026-02-14 — Softer background, remove panel divider - **Background color**: Changed from white (`#FFFFFF`) to a medium gray (`#979797`) for a gentler appearance. - **Remove divider**: Removed the vertical separator line between the controls panel and the album art. ### 2026-02-14 — Fix residual audio on cassette switch - **Clear device queue on switch**: `switch_file()` now calls `SDL_ClearQueuedAudio()` after pausing the audio device, preventing a brief snippet of the previous cassette from playing when the new one starts. ### 2026-02-14 — Split-screen layout with artwork focus - **Vertical left panel**: Transport controls (Prev, Rewind, Play/Stop, FF, Next) are stacked vertically in a 200px-wide left panel. Play/Stop is slightly larger (72x72) at the vertical center; other buttons are 56x56. - **Full-height artwork**: Album art now fills the right panel (420x460 max bounds, centered at x=420, y=240), giving cassette covers nearly the full screen height instead of being constrained to the upper portion. - **Vertical navigation**: D-pad UP/DOWN (and keyboard arrows) now navigate between buttons vertically instead of LEFT/RIGHT horizontally, matching the new stacked layout. - **Dedicated volume controls**: Volume is no longer a focusable UI element. Adjusted via `+`/`-` keys, `SDLK_VOLUMEUP`/`SDLK_VOLUMEDOWN`, or controller shoulder buttons (L1/R1). - **Sprite scaling quality**: Linear filtering (`SDL_HINT_RENDER_SCALE_QUALITY`) enabled for smoother downscaling of 200x200 sprite source to 56x56 button destinations. - **No-art placeholder**: When a file has no embedded album art, a circle sprite from the spritesheet is rendered as a placeholder in the right panel. - **Thin progress bar**: Progress bar moved to the bottom of the left panel (160x4px) as a subtle position indicator. ### 2026-02-13 — Fix power button screen toggle regression - **Power button screen off stays off**: Fixed regression from fbd32d7 where short-pressing the power button to turn off the screen would instantly turn it back on. The generic wake logic (`any_activity`) was being triggered by power button events themselves. Moved `any_activity = True` below the power button handler's `continue` so power events are handled exclusively by the power button handler and don't trigger the wake path. ### 2026-02-13 — Screen wake fixes and idle auto-shutdown - **D-pad wakes screen**: The screen monitor now wakes on any input event type (EV_ABS, EV_KEY, etc.), not just EV_KEY. This fixes d-pad presses (which generate EV_ABS hat events) not waking the screen. - **Wake button doesn't act in app**: Uses EVIOCGRAB to take exclusive access to input devices while the screen is off. SDL in sdlamp2 receives no events while grabbed, so the button press that wakes the screen doesn't also trigger play/stop or switch cassettes. Grabs are released when the screen turns back on. - **Idle auto-shutdown**: After 10 minutes of no input AND no audio playback, the device auto-shuts down to save battery. Playback detection works by monitoring the audio file's read position via `/proc//fdinfo/` — if the file offset is advancing, audio is being decoded. The timer resets on any input event or any detected playback activity. - **Goodbye screen on auto-shutdown**: The wrapper now restores backlight brightness via `/dev/disp` `DISP_SET_BRIGHTNESS` ioctl before writing `goodbye.png` to `/dev/fb0`. Previously the screen monitor's SIGTERM handler restored brightness, but the Allwinner driver resets it to 0 when the fd is closed, so the goodbye screen was never visible on idle auto-shutdown. ### 2026-02-13 — Remember last cassette and pause on switch - **Remember last cassette**: The current cassette filename is saved to `last_cassette.txt` in the audio directory on every file switch. On startup, the player resumes the last-loaded cassette instead of always starting with the first file. Falls back to the first file if the saved file is missing or not found. - **Pause on cassette switch**: Switching cassettes (prev/next) now always lands in a paused state, even if the player was playing. This avoids the jarring effect of immediately resuming from a saved position in a different cassette. ### 2026-02-13 — Fix power button shutdown regression - **Consolidated input handling**: Power button long-press shutdown is now handled by `rg35xx-screen-monitor.py` instead of a separate `evtest`-based monitor in the wrapper. Both monitors were reading `/dev/input/event0` simultaneously, causing the `evtest` parser to miss power button events on the device's Linux 4.9 kernel. - **Timer-based long press**: The screen monitor uses a dynamic `select()` timeout to detect the 3-second hold threshold while the button is still held, rather than waiting for release. On long press, it touches `/tmp/.sdlamp2_shutdown` and sends SIGTERM to sdlamp2. - **Removed evtest dependency**: The `monitor_power_button()` function and `evtest` pipe are removed from `rg35xx-wrapper.sh`. The screen monitor accepts the sdlamp2 PID as a command-line argument and launches after sdlamp2. ### 2026-02-13 — Screen idle timeout and power button toggle - **Screen idle timeout**: New Python screen monitor (`rg35xx-screen-monitor.py`) turns off the display after 15 seconds of no input on any device. Audio continues playing. Any button press wakes the screen. - **Power button screen toggle**: A short press (<2s) of the power button toggles the screen on/off. Long press (3s+) still triggers shutdown via the existing wrapper logic. - **Allwinner /dev/disp backlight**: Uses `SET_BRIGHTNESS` / `GET_BRIGHTNESS` ioctls on `/dev/disp` since the device has no sysfs backlight interface. Brightness is restored on SIGTERM so the shutdown screen remains visible. - **Wrapper integration**: `rg35xx-wrapper.sh` launches the screen monitor alongside sdlamp2 and kills it during cleanup. ### 2026-02-13 — Hold-to-shutdown with stock shutdown screen - **Hold-to-shutdown**: The power button monitor in `rg35xx-wrapper.sh` requires a 3-second hold before triggering shutdown. A quick tap does nothing — a background timer subshell starts on press and is cancelled on release. If held past 3 seconds, sends SIGTERM to sdlamp2 then triggers shutdown. - **Stock shutdown screen**: The wrapper displays the stock firmware's `goodbye.png` (`/mnt/vendor/res1/shutdown/goodbye.png`) by decoding it with Python3+PIL and writing raw BGRA pixels to `/dev/fb0`. This reuses the same shutdown image that `dmenu.bin` shows, keeping the experience consistent. The shutdown display is handled entirely in the wrapper — sdlamp2 just exits cleanly on SIGTERM. - **Restart loop blocked**: The wrapper uses a `/tmp/.sdlamp2_shutdown` flag file to distinguish shutdown from normal exit. On shutdown, the wrapper calls `poweroff` and blocks (sleep 30), preventing `loadapp.sh`'s restart loop from relaunching `dmenu_ln` and overwriting the shutdown screen. ### 2026-02-13 — Clean shutdown on SIGTERM/SIGINT - **Signal handling**: sdlamp2 now catches SIGTERM and SIGINT via a `sig_atomic_t` flag checked in the main loop. On signal, the existing cleanup path runs (save position, save volume, close decoder, SDL_Quit) instead of being killed instantly. This ensures position is saved when the system shuts down or the process is terminated by a wrapper script. ### 2026-02-13 — Combine Play/Stop, add Previous Cassette button - **Play/Stop combined**: The separate Stop and Play buttons are merged into a single toggle button that shows the play icon (▶) when paused and the stop icon (■) when playing. - **Previous Cassette**: A new "Previous Cassette" button is added at the left of the transport controls, mirroring "Next Cassette". Wraps from the first tape to the last. - **New layout**: `[Volume] [Prev] [Rewind] [Play/Stop] [FF] [Next]` — same 6 focusable elements, same positions. ### 2026-02-13 — Start paused, fix next-tape autoplay, quiet joystick log - **Start paused**: The player no longer autoplays on startup; it opens the last file at the saved position but waits for the user to press Play. - **Next tape respects pause state**: `switch_file()` no longer forces playback. Pressing "next tape" while paused switches the file and stays paused; pressing it while playing switches and continues playing. - **Joystick log behind --debug**: The "Joystick: ..." message is now only printed when `--debug` is passed. ### 2026-02-13 — arm64 Docker build container - **New build container**: Replaced the broken Buildroot cross-compilation toolchain (`docker/`) with an arm64 Ubuntu 22.04 Docker container (`docker-arm64/`). Runs via QEMU user-mode emulation on x86 hosts and matches the target device exactly (same distro, same glibc, same library versions). The project's native `make` works as-is inside the container — no cross-compilation flags needed. ### 2026-02-13 — Replace build scripts with Makefile - **Makefile**: Replaced `build.sh` (macOS) and `build_aarch64.sh` (Linux ARM) with a single `Makefile`. Native builds use `pkg-config` to resolve SDL2 and FFmpeg flags; cross-compilation uses `CROSS_COMPILE` and `PREFIX` environment variables set by the Docker/Buildroot toolchain. ### 2026-02-13 — Raw joystick fallback for non-standard controllers - **Joystick fallback**: When no SDL GameController mapping exists for a connected device, the joystick is now opened directly via `SDL_JoystickOpen()` as a fallback. This fixes d-pad and face button input on devices like the Anbernic retro handheld whose GUID is not in SDL's GameController database. - **Hat/button handling**: `SDL_JOYHATMOTION` events drive d-pad navigation (left/right to move focus, up/down for volume), and `SDL_JOYBUTTONDOWN` button 0 (BTN_SOUTH / A) activates the focused button. - **Hot-unplug**: Raw joystick is properly closed on `SDL_JOYDEVICEREMOVED`. ### 2026-02-13 — Embed controls spritesheet into binary - **Embedded asset**: `controls.png` is now compiled into the binary as a C byte array (`src/controls_png.h`), eliminating the requirement to run from the `build/` directory. - **External override**: If a `controls.png` file exists in the current working directory, it is loaded in preference to the embedded data, preserving the ability to use custom skins. ### 2026-02-11 — Debug flag for input diagnostics - **`--debug` flag**: New command-line option (`./sdlamp2 --debug [audio_dir]`) that enables verbose logging of all SDL input events to stdout. Designed to diagnose controller issues on retro handheld devices where non-standard controllers may not be recognized by SDL's GameController API. - **Startup enumeration**: When debug is on, logs the number of detected joysticks, each joystick's name, whether SDL recognizes it as a GameController, and its GUID. - **Event logging**: Logs `KEYDOWN/UP`, `CONTROLLERBUTTONDOWN/UP`, `JOYBUTTONDOWN/UP`, `JOYHATMOTION`, `JOYAXISMOTION` (with deadzone filter), and device add/remove events with identifying details. ### 2026-02-11 — Lossless M4A concatenation tool - **New tool**: `tools/concat_cassette.sh` — losslessly concatenates multiple m4a files into a single file using the ffmpeg concat demuxer (`-c copy`, no re-encoding). Designed to recombine individual story files back into per-cassette files. - **Chapter markers**: Generates ffmpeg metadata with one chapter per input file, titled from the input filename (sans extension). Chapters enable navigation within the combined file. - **Album art preserved**: Attached pictures from the first input file carry through automatically via stream copy. - **Fast-start output**: Uses `-movflags +faststart` to place the moov atom at the front for better seeking. ### 2026-02-10 — Volume control and d-pad/keyboard navigation - **Cursor-based navigation**: Replaced mouse input with a focus-highlight model. Arrow keys (Left/Right) and d-pad move a visible blue highlight between UI elements; Enter/A button activates the focused button. Designed for use on a handheld gaming device with no mouse. - **App-level volume control**: Float samples are scaled by a volume factor (0–100%) before being queued to the audio device. Volume slider rendered as a vertical bar to the left of the transport buttons, using the same gray palette as the progress bar. - **Volume persistence**: Volume level saved to `volume.txt` in the audio directory. Defaults to 50% on first run. Loaded on startup, saved on quit and on every adjustment. - **SDL_GameController support**: Uses `SDL_INIT_GAMECONTROLLER` and the `SDL_GameController` API to normalize d-pad input across hardware. Hot-plug support via `SDL_CONTROLLERDEVICEADDED/REMOVED`. Keyboard (arrow keys + Enter) works identically for desktop testing. - **Removed mouse input**: `SDL_MOUSEBUTTONDOWN` handler removed entirely; all interaction is now via keyboard or gamepad. ### 2026-02-10 — Full implementation of audio player features - **Streaming decoder**: Replaced fire-and-forget `decode_audio()` with a persistent `Decoder` struct that streams audio on demand via `decoder_pump()`. Uses `libswresample` (`swr_alloc_set_opts2`) to convert from the decoder's native format (e.g. planar float) to interleaved float stereo 48kHz. Fixes the sped-up/distorted audio bug and eliminates the multi-GB memory spike for long files. - **Seeking**: Rewind (10s back) and fast-forward (10s ahead) via `av_seek_frame()` with codec buffer flush and audio pipeline clear. Clamped to file bounds. - **Play/Stop separation**: Removed play/pause toggle. Play always resumes, stop always pauses in place and saves position. No icon toggling. - **Position persistence**: Saves/loads playback position per file in `positions.txt` (tab-separated) in the audio directory. Position saved on stop, quit, and file switch. Restored on file open. - **File selection**: Scans audio directory for `.m4a`, `.mp3`, `.wav`, `.ogg` files. Sorted alphabetically. 5th button ("next tape") cycles through files. Window title shows current filename. - **Album art**: Extracts embedded cover art (`AV_DISPOSITION_ATTACHED_PIC`) and displays it scaled with preserved aspect ratio in the upper portion of the window. - **Progress bar**: Gray bar between album art and controls showing playback position relative to duration. - **Command-line argument**: First argument sets audio directory (defaults to current working directory). - **Error handling**: Non-fatal errors (stream ops, corrupt files) use `fprintf(stderr)` and continue. Corrupt files are skipped when switching. Fatal errors (SDL init, window, audio device) still abort. Proper cleanup order on exit. - **EOF handling**: When a file plays to the end, playback auto-pauses and resets to the start. - **Removed dead code**: `load_audio_file()`, `wavbuf`/`wavlen`/`wavspec` globals.