A terminal (TUI) music player for retro game-music and tracker formats — chiptunes (NSF, GBS, HES, SPC, VGM, SAP, GYM, AY, KSS via libgme), tracker modules (MOD, XM, IT, S3M, and more via libopenmpt), and ordinary audio files (MP3, FLAC, AAC/M4A, Ogg, Opus, WAV, and more via FFmpeg). Written in Ruby, with a small C shim (miniaudio) for audio output.
Many popular chiptune formats support a live look into what the various sound channels are doing. Here's "The Shinobi" from Revenge of Shinobi / The Super Shinobi (Yuzo Koshiro) showing us what all of the Genesis' sound channels are doing. We can even see part of Yuzo's secret sauce here -- the seldom-used PSG channels are helping out the FM channels.
- Ruby 4.x (managed via mise; see
.ruby-version) - Homebrew:
libgme,libopenmpt,sox,ffmpeg bsdtar(ships with macOS; fallback:brew install libarchive)- Xcode command-line tools (
clang) to build the native audio shim
brew install libgme libopenmpt sox ffmpeg
mise install
mise exec -- bundle installInstall commands that work from any directory with YJIT enabled:
bin/install
rubyplayer [path ...]
rp [path ...]The installer creates ~/.local/bin/rubyplayer and an rp alias. If that
directory is absent from PATH, it prints the line to add to your shell
configuration (usually ~/.zshrc). Use bin/install --bin-dir DIR for another
location. Existing unrelated commands are preserved. Rerun the installer if
you move the checkout; remove both installed commands to uninstall.
Both commands use the checkout's mise Ruby version and Gemfile, preserve your current directory for relative music paths, and forward all arguments. Install the dependencies above before launching.
To run directly from the checkout:
mise exec -- bundle exec ruby --yjit bin/rubyplayer [path ...]mise exec -- bundle exec rake compile builds the native audio shim (lib/rubyplayer/native/librp_audio.dylib), the SPC player (lib/rubyplayer/native/librp_spc.dylib), and the GBS, NSF, and HES players (lib/rubyplayer/native/librp_gbs.dylib, librp_nsf.dylib, librp_hes.dylib; all C++, needing clang++). mise exec -- bundle exec rake test also builds them as a prerequisite. You can build them manually, but a fresh checkout will build them on first launch.
Startup checks every supported runtime dependency before taking over the terminal. Missing Homebrew packages are reported together with one brew install ... command. If a bundled library is missing or cannot be loaded, RubyPlayer builds the affected library once and checks it again. This may delay that launch; normal launches do not run Rake or a compiler. If the build fails, its output and a manual retry command are shown. After changing native C/C++ sources, run mise exec -- bundle exec rake compile yourself; startup checks whether binaries load, not whether sources are newer.
Any paths given on the command line are scanned into the library on startup, in addition to any roots already remembered from a previous run.
Select Focus in the Library pane, then choose a noise recipe in Tracks and press Enter to play it indefinitely. Focus sounds use SoX, are not added to the queue or library history, and stop when normal playback begins.
- Press
>to skip to the next track. Press<to restart the current track; within its first three seconds, it plays the previous track from this session if available. With no previous track, it restarts the current track. - Footer commands occupy three stable rows: playback, queue actions, and controls for the focused pane. Keys and colons are bold; Ctrl combinations use caret notation (
^Rfor Ctrl+R). Feature toggles show their current(on)or(off)state. Narrow terminals show shorter labels and fewer hints. Help stays at the right edge; press?for every available command. - Page Up/Page Down (Fn+↑/↓ on Mac laptops), Option+↑/↓,
Ctrl+B/Ctrl+F, orCtrl+U/Ctrl+Dpage through lists. - Press
/to filter current Tracks view live across title, artist, album, composer, and path. Enter accepts filter, as do Up, Down, and Tab, which then move the selection or switch pane as usual; Escape restores previous filter. Each view remembers its filter, cursor, and scroll position. - Tracks pane title shows current source or folder breadcrumb plus visible item count. Long breadcrumbs keep leaf name and count visible.
- Track times without an authoritative file or M3U duration are shown with
~. For looping chiptunes, the planned playback length is the intro plus two loops; when no timing data exists, it is the 2:30 playback cutoff. PressIon a track to see whether its length came from file metadata, an M3U playlist, loop timing, or the default cutoff. - Pane-edge scrollbar appears only when rows overflow viewport.
- Terminals narrower than 72 columns show one full-width pane; Tab switches panes. Wider terminals retain two-pane layout.
- The Queued pane always shows upcoming tracks and the three latest playback-history entries: a right-hand column when Library + Tracks keep 72 columns beside it, otherwise stacked under Tracks (or under the single pane on narrow terminals). Tab selects it; upcoming rows support the same queue actions as the top-level Playback Queue view, while history rows are informational. It hides only while the Playback Queue view is selected (which already shows the queue) or on screens too short to share.
- The Now Playing bar under the panes shows the playing track's title, rating, and state; its artist, album, year, track number, and composer; its file type and hardware or codec facts; and a progress bar, beside a spectrum as tall as the bar (album art too in
paneart mode). It is the last Tab stop. Terminals under 30 rows show it as one line (title, rating, time, and a small spectrum). - Keys
1–6rate a track and0clears its rating: the highlighted track in Tracks or Queued, or the playing track from the Now Playing bar. The Library pane rates nothing. - In Tracks and Queued, Shift+↑/↓ selects a range of tracks and Ctrl+A selects every track in the current view (including only matches when filtered); the Tracks title counts multi-track selections. Enter puts the selection at the head of the queue and plays its first track;
Q/Nqueue it,1–6/0rate it,Ladds it to a playlist, andXremoves it from a playlist or, in the Playback Queue view or Queued, from the queue as one undoable edit (a playing track in the range is skipped). Any other movement, sorting, filtering, or Escape ends a range. Playlist entries move one at a time. - Fixed smart views expose Recently Added, Unrated, Missing, Failed to Scan, and Most Played track lists.
- In Missing, press
Ctrl+Xto permanently purge currently filtered/visible tracks and their playback history after confirmation. - During Focus playback the Now Playing bar names the active recipe, shows ∞ for its duration, and notes the paused queue.
Inline artwork uses the Kitty graphics protocol in Kitty and iTerm2 3.5.4 or
newer. Detection is automatic; older iTerm2 releases, multiplexers, and other
terminals show no images while track information remains available. Press V
to cycle placement through off, inset (bottom of the Library pane), pane (left
of the Now Playing bar), and corner; the choice persists in configuration.
Press O to open the now-playing view. Artwork keeps its aspect ratio,
centered within whatever space the placement leaves; the inset grows with the
Library pane so the cover spans its width. Without a cover, only the inset
keeps its space, filled with a braille spectrum while audio plays; the corner
and the now-playing view's art column disappear.
Now Playing separates artist and album information from technical file details.
Playback keys keep working while it is open, on any page: Space plays or
pauses, </> go to the previous or next track (the popup follows), and
[/] seek.
Track numbers show N of M when the file tags include a total. The technical
section shows file type, codec, bitrate, sample rate, channels, and bit depth
where available. Chiptunes show system, sound chips, static voice capabilities,
and known intro/loop lengths; VGM/VGZ also show version and declared chip clocks.
NSF/NSFE expansion hardware and SAP/KSS configurations come from file headers.
Voice capabilities describe hardware, not live activity. Copyright, dumper,
and comments appear when available. Opening the popup loads fresh details for
ordinary audio files and chiptunes without a rescan. Unknown fields are omitted.
For VGM/VGZ tracks, Now Playing opens on a full-screen VGM live page when
the current playback source is a VGM stream. Press O again for the
Tracker page, then O once more to close Now Playing. Tab switches
between Info and whichever live page was last open; Escape and Enter
close from any page. Other formats with one live page close on the second O.
The Tracker stays open when the next track also has one (VGM or HES).
The VGM overview covers YM2612/YM3438 FM, SN76489 PSG, and YM2413
OPLL chips when libgme renders them, and uses the whole terminal: one line per
chip (LFO, channel 3 mode, DAC stream rate, timers; PSG noise mode and stereo;
OPLL rhythm keys), every channel (operator keys, note and cents, frequency, an
estimated level bar, pan, instrument, state), a keyboard marking each sounding
channel, the selected channel's voice (YM2612 algorithm diagram and operator
table, OPLL instrument, PSG and DAC detail), estimated operator envelope
shapes, and a few seconds of per-channel note history. Up/Down selects a
channel; M mutes or unmutes its libgme voice and S solos it. libgme mixes
some channels as one voice (the PSG beside an FM chip, OPLL channels 6–9, OPLL
rhythm), so those mute together. Mute is heard once already-decoded audio
drains. Both VGM pages read VGM commands on one bounded background worker and
follow the sample position already consumed by the audio output, so decoder
read-ahead does not move the display early. Switching between them keeps that
replay. Opening either page deep into a dense track replays in bounded slices
and shows Catching up until it reaches the playhead,
including while paused. GME also preserves VGM leading silence so the audio and
register timelines begin together; this also turns off libgme's silence-based
end-of-track detection for VGM, which instead ends on its end command or
header length. Channel state comes from registers; levels and envelope shapes
are estimated from envelope rates and key timing, not measured. Unsupported
chips and partial DAC/stream cases are labeled in the page; for example,
libgme's YM2612 priority makes mixed YM2612/YM2413 files unavailable for OPLL
detail, and YM2151/T6W28 live detail is not provided. Switching to Info or
closing the popup suspends live replay.
The Tracker page scrolls a FastTracker-style time grid upward beneath the
playhead. Each row is 50 ms, with the track time and columns for the chip
channels. Note starts, pitch changes, and releases come from the existing
note history. A cell shows a start as C-4, a pitch change as >D-4, and
a release with a trailing ! or ===; a note that starts and glides in one
row shows both (C-4>D-4), and one that also ends there stays visible.
Left/Right moves between channel columns when they do not all fit. This is
a view of timed chip events: VGM and HES have no pattern rows or reliable
tempo, and the page does not infer historical instruments, volume, or effects
from the current registers. Mute and solo remain on the overview page.
For SPC (SNES) tracks, Now Playing opens on a full-screen S-DSP live page
that uses the whole terminal: a global row (main/echo volume, FLG, key-on and
end flags), all eight voices (sample, pitch, playback rate, pan, ADSR/GAIN,
envelope level bar, output level, echo/noise/pitch-modulation flags, envelope
phase), an echo/FIR panel with the filter's frequency response, the selected
voice's sample with a waveform and loop point, a map of the SPC's 64 KB RAM, and
the sample directory. Up/Down selects the voice whose sample is shown; 0–7
mute voices (numbered as in the table) and S solos the selected voice. Mute
is heard once already-decoded audio drains (up to about 0.85 s with default
buffers). The page shows the state of the audio being heard, refreshed about
20 times a second; finer snapshots would change the emulation itself. .spc
playback uses a native port of libgme's SPC player (ext/rp_spc), verified
sample-for-sample against libgme; config.backends[".spc"] = :gme switches
back to libgme playback (no live page), and the app does so automatically if
librp_spc.dylib is missing.
For GBS (Game Boy) tracks, Now Playing opens on a full-screen GBS live page
with the same layout as the VGM page: the APU line (power, master volume,
music-driver rate), all four channels (note and cents, frequency, level, pan,
instrument, state), a keyboard, the selected channel's detail (square duty
cycle and sweep, wave output level, noise LFSR width and clock, volume
envelope and length counter), its volume envelope drawn from the last trigger
(or, for the wave channel, the 32-sample wave RAM), and per-channel note
history. Unlike VGM, every value is read from the emulator, so levels and
envelopes are exact. M mutes or unmutes the selected channel and S solos
it. .gbs playback uses libgme's own GBS emulator, vendored with hooks that
record APU state whenever what is heard changes (ext/rp_gbs), verified
sample-for-sample against Homebrew libgme; config.backends[".gbs"] = :gme
switches back to libgme playback (no live page), and the app does so
automatically if librp_gbs.dylib is missing.
NSF and NSFe (NES and Famicom) tracks open on an NSF live page laid out
the same way, covering the 2A03 and every expansion chip libgme plays: Konami
VRC6 (two pulses and a sawtooth) and VRC7 (six FM channels, shown with the
YM2413 OPLL panels and VRC7 instrument names), the Famicom Disk System
(wavetable, envelope, and modulation table), Nintendo MMC5 (two pulses and
PCM), Namco 163 (up to eight wavetable channels, with how many are enabled),
and Sunsoft 5B. The 2A03 line names the region (NTSC, or PAL's 2A07 at 50
Hz), frame counter mode, and music-driver rate. Each channel shows its pitch,
level, and settings (duty, envelope, length counter, sweep, linear counter,
LFSR mode, DPCM sample and rate); pulse and noise channels draw their volume
envelope from the trigger, and wavetable channels their wave. A looping DPCM
sample gets the pitch its loop length makes (Sunsoft's DMC bass). Levels are
exact except VRC7's, which are estimated as on the VGM page. Channels mute
and solo by libgme voice. An NSF that sets expansion bits no chip defines
still plays and shows the chips libgme knows. .nsf and .nsfe play
through libgme's own NSF emulator, vendored with the same kind of hooks
(ext/rp_nsf) and verified sample-for-sample against Homebrew libgme on every
fixture, every chip included; config.backends[".nsf"] = :gme switches back,
and the app does so automatically if librp_nsf.dylib is missing.
HES (PC Engine / TurboGrafx-16) tracks open on an HES live page laid out
the same way, for the HuC6280 PSG's six voices (Wave 1-4, Multi 1-2): the chip
line (master balance, VBlank rate), each voice's mode (32-step 5-bit wave,
noise on Multi 1-2, or direct DAC), period and pitch for waves, volume,
balance, and libgme's output gain, the selected wave's 32 samples, and note
history. Noise and DAC voices show no note. Levels are libgme's gain, not the
sound, so they are labeled estimates; the PSG has no envelopes, and libgme
plays neither its LFO nor the CD-ROM ADPCM voice. Each voice mutes and solos
as its own libgme voice. A second O opens the same Tracker page as VGM
for the six voices; noise and direct-DAC starts show as NOI and DAC, by the
mode each note began in, since Multi voices switch modes mid-song.
.hes plays through libgme's own HES emulator
(ext/rp_hes), verified sample-for-sample against Homebrew libgme;
config.backends[".hes"] = :gme switches back, and the app does so
automatically if librp_hes.dylib is missing.
Artwork comes from embedded pictures in FFmpeg-backed files, falling back to
images in the track's folder (such as cover.jpg or folder.png). Archived
tracks look beside their entry inside the archive first (rip zips often pack
box art), then in the folder containing the archive. Loading and image
processing run in a background thread and notify the UI through EventBus.
mise exec -- bundle exec rake testAlbum-art rendering is also checked in real terminals (macOS, with Kitty and iTerm2 installed and Screen Recording allowed for the calling terminal):
mise exec -- bundle exec rake visual # capture matrix, checks aspect and gaps
script/art_snapshot kitty --size 160x50 --mode inset # one capture, JSON measurementsEach capture runs the real app against a throwaway library with a synthetic
magenta cover holding a cyan square; the square must stay square, and inset
art must sit evenly between the pane borders. Captures land in tmp/visual/.
Kitty windows open in the background; iTerm2 windows briefly take focus and
ignore anything typed meanwhile.
Visual walkthroughs (current implementation, with links to source):
- Component map — responsibilities, thread boundaries, and data flow.
- Playback sequence — selection, decoding, native audio, and UI updates.
- Library scan sequence — reconciliation, metadata extraction, and archive restoration.
rubyplayer runs in one Ruby process with UI, decoder, scan, and native audio threads, coordinated through a small set of thread-safe primitives. FFmpeg, SoX, and archive extraction use subprocesses:
- Main thread — runs the terminal UI loop (
UI::App#run): waits for keyboard input, events, or a timeout; drains events; and renders when something visible changes. Active playback uses the configured frame rate; idle rendering skips unchanged frames. - Scan orchestration thread — reconciles filesystem state against the database without opening music files, then waits for metadata extraction to finish before processing the next root. Created for each scan request.
- Extractor pool — a bounded pool of worker threads created for each extraction batch. Workers open new or changed files through the appropriate backend to pull out metadata (title, duration, subtune count, ...). Expensive libgme/libopenmpt calls use
blocking: truein the FFI binding, releasing Ruby's GVL so workers can run native work concurrently. FFmpeg-backed metadata uses ffprobe subprocesses. - Progressive scan display — workers publish completed/total progress through the event bus. The UI reloads panes at most every 250 ms during extraction, then performs a final refresh after folder counts are recomputed.
- Decoder thread — owned by
PlaybackEngine. Opens the play-queue head via a backend, decodes audio, and pumps PCM into the native ring buffer. Focus playback reads SoX-generated PCM through the same thread and audio output, preserving the finite queue. Idle or paused decoding blocks until a command arrives. - miniaudio's own callback thread — native (C), drains the ring buffer at the audio device's pace. It never touches Ruby.
- Artwork threads — load and process cover images off the UI thread, returning results through EventBus.
Threads talk to each other through:
EventBus— a self-pipe-backed queue. Background threads publish events (:track_started,:scan_complete, ...); the main loopselect()s on the bus's read end alongside stdin, so it can react to both keyboard input and background progress without polling.- Decoder command queue — a Ruby
Thread::Queuecarries play, pause, seek, and source-switch requests intoPlaybackEngine's decoder thread. It is separate fromPlayQueue, which holds ordered tracks. Some public engine methods also update the track queue synchronously under a mutex. - A lock-free SPSC ring buffer (in the C shim) — the only channel between the decoder thread and the audio callback. The decoder writes, the callback reads; no Ruby object crosses that boundary.
- Small mutex-guarded state inside
PlaybackEngineandDatabase(SQLite access is serialized through a single writer mutex; WAL mode allows concurrent readers).
Library data lives in a single SQLite database (~/.local/share/rubyplayer/library.sqlite3): folders, tracks, track_metadata, playback_history, playlists, and playlist_tracks. The playback queue remains in memory. A "folder" isn't necessarily a directory — a multi-subtune file (an NSF with 20 songs in it) is modeled as a virtual folder whose children are its individual tracks. This unifies "browse a directory" and "browse the subtunes of one file" into the same tree-walking code in LibraryPane/Library#children_of.
Scans and library-folder removal use soft deletion: rows get a missing flag, preserving identity and history so they can reappear on rescan. Explicitly purging Missing tracks permanently deletes those track rows and their history, metadata, and playlist entries. Deleting a playlist permanently removes the playlist and its entries while retaining the library tracks.
Archive reconciliation completes before extraction starts. Changed archives first mark old inner rows missing; extraction restores entries still present. Removed entries remain missing. See the scan sequence for this ordering and nested-archive identity.
The TUI is a custom double-buffered renderer (UI::Screen), not a curses wrapper: every frame writes into a back buffer of styled cells, and flush diffs it against what was last drawn to emit the minimal ANSI escape sequences needed to reconcile the two. Cells support foreground/background color, bold, italic, underline, and dim text. There's no z-order — whatever draws last into a cell wins, which is how modal dialogs paint over the panes underneath at the end of render.
| File | Purpose |
|---|---|
bin/rubyplayer |
Executable entry point. Boots UI::App with ARGV as initial scan paths; logs uncaught exceptions to ~/.local/share/rubyplayer/rubyplayer.log before re-raising (the TUI owns the screen, so crashes can't just print to stdout). |
| File | Purpose |
|---|---|
rubyplayer.rb |
Top-level module: version constant and the require_relative list that loads the rest of the library in dependency order. |
config.rb |
Built-in defaults and ConfigStore: installs examples/config.rb when needed, transactionally loads executable ~/.config/rubyplayer/config.rb, hot-reloads it, snapshots valid source to config-previous.rb, and restores that snapshot when primary source disappears. |
config_dsl.rb |
Validated configuration DSL and evaluator. Builds settings before activation, rejects unknown names with suggestions, and wraps syntax/runtime/validation failures as ConfigError. |
duration_formatter.rb |
Shared duration formatting helper used by track formatters, bottom lines, and app rendering. |
database.rb |
Database: owns the SQLite connection (WAL mode, foreign keys on), the six library tables listed above, and a mutex-guarded transactional write API with direct read access. Backs up the existing DB file on open and rebuilds from scratch if the schema version doesn't match. |
track.rb |
Track: a keyword-init Struct mirroring a tracks row, with Track.from_row to build one from a SQLite result hash. This is the value object passed around the whole playback pipeline (queue, engine, UI panes). |
library.rb |
Library: the query/mutation layer over Database — upserting folders/tracks during a scan, reading folders plus source/smart views (favorites, history, Recently Added, Unrated, Missing, Failed to Scan, Most Played), rating tracks, soft-deleting folder subtrees, user-defined playlists (CRUD, position-ordered entries with move/remove, duplicate), and the track_metadata KV sidecar (full tag sets stored at scan, read on demand). No SQL lives outside this file (and database.rb's schema). |
scanner.rb |
Scanner: phase 1 of a library scan. Walks a directory tree (or a single file), diffs what it finds against what the DB already knows (by mtime/size), and emits WorkItems for anything new or changed — without opening a single music file. Archives are stat-diffed as a single unit (one mtime check covers every entry inside). Also marks anything that's vanished from disk as missing. |
extractor_pool.rb |
ExtractorPool: phase 2 of a scan. A bounded thread pool (size configurable, default = CPU core count) that opens each WorkItem through the right backend to extract metadata, expands multi-subtune files into virtual folders + child tracks, and flags anything undecodable rather than losing it. Archives are unpacked via ArchiveCache and their entries (including nested archives and multi-subtune files) scanned recursively. |
archive_cache.rb |
ArchiveCache: extracts .zip/.7z/.rar containers into a content-addressed on-disk cache (keyed by path+mtime+size) so the FFI backends — which can only read real files — can decode entries stored inside archives. Uses bsdtar (libarchive, bundled with macOS), which reads all three formats and refuses path-traversal entry names. materialize resolves a track's (physical_path, archive_entry) pair to a real extracted file, chaining through nested archives. The cache directory is safe to delete at any time. |
play_queue.rb |
PlayQueue: the in-memory playback queue (Track objects, not DB rows). Head of the list is "currently playing" when the engine is playing. Supports insert-now/front/end, positional or by-id removal, and bounded undo/redo. |
playback_engine.rb |
PlaybackEngine: owns the decoder thread, the queue, and the AudioOutput device for the process's lifetime. UI threads call its public methods (commands in); it publishes events out via EventBus. Handles play/pause/skip/seek, disliked-track auto-skip, and per-track playback-history recording once a track has played past a configurable percentage. Its VGM request API captures consumed audio time and transport identity without disk access. |
vgm/reader.rb, vgm/cursor.rb, vgm/chips.rb, vgm/telemetry.rb |
Bounded plain/gzip VGM command reader, state-carrying loop/checkpoint replay, chip capability routing, and a lazy coalescing worker that publishes immutable snapshots. Chip adapters live under vgm/chips/. |
vgm/note_log.rb, vgm/envelope.rb |
Per-chip packed history of note starts, stops, and pitch changes recorded during replay; closed-form estimates of YM2612/YM2413 envelope levels from register rates and key timing. |
audio_output.rb |
AudioOutput: thin FFI wrapper around the native shim (RpAudio) — init/start/stop the device, write float32 stereo frames into its ring buffer, query playback position, pause. One instance per process (the C side holds module-level state). |
audio_format.rb |
AudioFormat: canonical little-endian float32 interleaved-stereo constants and frame sizes shared by audio producers and consumers. |
focus_sounds.rb |
FocusSound and FocusSounds: immutable catalog of infinite SoX noise recipes, separate from finite library tracks. |
focus_player.rb |
FocusPlayer: manages the SoX subprocess and reads complete PCM frames from its output pipe for PlaybackEngine. |
artwork.rb |
Artwork: resolves embedded and folder cover art, caches results, uses FFmpeg to produce bounded PNG display images, and derives accent colors. |
runtime_dependencies.rb |
RuntimeDependencies: checks external tools and libraries plus all four bundled native libraries before App loads; rebuilds missing or unloadable bundled libraries once and reports startup failures. |
event_bus.rb |
EventBus: thread-safe event queue with a self-pipe wakeup, so the main loop's IO.select can block on stdin and background events simultaneously instead of polling. |
level_tap.rb |
LevelTap: feeds the bottom-line EQ animation. Runs a Goertzel-algorithm frequency analysis at log-spaced band frequencies over the window of audio the device is playing: push (decoder thread) tags audio with its output-timeline frame as it enters the ring; levels (UI thread) reads the window ending at frames_played. |
playback_engine/chip_state.rb |
PlaybackEngine::ChipState: live chip state for handles that record native snapshots — pushes each chunk's snapshots into a ChipTimeline at the chunk's output frame; returns the SPC snapshot being heard (spc_live) or every GBS, NSF, or HES snapshot heard since the last call (chip_live), and which kind the track has (chip_live_kind). |
chip_timeline.rb |
ChipTimeline: packed native snapshots (any record class) indexed by output-timeline frame: the latest heard (at) or each heard record once, in order (take_through). |
playback_engine/voice_muting.rb |
PlaybackEngine::VoiceMuting: user voice muting for any handle with #mute (native SPC, libgme), queued for the decoder thread and tied to the track it targets. |
spc/snapshot.rb |
Spc::Snapshot (one decoded S-DSP state record) and Spc::LiveState. |
chip_tracker.rb |
ChipTracker: the lifecycle the GBS and NSF trackers share. It reads an engine live state's native snapshots in order, restarts history on a new generation, records note starts, pitch changes, and stops, and builds the live page's view snapshot. |
gbs/snapshot.rb, gbs/tracker.rb |
Gbs::Snapshot (one decoded Game Boy APU record) and Gbs::Tracker, which turns the snapshot stream into the VGM live page's channel model and note history. |
nsf/snapshot.rb, nsf/chips.rb, nsf/vrc7.rb, nsf/tracker.rb |
Nsf::Snapshot (one decoded 2A03-plus-expansion record) and Nsf::Voices (libgme's voice numbering per chip set); Nsf::Chips, which reads each chip's channels (pitch, level, settings) from a record; Nsf::Vrc7, the YM2413 model with VRC7's channel count and instrument names; and Nsf::Tracker, which builds the VGM live page's channel model and per-chip note history. |
hes/snapshot.rb, hes/tracker.rb |
Hes::Snapshot: one decoded HuC6280 PSG record (six voices' key, mode, period, gains, and 32-step waves); and Hes::Tracker, which builds the live page's channel model (wave, noise, or direct-DAC mode per voice) and note history. |
spc/dsp.rb, spc/brr.rb, spc/fir.rb, spc/ram_map.rb |
Pure decoders for the live page: DSP registers, sample directory and BRR samples, echo FIR frequency response, and SPC RAM layout. |
track_formatter.rb |
TrackFormatter: runs configured formatter lambdas against a track and helper context, then normalizes strings and conditional fragments into validated styled segments. |
keymap.rb |
Keymap: maps normalized key names to action symbols, merging Ruby-config overrides over sensible single-letter defaults. Matching is case-insensitive. Pane-local bindings shadow global bindings. |
theme.rb |
Theme: named color palettes for the TUI — a semantic role (border, selection_bg, text_muted, ...) mapped to either a named ANSI symbol or a "#rrggbb" truecolor string. Theme::DEFAULT reproduces the app's original hardcoded ANSI colors exactly; Theme::THEMES holds the selectable named palettes. Widgets receive the active theme per render call, not at construction time, so switching is instant. |
| File | Purpose |
|---|---|
registry.rb |
Backends::Registry: maps file extensions to GME, OpenMPT, FFmpeg, or a native chip player (NATIVE_BACKENDS, falling back to GME when its dylib is missing) and lazily instantiates/caches backend objects. Also classifies archive containers (.zip/.7z/.rar), which are "supported" but have no backend of their own — the ExtractorPool unpacks them and dispatches each entry to its real backend. |
metadata_helper.rb |
Shared backend metadata helpers for presence checks and normalized file extensions. |
gme.rb |
Backends::Gme: FFI bindings to libgme, plus Handle (an open, playable track) and metadata extraction for chiptune formats. Converts libgme's 16-bit PCM output to the app's canonical float32 format. |
openmpt.rb |
Backends::Openmpt: FFI bindings to libopenmpt, plus Handle and metadata extraction for tracker module formats. libopenmpt renders float natively, so no format conversion is needed. |
snes_spc.rb, snes_spc_lib.rb |
Backends::SnesSpc: plays .spc through librp_spc (handles also expose DSP snapshots, RAM, and voice muting); scanning and metadata stay on libgme. Requiring snes_spc_lib.rb loads the dylib and doubles as the availability check. |
native_gme.rb, gzip_content.rb |
Backends::NativeGme: the shared backend and handle for native libgme players (snapshots, voice muting, a sibling .m3u as libgme loads it); scanning and metadata stay on libgme. GzipContent inflates gzip content (with a size limit) as libgme's file loader does. |
gbs_native.rb, gbs_native_lib.rb, nsf_native.rb, nsf_native_lib.rb, hes_native.rb, hes_native_lib.rb |
Backends::GbsNative, NsfNative, and HesNative: .gbs through librp_gbs, .nsf/.nsfe through librp_nsf, .hes through librp_hes. Requiring a *_lib.rb binding doubles as the availability check; each attaches the shared player API through native_gme_lib.rb. |
ffmpeg.rb |
Backends::Ffmpeg: probes ordinary audio files with ffprobe and decodes through an FFmpeg subprocess to float32 stereo PCM; supports seeking by restarting decoding at the requested offset. |
| File | Purpose |
|---|---|
app.rb |
UI::App: wires every other component together and owns the main loop — key dispatch, event handling, config hot-reload, and the top-level render. This is the natural place to look first to see how a keypress becomes an action and how state changes reach the screen. |
spc_live_view.rb |
SpcLiveView: the full-modal SPC live page. Renders a Spc::LiveState into a rectangle, choosing three-column, two-column, or stacked panels by size; graphs are memoized per input. |
chip_live_view.rb, gb_live_panels.rb, nes_live_panels.rb, vgm_telemetry_controller.rb |
ChipLiveView: the full-modal VGM (and GBS, NSF, and HES, through the Game Boy, NES, and HES panel modules, registered in NATIVE_PANEL_FAMILIES) live page, rendering snapshots into a rectangle with three-column, two-column, or stacked panels by size; and the visible-consumer refresh controller. The view does not read files, query the engine, or depend on the Now Playing modal. |
chip_tracker_view.rb |
ChipTrackerView: the Tracker page (VGM and HES). Buckets the snapshot's packed note history into 50 ms rows of track time and renders a scrolling time grid with a horizontal channel window. Like ChipLiveView, it reads only snapshots. |
hes_live_panels.rb |
HesLivePanels: the HuC6280 PSG parts of ChipLiveView (chip line, mode-specific instrument text, estimated levels from libgme's gain, voice panel, wave RAM). |
gb_live_panels.rb |
GbLivePanels: the Game Boy APU parts of ChipLiveView (chip line, instrument text, exact levels, voice panel, volume envelope and wave RAM). |
live_glyphs.rb |
Block-character level bars and graphs shared by the live pages. |
chip_sample_panels.rb |
ChipSamplePanels: wave sparklines, wave graphs, and amplitude-to-attenuation levels shared by the Game Boy, NES, and HES panel modules. |
live_panel_renderer.rb |
Panel drawing shared by the live pages: equal-width columns of clipped lines and a one-entry cache per panel. Layout stays in each page. |
screen.rb |
Screen: the double-buffered renderer described above (put/clear_back/flush), plus truecolor and named ANSI color support. Has no knowledge of panes, tracks, or layout — purely a drawing primitive. |
library_pane.rb |
LibraryPane: left-hand source tree with queue/history/favorites/Focus, five smart views, and folder hierarchy. Owns folder breadcrumbs, selection, scrolling, and overflow indicator. |
list_row_renderer.rb |
Shared stateless track-row construction, styled segment drawing, clipping, theme-color resolution, and section-header text. |
playlist_modal.rb |
Snapshot-backed add-to-playlist picker. Owns filtering, selection, duplicate confirmation, and drawing; returns intents to App for database mutations. Playlist lists refresh when the picker opens, without queries during redraws. |
tracks_pane.rb |
TracksPane: interactive item list for every source. Owns filtering, cursor/scroll memory, grouping/sorting, and queue identity while delegating individual track-row drawing. Queue remains flat and ordered. |
queued_pane.rb |
QueuedPane: snapshot-backed upcoming/history overview with selectable upcoming tracks, queue actions routed through App, cumulative duration, and overflow allocation. History rows are informational. |
queue_selection.rb |
QueueSelection: shared identity-preserving selection and scrolling for the Playback Queue view and Queued pane. |
kitty_image.rb |
KittyImage: detects Kitty/current iTerm2 support and owns one uploaded Kitty-protocol image and placement. |
player_bar.rb |
PlayerBar: the Now Playing bar (title, rating, state, credits, file facts, and progress beside a full-height spectrum, or one line on short terminals) and PlayerBar::Details, its per-track facts built from stored metadata. |
bottom_lines.rb |
Two bottom renderers: StatusLine (transient feedback) and HotkeyLine (context-sensitive bindings). |
key_decoder.rb |
KeyDecoder: turns raw bytes read from a raw-mode terminal into the normalized key-name strings Keymap understands (arrow-key escape sequences, Enter, Tab, Ctrl-combinations, etc). |
| File | Purpose |
|---|---|
rp_audio.c |
A minimal playback shim over vendored miniaudio: owns a lock-free SPSC ring buffer of float32 interleaved stereo frames. The Ruby decoder thread produces PCM via rp_write, which retains the GVL for its bounded memory copy; miniaudio's own native callback consumes it and never touches Ruby. Compiled to lib/rubyplayer/native/librp_audio.dylib by mise exec -- bundle exec rake compile. |
miniaudio.h |
Vendored single-header miniaudio library — the actual cross-platform audio device backend. Not modified. |
| File | Purpose |
|---|---|
ext/snes_spc/ |
Vendored SPC700 + S-DSP core, output filter, and resampler from libgme 0.6.5 (LGPL 2.1; see ext/snes_spc/VENDOR.md for files, build flags, and the one read-only accessor patch). |
ext/rp_spc/rp_spc.{h,cpp} |
C API over that core: a faithful port of libgme's SPC playback control (resampling, gain, fade, silence handling) so output matches libgme exactly, plus S-DSP snapshots recorded once per emulator call (~50 ms). Compiled to lib/rubyplayer/native/librp_spc.dylib by mise exec -- bundle exec rake compile. test/snes_spc_parity_test.rb requires bit-exact output against libgme on every SPC fixture. |
| File | Purpose |
|---|---|
ext/gme/ |
Vendored libgme 0.6.5 GBS, NSF, and HES emulators (with every NSF expansion chip and emu2413 for VRC7) and the playback framework, effects buffer, and C API they play through (LGPL 2.1, emu2413 MIT; see ext/gme/VENDOR.md for files, build flags, and the accessor and hook patch). Each player compiles the framework plus only its own format. |
ext/rp_gme/rp_recorder.h |
What the players share: an emulator subclass that records snapshots from libgme's hooks and maps them from emulator clocks to output frames across leading-silence skipping, keeping those ahead of output so a seek shows the state heard at its target; and the C API plumbing built on it. |
ext/rp_gbs/rp_gbs.{h,cpp}, rp_gbs_hooks.h |
C API that builds the GBS emulator through libgme's own gme_new_emu path (so output is libgme's), plus APU snapshots taken through libgme's GME_APU_HOOK/GME_FRAME_HOOK macros at each trigger, length or envelope change, and driver call. Compiled with hidden symbols to lib/rubyplayer/native/librp_gbs.dylib. test/gbs_native_parity_test.rb requires bit-exact output against libgme. |
ext/rp_nsf/rp_nsf.{h,cpp}, rp_nsf_hooks.h |
The same for NSF and NSFe: 2A03 and expansion chip snapshots at each trigger, key on, length or envelope change, and driver call. The 2A03 and MMC5 frame counters are simulated on copies between hooks rather than advanced (libgme's triangle keeps phase differently across extra time splits while muted). Compiled to lib/rubyplayer/native/librp_nsf.dylib; test/nsf_native_parity_test.rb requires bit-exact output against libgme on every NSF fixture. |
ext/rp_hes/rp_hes.{h,cpp}, rp_hes_hooks.h |
The same for HES, built through libgme's gme_new_emu path. Compiled to lib/rubyplayer/native/librp_hes.dylib; PSG snapshots come from libgme's write and driver hooks alone (the PSG has no envelopes or length counters): one at each key or mode change, even within a driver call, plus the registers each driver call leaves, at most 240 a second. test/hes_native_parity_test.rb requires bit-exact output against libgme on every HES fixture, including playlists, seeks, fades, and muting. .hes playback uses it by default and falls back to libgme when the dylib is missing. |
| Path | Purpose |
|---|---|
test/ |
Minitest suite, one file per class above plus test_helper.rb (fixture path constant, requires); shared test support is split under test/support/ (AppTestSupport and AsyncWait). Run with mise exec -- bundle exec rake test. |
fixtures/ |
Real sample files in every supported format, used by the test suite and for manual verification. |
examples/config.rb |
Packaged executable-config starter. First run copies it to ~/.config/rubyplayer/config.rb; settings remain commented so future built-in default changes still apply. |
docs/superpowers/ |
Historical design specs and plans; see docs/README.md for current documentation guidance. |
docs/explainers/ |
Current Mermaid component and sequence diagrams, with implementation notes and source links. |
docs/ideas.md |
Original freeform brainstorm this project's design was distilled from. |
User configuration is executable Ruby at ~/.config/rubyplayer/config.rb.
On first run rubyplayer atomically copies packaged examples/config.rb there.
Starter includes common settings, maps, and formatter examples, but leaves them
commented so built-in defaults can evolve between releases.
Security:
config.rbis ordinary Ruby, not a sandbox. It runs with your user account's permissions. Only use configuration you wrote or reviewed.
Override settings through RubyPlayer.configure:
RubyPlayer.configure do |config|
config.ui.theme = "ocean_mist"
config.ui.library_pane_percent = 30
config.audio.sample_rate = "auto"
config.scanner.thread_count = 0
config.keymap.global["ctrl+p"] = :toggle_play
config.keymap.tracks["j"] = :nav_down
config.backends[".xyz"] = :ffmpeg
endNormal Ruby variables, methods, conditionals, loops, and multiple
RubyPlayer.configure blocks work. Blocks apply in source order. Unknown
settings fail with a nearest-name suggestion; invalid values identify their
setting path.
Rubyplayer checks config.rb once per second:
- Valid saves activate atomically and copy exact validated source to
~/.config/rubyplayer/config-previous.rb. - Missing primary config is restored from
config-previous.rb. If both user files are absent, packaged example is installed again. - Invalid hot reloads keep current in-memory config and open an exception modal. Fix and save again; successful reload closes it.
- Invalid startup config falls back to valid
config-previous.rband displays original error after UI opens. - Startup exits only when primary config fails and previous config is missing or also fails.
Press Escape or Enter to dismiss error modal without changing active config.
To intentionally reset config, remove both config.rb and
config-previous.rb, then restart. Rubyplayer installs fresh packaged example.
These user files live under ~/.config, outside repository, so repository
.gitignore entries are unnecessary.
Theme, artwork mode, and Queued-pane preference each use a separate clearly
marked managed block at the end of config.rb. Rubyplayer replaces only the
relevant block; user code and comments remain untouched.
Hot reload immediately applies pane widths, Queued preference, seek interval, theme, keymap, History limit, star glyph, track formatters, frame rate, idle poll interval, VGM telemetry rate, and the VGM byte limit. Settings captured when constructing audio, scanner, database, archive cache, playback engine, artwork resolver, or status-line objects require restart.
| Setting | Default | Meaning |
|---|---|---|
config.ui.library_pane_percent |
33 |
Library width, integer 1..99. |
config.ui.frame_fps |
30 |
Frame limit; positive integer. |
config.ui.vgm_telemetry_fps |
15 |
Maximum VGM live-page snapshot rate (levels still animate at the frame rate), integer 1..30, also capped by frame_fps. |
config.ui.vgm_telemetry_max_bytes |
67108864 |
Maximum decompressed VGM/VGZ bytes held for telemetry; positive integer. Above-limit files continue playing without live detail. |
config.ui.idle_poll_seconds |
0.25 |
Idle loop wake-up interval. |
config.ui.status_message_seconds |
5 |
Status duration; positive integer. |
config.ui.resize_settle_seconds |
0.5 |
Delay after resize before re-emitting art. |
config.ui.seek_seconds |
10 |
Seek step; positive integer. |
config.ui.format_track_grouped |
callable | Grouped-row formatter. |
config.ui.format_track_ungrouped |
callable | Flat-row formatter. |
config.ui.format_track_queued |
callable | Queued-pane track formatter. |
config.ui.queued_pane_width |
36 |
Queued pane width; positive integer columns. |
config.ui.theme |
"default" |
Id from Theme::ALL_IDS. |
config.ui.art_mode |
"off" |
Album-art placement: off, inset, pane, or corner. |
config.ui.player_bar_rows |
4 |
Inner rows of the Now Playing bar on terminals at least 30 rows tall; positive integer. |
config.ui.art_pane_width |
30 |
Retired (the Now Playing bar holds pane art); accepted so older configs load. |
config.ui.art_display_max_px |
480 |
Longest emitted album-art side in pixels. |
config.ui.art_corner_rows |
8 |
Album-art corner height in rows. |
config.ui.art_gap_cells |
1.0 |
Target visual gap between album art and the pane border, in cell widths; whole-cell placement gets each side as close as it can. |
config.ui.art_cell_aspect |
2.0 |
Cell height/width ratio for undistorted art when the terminal doesn't report pixel sizes. |
config.ui.art_min_rows |
3 |
Minimum usable album-art height in rows. |
config.ui.art_inset_max_rows |
0 |
Maximum inset album-art height in rows; 0 sizes the inset to fill the Library pane's width (the list keeps at least half the pane). |
config.ui.art_filenames |
Artwork::DEFAULT_NAMES |
Folder-art basenames tried before image fallback. |
config.ui.art_accent |
true |
Tint accent color toward current cover art. |
config.ui.playlist_recent_count |
3 |
Recent playlists shown in add-to-playlist modal. |
config.ui.info_metadata_rows |
8 |
Extra metadata rows shown in info modal. |
config.audio.sample_rate |
"auto" |
Device-native or positive integer Hz. |
config.audio.ring_buffer_ms |
500 |
Audio buffer; positive integer ms. |
config.audio.decode_chunk_frames |
16384 |
Decode chunk; positive integer frames. Larger bursts use less CPU for FM chiptunes. |
config.scanner.thread_count |
0 |
Extractor workers; 0 uses CPU count. |
config.library.backup_retention |
10 |
SQLite backups retained. |
config.library.history_limit |
100 |
Tracks shown in History. |
config.library.history_min_percent |
5 |
Played threshold, 0..100. |
config.library.history_min_seconds_unknown |
30 |
Threshold for unknown durations. |
config.library.undo_depth |
10 |
Queue undo/redo depth. |
config.library.archive_cache_dir |
~/.cache/rubyplayer/archives |
Extracted archive cache. |
config.library.archive_tool |
"bsdtar" |
Archive executable. |
config.library.metadata_value_limit |
8192 |
Maximum bytes per stored metadata value. |
config.eq.bands |
16 |
Analyzer band count. |
config.eq.fps |
30 |
Analyzer update rate. |
config.glyphs.dir |
folder glyph | Directory icon. |
config.glyphs.archive |
archive glyph | Archive icon. |
config.glyphs.playlist |
list glyph | Playlist icon. |
config.glyphs.multitrack |
chip glyph | Multi-subtune icon. |
config.glyphs.star |
"★" |
Rating character. |
config.glyphs.missing |
warning glyph | Missing-track icon. |
config.glyphs.errored |
circle-x glyph | Failed-scan icon. |
config.glyphs.play |
play glyph | Playing icon. |
config.glyphs.pause |
pause glyph | Paused icon. |
config.glyphs.eq_chars |
" ▁▂▃▄▅▆▇█" |
EQ levels, quiet to loud. |
config.glyphs.focus |
focus glyph | Focus view icon. |
config.keymap.global |
{} |
Global key/action overrides. |
config.keymap.library |
{} |
Library overrides; beat global bindings. |
config.keymap.tracks |
{} |
Tracks overrides; beat global bindings. |
config.backends |
{} |
Extension/backend overrides; dot optional. .spc, .gbs, .nsf/.nsfe, and .hes default to the native SPC, GBS, NSF, and HES players; for example ".nsf" => :gme restores libgme playback. |
Map settings use bracket assignment:
RubyPlayer.configure do |config|
config.keymap.library["j"] = :nav_down
config.keymap.library["k"] = :nav_up
config.backends["vgm"] = :gme
endFormatters receive (track, fmt) and return a string, one helper fragment, or
nested arrays of either. nil and empty fragments disappear, making normal Ruby
conditionals safe inside fmt.line.
Available track fields:
id, folder_id, physical_path, archive_entry, subtune_index, backend,
format, title, album, album_artist, artist, composer, track_number,
duration_ms, year, rating, missing, and errored.
Helpers:
fmt.text(value, **style)— text unless value is nil/empty.fmt.number(value, width: 2, **style)— zero-padded number.fmt.duration(milliseconds, **style)—M:SS.fmt.stars(rating, **style)— configured star repeated by rating.fmt.line(*parts, separator: " ")— flatten, omit empty parts, join survivors.fmt.album_artist— dominant grouped-album artist, otherwise nil.
Style keys: fg, bg, bold, italic, underline, and dim. Colors accept:
- Theme roles:
:text,:text_muted,:primary,:accent,:warning, etc. - ANSI names:
:red,:yellow,:blue,:bright_cyan, etc. - True color: six-digit strings such as
"#ffaa00".
Selected-row foreground/background override formatter colors for contrast. Bold, italic, underline, and dim remain active.
config.ui.format_track_ungrouped = ->(track, fmt) {
fmt.line(fmt.text(track.title, bold: true), fmt.text(track.artist, italic: true))
}config.ui.format_track_ungrouped = lambda do |track, fmt|
fmt.line(
fmt.text(track.album, fg: :accent),
fmt.number(track.track_number, fg: :text_muted),
fmt.text(track.title, fg: :primary, bold: true),
fmt.text(track.artist, fg: "#7dd3fc", italic: true),
fmt.stars(track.rating, fg: :yellow)
)
endconfig.ui.format_track_ungrouped = ->(track, fmt) {
fmt.line(
fmt.number(track.track_number),
fmt.text(track.title, bold: true),
fmt.duration(track.duration_ms, fg: :text_muted)
)
}config.ui.format_track_ungrouped = lambda do |track, fmt|
fmt.line(
fmt.text("[#{track.format&.upcase}]", fg: :text_muted, dim: true),
fmt.text(track.album, fg: :accent),
fmt.number(track.track_number),
fmt.text(track.title, bold: true),
fmt.text("—"),
fmt.text(track.artist, italic: true),
fmt.text(track.composer ? "(#{track.composer})" : nil, fg: :text_muted),
fmt.duration(track.duration_ms, fg: :text_muted),
fmt.stars(track.rating, fg: :yellow)
)
endconfig.ui.format_track_grouped = lambda do |track, fmt|
fmt.line(
fmt.number(track.track_number, fg: :text_muted),
fmt.text(track.title, bold: true),
fmt.duration(track.duration_ms, fg: :text_muted),
(fmt.text(track.artist, italic: true) unless track.artist == fmt.album_artist),
(fmt.text("missing", fg: :error, bold: true) if track.missing == 1),
(fmt.stars(track.rating, fg: :yellow) if track.rating)
)
end