Nova Musique
A local music library player for the desktop, built in Rust with an egui interface. Nova indexes a folder tree of audio files, reads their tags and cover art, and plays them through a small queue with shuffle and repeat — in the spirit of Apollo but for music you already own on disk.
Status: first pass. The playback core and the GUI are working end to end (scan → browse → play), on a deliberately small feature set. See Roadmap for what is intentionally not there yet.
Features
- Library scan — walks a folder tree, recognises common audio extensions, and reads title/artist/album/album-artist/genre/year/track/disc tags plus duration, sample rate and channel count.
- Cover art — embedded pictures are extracted (preferring "front cover") and decoded lazily into GPU textures.
- Track info panel — press I for a side panel spelling out the current track: its embedded image, its text fields, the stream details and the file path.
- Browsing — Songs, Albums and Artists views, plus a global search across title, artist and album.
- Queue — click any track to replace the queue with the current list and start there; shuffle (every item exactly once) and repeat-off/all/one.
- Playlists — Load and Save beside the Songs list: a loaded
.m3u8shows in place of the whole library, and saving writes back exactly what is on screen. The current playlist comes back at startup. See Playlists. - Converting to NFM — To NFM beside the Songs list turns the current
playlist (or the whole library) into
.nfmfiles with rate-controlled NFA5, audio-only and on a background thread. See Converting to NFM. - Transport — play/pause, previous/next, a seek bar, and volume.
- Resilient audio — playback goes through rust-
rodio; if no output device can be opened, Nova transparently falls back to a silent backend so the rest of the app (and the test suite) keeps working. - Background scanning — scanning runs on a worker thread and reports progress into the UI; the result is cached as JSON so the next launch is instant.
- Drag and drop — drop files to play them (indexed on the spot from their
tags) or a folder to scan it; files can also be passed on the command line as
a path or
file://URL. With a playlist loaded, drops (and the Folder row) add to that playlist instead — see Playlists. - No_FFT codecs —
.no_fft(polynomial fit),.nadc(NFA1–NFA6 DPCM / basket) and.nfm(a container wrapping any codec with metadata and a cover) are indexed and played in-process through the vendored upstream codecs, which decode through a sample sink so playback starts before the file is fully unpacked. See Codecs. - Video — video files are indexed alongside music and play in the main
window: the picture takes over the sidebar and the playlist, with the top
bar and transport bar still around it. libmpv decodes straight into an
OpenGL texture at the size it is displayed. Needs
libmpv(see Requirements) andffprobe/ffmpegfor indexing.
Repository layout
Nova is a Cargo workspace with five crates:
| Crate | What it is |
|---|---|
crates/nova_core357_11 |
The library and playback engine. UI-agnostic, fully unit-tested. |
crates/nova-codec |
unsafe FFI bindings to the vendored No_FFT C++ codecs. |
crates/nova-cli |
A tiny headless CLI used to verify scanning and playback. |
crates/nova-faav |
Process-wide get/set cells (static mut, no locks) — the search box's text, in the style of TAM's faav.rs. |
crates/nova-app |
The egui/eframe desktop application (binary: nova). |
no_fft/ holds a pristine copy of the upstream No_FFT codec sources.
crates/nova-codec/build.rs creates it if it is missing, writes
scripts/sync-nofft.sh and runs it, so every build refreshes it from GitHub:
main for --release builds, trunk otherwise (see Refreshing).
nova_core357_11
| Module | Responsibility |
|---|---|
model |
Track, Album, Artist, AlbumKey, and the stable track_id. |
metadata |
Tag/cover reading via lofty; the set of supported extensions. |
library |
Directory scanning, indexing, grouping, search and JSON caching. |
queue |
The playback queue: cursor, shuffle, repeat, insert/remove. |
playback |
PlaybackEngine and the AudioBackend abstraction. |
video |
Video extensions, ffprobe metadata, poster frames. |
error |
CoreError / Result. |
The key design point is that PlaybackEngine is driver-agnostic: it exposes
tick(&Library), which the host calls periodically. That is what the egui app's
frame loop calls, what the CLI's loop calls, and what the tests call.
Audio sits behind a small AudioBackend trait with two implementations:
RodioBackend— real output throughrodio.NullBackend— a fake clock, used for tests and as a fallback when no device exists. Tests useNullBackend::with_track_duration(..)to run queue logic without waiting on real time.
nova-app
The app's code lives in crates/nova-app/src/app/, one module per concern:
| Module | Responsibility |
|---|---|
mod |
NovaApp (all the state), new, and the per-frame ui() loop. |
config |
The JSON config file, XDG paths, and the global allow_video_movement. |
types |
View, Action and the messages passed between threads/processes. |
input |
Keyboard shortcuts, and applying the Actions collected in a frame. |
chrome |
The top bar and the bottom transport bar. |
browse |
Songs / Albums / Artists / Queue views, the sidebar, the track-info panel. |
library |
Scanning, indexing, the library cache and the hidden list. |
playlist |
.m3u8 reading/writing, the Load/Save menus, the playlist window. |
drops |
Drag and drop, command-line targets, "play these files". |
nfm |
Songs → To NFM: the conversion window and its worker threads. |
video |
In-window video, the stream picker's commands, and WindowMode (F). |
audio |
The Audio window. |
covers |
Cover-art texture cache. |
remote |
Commands read from stdin. |
text |
Clock / duration / format-label helpers. |
tests |
Unit tests. |
Modules share one NovaApp: each adds its own impl NovaApp block, and
everything is pub(super) — visible across the app module and nowhere else.
A single-window egui app: a top bar (folder + scan + search), a left sidebar
(library sections and now playing), a content pane (the current view), and a
bottom transport bar. UI intent is collected as Actions during the frame and
applied afterwards, which keeps borrows simple and the state changes easy to
follow. mpv is the hand-written libmpv binding behind in-window video
playback.
Requirements
- Rust 1.92+. The GUI stack pins this:
eframe/egui0.35 require rustc 1.92 (0.36 already requires 1.95). - A C++ compiler (g++/clang++), for the vendored No_FFT codecs. The codec
support is the
nofftfeature, which the app and CLI enable by default;nova_core357_11alone still builds without a C++ toolchain. - libmpv (
libmpv.soplus its headers) — required to build the GUI, since the bindings are linked in.pkg-config --exists mpvis the check. Without it the app still builds audio-only if you dropcrates/nova-app/src/mpv.rs. - ffmpeg's
ffprobeandffmpeg— used to read video metadata and to grab poster frames. Optional: video indexing degrades, nothing else breaks. - Linux: ALSA for audio, plus the usual X11/Wayland + OpenGL bits that
eframeneeds. The folder picker uses the XDG desktop portal; if no portal is available you can simply paste a path into the folder field and press Scan (or Enter). The field takes either a folder to index or a single media file to add.
Build & run
# Desktop app (binary is named `nova`)
# Point it at a folder, or straight at a file to start playing it
# Headless CLI
# Everything
Passing an argument to the GUI is optional:
- a file (plain path or
file://URL) → Nova indexes its folder and starts playing that track right away, queueing the rest of its album; - a folder → scanned and opened, nothing auto-plays.
Without an argument, Nova reopens the last folder (instantly, from cache).
CLI usage
nova-cli scan <dir>
nova-cli search <dir> <query>
nova-cli play <dir> [--index N] [--volume V] [--null] [--seconds S]
| Option | Meaning |
|---|---|
--index N |
First track to play (default 0). |
--volume V |
Output volume 0.0..=1.5 (default 0.8). |
--null |
Use the silent backend; each track lasts 3 seconds. |
--seconds S |
Stop after S seconds of wall-clock time (default 30). |
Using the app
- Enter a music folder (or use Browse…) and press Scan — or just press Enter in the field. Point it at a folder and the tree is indexed; point it at a single file and only that file is added to the library, without walking its neighbours. Progress shows in the top bar; a folder scan is cached.
- Pick a view in the sidebar: Songs, Albums, Artists or Queue.
- Click a track to play it. Right-click a track for Play now / Play next, or Remove from queue in the Queue view.
- Use ↑/↓ to move the cursor through any track list and Enter to play it. The playing track is marked ▶ in the first column, so the cursor and now-playing never look the same.
- Press Enter in the search box for a message window with how many tracks match; Enter or OK dismisses it.
- Use the transport bar for play/pause, previous/next, seek, volume, shuffle and repeat.
- Press I (or the ℹ Info button) to open the track info panel.
- Click Nova in the top bar to open its menu; Playlist opens a floating playlist window and Video opens the video-stream picker. Audio opens the Audio window with SMPlayer's audio controls.
Clear all, Save, Load, To NFM and Clear cache sit beside the
Songs title and keep working with a search typed in; Clear all is the
playlist button — it clears the loaded playlist (see
Playlists) — while Save and Load manage playlist files.
To NFM turns the current playlist into .nfm files — see
Converting to NFM. Clear cache drops the cached
library index and the hidden list, then re-indexes the folder — see
Hiding songs.
The Nova menu
The Nova label at the top-left of the window is also the menu button. It opens a small dropdown whose items are Playlist, Video and Audio.
Playlist opens a floating playlist window over the app. Its title bar carries the three window controls:
- 🗕 Minimise — collapses the window to a slim bar at the top; nothing is lost, click again to bring it back.
- 🗖 Maximise — makes the window fill the app. Click again to restore.
- ✕ Close — puts the window away.
The window's position and size are pinned, so these states are exact rather than approximate.
Video opens the video-stream picker in its own OS window — the same thing
SMPlayer calls Video → Track. A container carrying more than one video stream
(multi-angle discs, dual-cam footage) lists them all, the one being shown is
marked, and clicking another switches to it. The picture itself stays in the main
window; the picker only chooses. It reads <empty> when no video stream is
loaded.
That window runs as a separate process (nova --video-window), not a second
window inside the app. This is deliberate: a second native window inside one
process gets a GL context of its own, and libmpv's render context is bound to the
main window's context for good, so the two together segfault. Behind a process
boundary the picker's context belongs to a different process and never touches
mpv. The two ends talk in plain lines over the picker's stdin and stdout — the
parent sends the streams it read from mpv, the picker sends back the one picked —
and the picker exits by itself if the main window goes away.
The main window reads the same language from its own stdin, one command per line, which is how a script, a test or anything else that can write to a pipe drives the app without a pointer:
| Command | What it does |
|---|---|
toggle |
Pause or resume |
next / prev |
Skip to the next or previous track |
seek <seconds> |
Jump there in the current track |
volume <0.0-1.0> |
Set the volume — the same numbers the slider uses |
play <path> |
Start that file, as the command line would |
picker <0|1> |
Open or close the video-stream picker window |
playlist <0|1> |
Open or close the playlist window |
pick <id> |
Show another video stream, from the list the picker shows |
flip <x> <y> |
Mirror left-right, flip top-bottom |
zoom <n> / pan <x> <y> / move <0|1> |
Picture size and position — what the picker's buttons do |
deint <n> |
Which deinterlacer is on the filter chain |
mode <name> |
SMPlayer's Stereo mode: stereo, left, right, mono, reverse |
Lines that do not parse are ignored here just as they are at the picker's end,
and play starts only files the library already knows: anything else gets a
toast saying so while playback carries on. A terminal's stdin is deliberately
left alone: run from a shell, nova keeps its own keystrokes; run from a
launcher, stdin is empty and nothing happens.
Starting nova with stdin on a named pipe leaves that channel open for as long as it runs. A writer has to stay on the other end — a pipe reports end-of-file when its last writer closes, so without one only the first command ever arrives:
& # a writer that never writes
&
and then from any terminal, at any time, while it plays or sits paused:
{ ; ; ; }
deint <n> counts the Deinterlace box top-down: 0 None, 1 Lowpass5,
2 Yadif (normal), 3 Yadif (double framerate), 4 Linear Blend, 5 Kerndeint.
sleep infinity is only one way to hold that write end. The rule it satisfies
is that a pipe reports end-of-file when its last writer closes: nova reads
stdin in a thread, and that reading loop stops at EOF. The failure is silent —
the window keeps running and the pipe still accepts a write, so a later
echo "toggle" > /tmp/nova.cmd succeeds and the line simply sits in the buffer
with nobody left to read it. Exactly one writer has to outlive every command,
and how to get one depends on who is driving.
A shell left open, for a human poking at it by hand:
exec without a command edits the shell's own fd table, so fd 3 outlives each
echo instead of opening and closing per command. Opening a named pipe for
writing blocks until a reader exists, so nova has to be running with stdin on
that pipe first — otherwise the shell hangs right there in the exec. Leaving
the shell closes the end.
A coproc, for one script that both starts the app and steers it (bash or
ksh, not POSIX sh):
coproc NOVA
The shell holds both ends of the pair it made, so nothing reaches EOF while
the script lives: no named pipe, no holder process. "${NOVA[0]}" is the read
end, if nova ever has something to say back, and script and app go down
together.
A socket, for senders that are not descendants of the launcher — another program, a systemd unit, another machine:
& # bridge onto the pipe
|
A named pipe is local and unidirectional: only whoever can open that path can
write, and only while the holder is alive. Replacing UNIX-LISTEN with
TCP-LISTEN:9999 puts it on the network, which also means anyone who can
reach that port controls playback — keep it on localhost or a trusted
interface.
Audio opens the Audio window: SMPlayer's audio controls (device, channels, delay, stereo mode, filters, external audio). The stereo modes apply to both mpv (video) and rodio (music) playback.
Video
Video files are indexed with the music (title/artist/album, duration and
resolution from ffprobe, a poster frame grabbed on demand for the cover art)
and are marked V in track lists. Playing one hands the main window over to the
picture: the sidebar and the playlist step aside, and the video fills the space
between the top bar and the transport bar, scaled to its own aspect ratio and
centred. Press Esc to stop and get the library back, and F
to change how the window shows it — see Video window modes.
A file with more
than one video stream can switch between them from the Nova menu's Video
item, which opens the stream picker in its own process.
That window also carries SMPlayer's Mirror image and Flip image
toggles. SMPlayer implements both by asking mpv for the hflip and vflip
lavfi filters (MPVProcess::lavfi); mpv has no property for either, but its
vf add command puts them in the pipeline in the same place, so that is what
Nova asks for.
The Deinterlace box between them and Zoom is SMPlayer's
Video → Deinterlace submenu: None, Lowpass5, Yadif (normal),
Yadif (double framerate), Linear Blend, Kerndeint, in its order.
SMPlayer changes it live rather than restarting playback
(Core::changeDeinterlace): what is already on the chain comes off before the
new filter goes on, and a removal is matched against the whole parameterised
string. Its mapping is followed too — l5 and lb are postprocessing filters
mpv no longer carries and refuses by name, so both arrive as yadif=mode=1,
exactly what MPVProcess::lavfi sends. The choice lasts for the session;
SMPlayer stores current_deinterlacer in its settings and Nova does not.
Zoom comes from the same submenu: Zoom −, Reset and Zoom +, stepping
by 5% with a floor of 50%, and the percentage shown beside them. SMPlayer
implements this by resizing its video widget and re-centring it — no mpv command
involved — so Nova scales the picture it draws and lets the panel clip it.
Nova adds a 4× ceiling that SMPlayer has no need for: the picture is drawn
into a texture that size.
Left / Right / Up / Down / Centre are SMPlayer's Move left,
Move right, Move up, Move down from the same submenu. It nudges its video
widget 16 pixels at a time and refuses to push it out of sight;
MplayerWindow::moveLeft tests the edge before it moves. Nova draws the picture
that much further off centre and clamps it to the panel, so panning does nothing
until you have zoomed in — the same as SMPlayer's default, where its
allow_video_movement preference is off. Dragging the picture does the same
thing by hand: it is only offered once there is somewhere left to pan to, and the
picker is told about it so its buttons keep working from wherever you left it.
The checkbox Allow moving the video in the picker is SMPlayer's
allow_video_movement itself, on by default here rather than off. With it off,
the picture can only be moved while it reaches past the panel, exactly as
SMPlayer gates it; with it on, it can be pushed around freely but never leaves
the panel.
It is kept as a process-wide variable seeded from config.json rather than a
field on the app, so it outlives the window: unchecking it, quitting, and
reopening leaves it unchecked.
There is no decoder in Nova. crates/nova-app/src/mpv.rs is a small hand-written
libmpv binding: libmpv decodes, renders into an OpenGL texture that egui draws as
an ordinary image, and drives the clock, so audio and video stay in step. Audio
files keep going through rodio — VideoBackend implements AudioBackend and
picks whichever of the two can play the track, so PlaybackEngine is unchanged.
The texture is allocated at the size the video is actually shown at, so mpv decodes straight to that resolution; resizing the window reallocates it rather than scaling a stale frame. Only the width is snapped to an allocation grid, and the height is derived from it, so the aspect ratio survives.
Two things are worth knowing if you touch that file:
- The UI thread never calls into mpv. While a video is loaded the core waits on the render API, which only runs on the UI thread, so a synchronous property read from there deadlocks. A background thread polls the properties into a shared snapshot, and commands are issued asynchronously.
advanced-controlis deliberately off. With it on, mpv holds the core until the client reports each swap, which aborts the process on hosts without real-time priority. Letting mpv keep its own timing costs a little A/V precision and works everywhere.
Track info
The info panel is the place where a file's embedded data is spelled out. It describes whatever is playing, or the last track you started:
- the embedded image — the cover from an NFM container or a tagged picture frame, with a placeholder note when a file has none;
- the text fields — title, artist and album up top, then album artist, genre, year and track/disc numbering where the file carries them;
- the stream details — duration, sample rate, channel count and container name. For an NFM these come from the inner payload that gets decoded, so the panel reports the audio you actually hear;
- the file path, so you can see exactly which file is loaded.
Fields a file does not carry are left out rather than shown blank, and the
grouping fallbacks (Unknown Artist / Unknown Album) are used where a file
has no tags at all.
Drag and drop
- Drop audio files onto the window to queue and play them immediately. Files the library hasn't indexed yet are read straight from their tags, so no rescan is needed.
- Drop a folder to scan and open it.
- With a playlist loaded, drops of either kind are added to that playlist instead of playing or scanning — see Playlists.
- While dragging, an overlay shows how many items are in flight.
The queue
- Click a row to jump to it; right-click for Play now / Play next.
- Remove from queue is on the row's right-click menu, or press Del in the Queue view to drop the highlighted track. Playback stops when the track you were listening to is the one removed, so audio never outlives the queue.
- Clear all empties the queue and stops playback.
Playlists
The Songs title carries three buttons, Clear all, Save and Load,
for plain .m3u8 files (with .m3u accepted on load). All three menus are
sized to their longest entry, so file names never wrap.
Clear all is only visible while a playlist is loaded; it clears the active
playlist — its tracks leave the library too, and the playlist file on disk is
rewritten with the plain .m3u8 header, so it stays a valid (empty) playlist.
The playlist stays the current one, with nothing in it.
Load offers:
- the playlists recently opened or saved, newest first (up to ten; hovering one shows its full path);
- all songs, to unload the playlist and get the whole library back;
- new…, a file picker for any other
.m3u8/.m3uon disk.
A loaded playlist shows in place of the whole library — only its tracks, in its own order, with the file name beside the Songs title. The search box still narrows within it. Files the library has never indexed are read from their tags on the spot and joined, so a playlist of files from another disk simply works; files that cannot be read at all are counted in the load toast.
Save has two entries:
- as is — writes the playlist on screen to the current file, which is the
one Load last opened (or the default
~/.config/nova/playlist.m3u8for the first save); - as… — a file picker to write a new file.
What is on screen is what gets written, search filter included — a filtered save writes the filtered list. The playlist keeps its own order.
Both the list of used playlists and the current one live in config.json, so
the Load menu remembers and the current playlist is reloaded at startup,
exactly as it was left.
Adding to the loaded playlist does not need the Save round-trip first:
- drop files or a folder onto the window — they are appended (folders are walked for audio, junk is counted and reported); drop targets the playlist only while one is loaded, otherwise drops play files and scan folders as usual;
- the Folder row — with a playlist loaded its button reads Add instead of Scan, and Enter / Browse… add that folder's files the same way.
Duplicates of tracks already in the playlist are skipped, tracks the library has not seen get their tags read on the spot, and a toast reports what was added. The file on disk is only written by Save — adding is in-memory until then.
Converting to NFM
To NFM, beside Load, turns audio into .nfm containers (see
Codecs for what an .nfm is). It opens a small window:
- Save to — the folder the
.nfmfiles are written to. It is a plain text field, so type or paste a path; it is remembered in the config aspath_to_save_converted_files. - NFA5 — the codec written inside the container.
- -set-rate — the target bits per sample for the rate-controlled NFA5 encoder. Quiet material stays fine and cheap, loud material coarsens to hold the budget, so the number is a quality/size dial rather than a fixed quantum.
- threads — how many groups to split the playlist into, one thread each. Defaults to one per CPU core; a lower number leaves the machine some room.
- Go — starts the conversion on background threads, so the window stays
live. While it runs it shows
Converting n/total…with the elapsed time and the files being converted now; a toast reports the result, the failure count and the duration when it finishes. - Cancel — asks the run to stop. The threads stand down at the next file boundary, so a file already being converted is still written; the toast says how many failed.
The files are split into as many contiguous groups as the threads field, and
the groups convert in parallel (never more groups than files). It converts the
current playlist when one is loaded, otherwise the whole library. Each source
file is decoded in-process, re-encoded to NFA5 and wrapped with its tags and
embedded cover (PNG or JPEG). Codec sources (.nadc / .no_fft / .nfm) are
already No_FFT, so converting those is not the point — this is for the ordinary
MP3/FLAC/etc. in the library.
Hiding songs
Del in the Songs view hides the highlighted track instead: it
disappears from the library without touching the file. Hidden ids are kept in
~/.cache/nova/hidden.json (a plain JSON array of track ids), so songs stay
hidden across restarts and rescans.
To bring songs back, empty or delete that file and then press Scan. The rescan matters: hiding also drops the track from the cached library, so without one you would be reading the cache that no longer has it.
Songs → Clear cache does both in one go: it deletes hidden.json and the
library cache, then re-indexes the folder — so the library is back to what is
on disk and nothing stays hidden.
Keyboard
| Shortcut | Action |
|---|---|
| Space | Play / pause |
| Ctrl+← | Previous track |
| Ctrl+→ | Next track |
| I | Toggle the track info panel |
| ↑ / ↓ | Move the cursor through a list |
| Enter | Play the track under the cursor |
| Del | Queue view: remove from queue · Songs view: hide from library |
| F | While a video is on screen: normal window → full screen → maximised window → normal window |
| Esc | Leave full screen; otherwise stop and return to the library while a video is on screen |
Video window modes
F steps through three states while a video is showing:
- Normal window — the window as you sized it, with the top bar, transport bar and side panels.
- Full screen — the picture fills the whole display on black. The top bar, transport bar, sidebar and info panel are hidden; Space, Ctrl+←/→ and F still work.
- Maximised window — the window fills the desktop but keeps its title bar and all the bars.
Esc leaves full screen straight back to the normal window instead of stopping the video. If a video ends while the window is still full screen, the library comes back inside it and F or Esc leaves full screen. F is ignored while a text field has focus, and with Ctrl or Alt held.
Codecs
Nova plays the No_FFT family natively, alongside the usual MP3/FLAC/etc.:
| Extension | Family | Magic |
|---|---|---|
.no_fft |
Variable-degree polynomial fit | NOFF |
.nadc |
DPCM / basket (NFA1–NFA4) | NFA1..NFA4 |
.nadc |
Multiplicative log-domain (NFA5) | NFA5 |
.nadc |
Blocked NFA5 (NFA6) | NFA6 |
.nfm |
NFM container (wraps any codec + metadata + cover) | nfm |
Files are detected by their magic bytes, not the extension, so a renamed
file still decodes. During a scan the stream properties (sample rate, channels,
duration) come straight from the codec header — no full decode — and playback
decodes to PCM in-process and hands it to rodio. An .nfm container is
unwrapped first: its text metadata (title/artist/album) and optional cover image
are read for indexing, and the inner codec payload is decoded for playback
(via a short-lived temp file). There are no external tools.
Streaming playback
Codec files are decoded through the upstream sample sink API, so the decoder hands samples to the player as it produces them instead of unpacking the whole clip first:
- the first audio starts after the header is read, not after the full decode — a 60 s file goes from ~700 ms of silence to ~17 ms;
- memory stays flat: only a few chunks are in flight, not the whole clip;
- the seek bar still works: a seek restarts the decode and drops the samples before the target, because the sink decodes forwards only.
An .nfm is unwrapped to its inner payload first, which needs a temp file; the
file is removed when playback moves on, and any left behind by a killed run is
cleaned up at the next start.
Both NFM container revisions are read: the current layout (mandatory image
block before the text fields) and the older v1 layout (text fields, then an
optional image block) — including header-only v1 files with header_len == 0.
Layout
| Path | Role |
|---|---|
no_fft/ |
Pristine, unmodified copy of the upstream sources (main or trunk, see below). Created by build.rs if missing. |
crates/nova-codec/ |
The C ABI shim (shim/nova_nofft_shim.cpp), build.rs, and safe Rust wrappers. |
scripts/sync-nofft.sh |
Re-vendors the sources from upstream. Generated by build.rs; run by it on every build. |
The C++ sources are compiled into a static library by nova-codec's
build.rs (via the cc crate) and linked into
whatever binary uses the nofft feature. crates/nova_core357_11 exposes that as the
nofft feature; nova-app and nova-cli turn it on by default.
Refreshing
nova-codec's build.rs keeps no_fft/ current by itself. Each time it runs
it:
- creates
no_fft/if it does not exist, - writes
scripts/sync-nofft.sh(the script is embedded inbuild.rs; edit it there, not inscripts/), and - runs it, which fetches the upstream branch that matches the Cargo profile:
| Build | Upstream branch |
|---|---|
cargo build, cargo run, cargo test (dev profile) |
trunk |
cargo build --release |
main |
Only files whose content changed are rewritten, so an up-to-date checkout never
triggers a rebuild, and no_fft/UPSTREAM.md always records the exact
branch/commit that is vendored. Because the profiles share one no_fft/
directory, alternating between a dev and a release build re-syncs it each time.
If there is no network (or no git/bash), the sync is skipped with a build
warning and the copy already in no_fft/ is used; the build only fails if those
sources are missing altogether.
| Variable | Effect |
|---|---|
NOVA_NOFFT_OFFLINE=1 |
Skip the sync; build what is in no_fft/. |
NOVA_NOFFT_BRANCH=<name> |
Use this branch whatever the profile. |
NOFFT_REPO_URL=<url> |
Fetch from another repository (an alternative fork). |
NOVA_NOFFT_DIR=<dir> |
Build against that checkout instead; no sync at all. |
The script also works by hand:
(NOFFT_MODE, NOFFT_BRANCH and NOFFT_REPO_URL are accepted as environment
overrides too.)
To build against a live checkout instead of the vendored copy, point
NOVA_NOFFT_DIR at it:
NOVA_SYNC_NOFFT=1 NOFFT_MODE=dev NOVA_SYNC_NOFFT=1
You can also override the upstream repository or branch using NOFFT_REPO_URL and NOFFT_BRANCH.
//! Options:
//! NOVA_NOFFT_DIR build against a different checkout instead of no_fft
//! NOVA_SYNC_NOFFT set to 1/true to refresh upstream sources manually
//! NOFFT_MODE release (default, branch main) or dev (branch trunk)
//! NOFFT_BRANCH explicit upstream branch override
//! NOFFT_REPO_URL upstream repository URL override
Feature gates
Files Nova writes
| Path | Contents |
|---|---|
~/.config/nova/config.json |
Last music folder, volume, the used-playlist list, the current playlist and the NFM output folder. |
~/.config/nova/playlist.m3u8 |
Where Save → as is writes before as… has picked a file. |
~/.cache/nova/library.json |
Cached library index for the last folder. |
~/.cache/nova/hidden.json |
Track ids hidden from the library. |
All of these honour XDG_CONFIG_HOME / XDG_CACHE_HOME. The cache is only
used when it was built from the currently configured folder.
While it runs, Nova also mirrors a few states into /dev/shm/nova/*.out as
tiny text files — playlist (the loaded file and its track count),
menu_state (load / save while one of those menus is open),
now_playing (playing / paused / stopped and the track), queue,
library (the track count), search and toast. They are only rewritten when
a value changes, and exist
so a script can watch what the app is doing without screenshots:
Testing
The test suite needs no audio hardware and no external tools:
- Unit tests in
nova_core357_11cover the model, tag parsing, grouping/sorting, search, queue semantics (shuffle/repeat/insert) and engine advance/pause/seek. crates/nova-codectests round-trip a generated signal through the polynomial codec and all six NFA variants, and round-trip NFM containers (with and without metadata/cover), so no binary fixtures are needed. The streaming decoder is checked against the buffered one: same samples, same order.crates/nova_core357_11/tests/library_integration.rssynthesises WAV files into a temporary directory and exercises the real scan → cache → play loop.
Roadmap
Deliberately out of scope for this first pass — the natural next steps:
- Playlist editing beyond append/save (reordering, removing single tracks).
- MPRIS integration so desktop media keys and shell widgets work.
- Gapless/crossfade, ReplayGain, an equaliser.
- File-watching so the library updates as folders change.
- Virtualised lists for very large libraries (views currently rebuild their row list each frame).
- Richer now-playing (waveform/visualiser, lyrics).
- Drag-and-drop reordering of the queue.