No description
  • Rust 48.4%
  • JavaScript 20.9%
  • TypeScript 16.5%
  • Swift 10.9%
  • CSS 1.9%
  • Other 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-23 13:02:24 +02:00
.vscode Build fast M4A library indexer 2026-09-02 16:32:01 +02:00
benchmarks/results Render library scrolling from packed snapshots 2026-09-06 12:42:43 +02:00
docs bugfix: stop command on linux did not clear the timer 2026-09-23 12:21:59 +02:00
plugins/tauri-plugin-nmrav-player bugfix: stop command on linux did not clear the timer 2026-09-23 12:21:59 +02:00
public Start from a clean application schema 2026-09-10 18:57:37 +02:00
scripts Fix playback races and tighten typed boundaries 2026-09-15 12:05:14 +02:00
src Fix playback races and tighten typed boundaries 2026-09-15 12:05:14 +02:00
src-tauri upgrade packages 2026-09-23 13:02:24 +02:00
tests Fix scrollbar fixtures and row reuse browser tests 2026-09-15 13:04:04 +02:00
.gitignore Ignore generated Apple build state 2026-09-03 20:24:09 +02:00
.prettierrc.json Unify domain types and enforce code style 2026-09-06 21:14:38 +02:00
.swiftformat Unify domain types and enforce code style 2026-09-06 21:14:38 +02:00
AGENTS.md Fix Linux playback wrapping 2026-09-03 15:34:52 +02:00
BENCHMARKS.md Add playback actions and native level feedback 2026-09-05 20:59:14 +02:00
index.html Add artist and album sorting 2026-09-10 16:06:39 +02:00
missing-cover.png Build fast M4A library indexer 2026-09-02 16:32:01 +02:00
missing-cover.svg Add native playback and compact library UI 2026-09-03 09:01:23 +02:00
package.json upgrade packages 2026-09-23 13:02:24 +02:00
pnpm-lock.yaml upgrade packages 2026-09-23 13:02:24 +02:00
pnpm-workspace.yaml Build fast M4A library indexer 2026-09-02 16:32:01 +02:00
README.md bugfix: stop command on linux did not clear the timer 2026-09-23 12:21:59 +02:00
TODO.md Fix playback races and tighten typed boundaries 2026-09-15 12:05:14 +02:00
tsconfig.json Migrate UI to Preact state transitions 2026-09-09 19:22:53 +02:00
vite.config.ts Unify domain types and enforce code style 2026-09-06 21:14:38 +02:00

nmrav

A small Tauri 2 music-library indexer. The first pass deliberately supports only M4A/MP4 metadata and focuses on bounded work, minimal reads, and a small UI.

What is implemented

  • Recursive, case-insensitive .m4a discovery.
  • A focused ISO-BMFF parser for artist, album artist, album, title, release date, disc/track number, duration, and MusicBrainz Track Id.
  • No heap allocations in the parser hot path. Tag values have fixed inline bounds; media payloads, cover art, and unknown atoms are skipped by size.
  • A small ReadAt effect boundary backed by the winning fixed 4 KiB page reader.
  • A bounded 128-file pipeline with eight desktop I/O workers and four mobile workers.
  • A transactional SQLite catalog with FTS5 prefix search.
  • Release-date and date-added album-group sorting in either direction; disc and track order always remain ascending within each album.
  • Absolute times are UTC Unix milliseconds. Release tags retain year/month/day precision as a small integer; raw date strings are never persisted.
  • A virtualized HTML list backed by a resident packed struct of arrays. Search and sorting transfer only row indexes; scrolling performs no IPC. Only visible and overscan rows and their cover images are rendered.
  • Compact, tappable track cards modeled on the earlier Swift app.
  • Case-insensitive folder/cover JPEG or PNG discovery that selects the highest-resolution image, with lazy native thumbnails.
  • One shared 2.28 KiB SVG placeholder asset; it is cached once rather than copied into every virtualized track card.
  • A native desktop folder picker and import progress.
  • A persisted Rust playback queue with deterministic shuffle, fixed native lookahead, seek IDs, and stale native-event rejection.
  • Monotonic native playback checkpoints for elapsed listening time; UTC epoch milliseconds are used only for absolute persisted event dates.
  • An iOS AVQueuePlayer Tauri plugin scaffold with a direct Swift-to-Rust event boundary that does not depend on the WebView being awake.
  • Linux playback through GStreamer playbin3, with MPRIS metadata and desktop play/pause, next, previous, and seek controls routed through the same Rust state machine.

Directory walking, paths, SQLite storage, Tauri IPC, and DOM rendering necessarily allocate. The zero-allocation claim is intentionally limited to parsing one already opened file.

Desktop development on Arch Linux

Install Tauri's Linux prerequisites, Rust 1.98+, GStreamer 1.24+ development files, and an AAC/M4A GStreamer decoder, then:

sudo pacman -S --needed gstreamer gst-plugins-base gst-plugins-good gst-libav
pnpm install
pnpm tauri dev

The development command opens the app and reloads the frontend as it changes. Choose Import folder, select a library such as ~/Music/m4a, then scroll and search the result.

Application state is stored in app.sqlite3 below Tauri's app-data directory. This unreleased build starts with an empty library and disconnected scrobbling services; refresh/import the music and reconnect the services. Music files remain in place. Subsequent launches retain the new state. See storage.

For an intentional development reset, close the app and remove its state files. This clears imported metadata, playlists, playback, preferences, and credentials:

nmrav_data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.nmrav.client"
rm -f -- "$nmrav_data_dir/app.sqlite3" \
  "$nmrav_data_dir/app.sqlite3-wal" \
  "$nmrav_data_dir/app.sqlite3-shm"

Build and run a release executable without creating distro bundles:

pnpm tauri build --no-bundle
./src-tauri/target/release/nmrav

Tauri can emit Linux bundle formats with pnpm tauri build, but it does not emit a native Arch pkg.tar.zst; use a PKGBUILD when distribution packaging becomes useful.

Tests and storage benchmark

Cross-platform operation traces are persisted in the device SQLite alongside the playback journal. See structured diagnostics for coverage, correlation, clock semantics, retention limits and capture queries.

pnpm lint includes frontend, Rust, and Swift formatting checks. Install the pinned SwiftFormat version and see code style and scalar semantics for formatter setup, type boundaries, and the complete verification commands.

pnpm lint
cargo test --all-targets --manifest-path src-tauri/Cargo.toml
cargo run --release --manifest-path src-tauri/Cargo.toml \
  --bin nmrav-import-bench -- "$HOME/Music/m4a" 3 9705 warm 8
cargo run --release --manifest-path src-tauri/Cargo.toml \
  --bin nmrav-import-bench -- "$HOME/Music/m4a" 1 1000 cold 8 path
cargo run --release --manifest-path src-tauri/Cargo.toml \
  --bin nmrav-catalog-bench -- "$HOME/Music/m4a" 8 cold

The parser benchmark takes passes, maximum file count, warm/cold, worker count, and optional native/path ordering. On Linux, cold uses POSIX_FADV_DONTNEED on only the selected, clean music-file cache pages; it does not use global drop_caches. It reports logical parser bytes and kernel-accounted physical reads. The catalog benchmark additionally includes the directory walk, SQLite inserts, FTS triggers, commit, browse, and search queries.

On macOS, pnpm bench:webkit-memory opens an isolated WKWebView with the production frontend, 10,000 synthetic tracks, and simulated playing/meter events. It measures WebContent's physical footprint (including compressed pages) every 10 seconds, checks that the library stays virtualized with one full snapshot, and allows at most 64 MiB growth after warmup. Use BENCH_SECONDS=300 for a five-minute run. Keep the profiling window visible. No user library, audio, or network services are used; the server binds to loopback. The standalone Swift profiling adapter uses diagnostic WebKit SPI to identify its own child process.

This catches a WebKit allocation problem that JavaScript heap checks miss: updating an inherited progress CSS variable on the player every frame caused rapid WebCore memory growth. Progress now changes the fill element's transform directly. With the unchanged 60-second regression, the old frontend grew 220.8 MiB between the first and last samples; the corrected frontend's maximum growth was 3.6 MiB while animation and meter polling continued. The five-minute run finished at 59.3 MiB with a 76.2 MiB highest sampled footprint.

Playback command replies and native notifications use separate delivery paths. Every UI playback snapshot carries its queue revision and native event sequence; the UI rejects older versions before changing transport availability, track metadata, or the elapsed-time anchor. A command remains awaited until completion, but its late reply cannot undo a newer native observation. Discriminated unions describe valid states; version comparisons separately enforce their ordering. The transport browser regression deliberately delivers the ready event before the older transitioning reply. Pure ordering tests cover all 24 permutations of old-queue, transitioning, playing, and paused snapshots, plus delayed updates after Stop. These tests need no audio hardware or timing-dependent sleeps.

Library scrolling

Library navigation includes All songs, Artists, Albums, Decades, Years and named playlists. Browsing stays temporary until a track is selected for playback; Cancel restores the previous committed view. See navigation and playlist behavior.

library_snapshot reads the whole library in one SQLite transaction and returns typed numeric columns, references into a deduplicated UTF-8 string pool, and the initial display order. The UI keeps that snapshot resident and materializes only visible rows. Music-file paths stay native; the UI receives a format label and deduplicated cover paths. library_order returns four bytes per matching row when search or sorting changes. Large scroll jumps reuse the existing DOM slots.

Run pnpm exec playwright install webkit once, then pnpm test:webkit for WebKit touch-feedback, native scrolling, row-reuse, and title-paint regressions. These desktop engine tests do not certify iPhone frame timing or UIKit scroll indicator behavior. A sub-millisecond catalog lookup excludes DOM updates, layout, image decoding, painting, and compositor presentation.

A persisted revision changes in the same transaction as library metadata. Imports notify the UI after finishing, including partial failures with committed batches. An order request with a stale revision triggers a new snapshot. Resuming the app also checks through an order request, recovering missed update notifications. Search changes during loading retain the snapshot and coalesce to the latest selection; imports discard responses from the old library revision.

Date added continues to use the earlier of filesystem creation/birth time and modification time. If only one is available, it supplies the date; if neither is available, the first import time is retained across refreshes. Albums use the latest of their tracks' dates, while disc/track order remains ascending. Creation time here means birth time, not Unix inode-change ctime.

The metadata snapshot needs one rowid-ordered table scan, with no new metadata index. Existing date-added/release-date covering indexes and FTS still supply display orders. The current schema includes facet, queue, playback, scrobble, and diagnostic tables. Tests and benchmarks use the same binary snapshots, orders, and section queries as the application; there is no JSON page/catalog API.

For the 9,705-track Linux library, the binary snapshot is 908,128 bytes and takes 16.7 ms to build (warm p95). An unfiltered order response is 38,852 bytes and takes about 0.8 ms. The Chromium probe measured 8.6 ms p95 for distant-jump JavaScript and synchronous layout work after DOM reuse, down from 16.6 ms. Its maximum was 21.2 ms; it uses placeholder covers and does not measure presentation. These are desktop measurements, not an iPhone frame-rate guarantee. Full results and limitations are in the recorded benchmark.

To reproduce on a scratch copy of a library database:

pnpm bench:queries --database /path/to/app.sqlite3 --samples 25 \
  --snapshot-output /tmp/nmrav-snapshot.bin
pnpm build
node tests/snapshot-render-bench.mjs /tmp/nmrav-snapshot.bin

The native benchmark opens the source read-only and uses VACUUM INTO to create its scratch database. The binary export contains actual library metadata and cover paths; only aggregate results belong in the repository. The wire layout and shared Rust/TypeScript fixture are described in the snapshot format.

Mobile boundary

The Rust parser/database/import architecture is shared by desktop, Android, and iOS. Mobile document pickers can yield scoped URLs, Android content:// descriptors, provider streams, or cloud-backed documents. The parser therefore depends only on ReadAt; the ordinary-file implementation is a fixed 4 KiB positional-read cache, and a future mobile provider needs to implement only that small interface.

The official Tauri dialog plugin does not support directory picking on Android or iOS. On iOS, the app instead scans its own Documents directory, stores paths relative to the stable documents root, and exposes that directory through Finder/Files using UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace. Desktop folder import continues to use the native picker. Android library acquisition still needs its own document-tree/media adapter and is deliberately not hidden behind a fake path.

Android can be initialized/run from Linux:

pnpm tauri android init
pnpm tauri android dev
pnpm tauri android build --debug --apk --target aarch64

iOS builds require macOS and Xcode:

pnpm tauri ios init
pnpm tauri ios dev --open
pnpm tauri ios dev --open --host # physical device
pnpm tauri ios build

The repository already contains the local Swift player package and the iOS plist settings. On the Mac, launch Xcode through ios dev --open and keep that command running while using the IDE. The generated build phase loads the user's zsh environment so Xcode can find shell-managed pnpm, Node, and Cargo installations. Use Xcode or xcodebuild for verification while that IDE session is open. A separate pnpm tauri ios build replaces Tauri's temporary server-address file for this app, leaving the IDE pointed at an exited helper when the standalone build finishes. If switching to standalone builds, start a fresh pnpm tauri ios dev --open session before returning to Xcode. Run the package's NmravPlayerTests target first; it decodes the same command fixture serialized by the Rust test suite, encodes the native snapshot fixture parsed by Rust, and locks the callback event numbers. Every iOS app startup also sends every native command variant through the real Tauri plugin bridge for Swift to decode and compare. Swift then sends a sentinel packet through the real scalar callback, and Rust checks every received field before starting the event worker. A bridge, symbol, JSON shape, integer-width, signedness, or field-order mismatch therefore fails startup. Then select a development team in Xcode, run on a simulator/device, copy M4A files into the app's Documents directory, press Refresh Documents, and tap a track. Xcode execution is the remaining gate for Swift type-checking, signing, and actual AVFoundation behavior.

On every iOS or Linux process launch, Rust restores the persisted queue and asks the native player to seek to the last checkpoint while paused. Launch never resumes playback by itself; playback resumes only after an explicit user or system play request.

Queue revision identifies the ordered queue, not the audio object currently producing sound. Changing shuffle/repeat/end policy intentionally advances the revision and can give the current track a different ordinal. Every platform adapter must compare the incoming current track ID with the active native item and, when they match, retain that item and replace only the upcoming queue. It must not pause, seek, reload, or rebuild the current item merely because revision or ordinal changed. The iOS and Linux adapters implement this rule; the future Android adapter must do the same. Violating it produces an audible discontinuity on every mode change.

The database is created from one current schema. There are no schema upgrades or historical data conversions. Development schema changes require an explicit fresh store; normal launches reopen existing state.

iOS uses MPNowPlayingSession automatic publication, with descriptive metadata on each AVPlayerItem. AVFoundation supplies the system playback timeline. Diagnostics in diagnostic_events with source ios_now_playing record automatic_observation: the reason, sampled audio position/rate, queue identity, callback thread and output route. Historical manual-publication records also include published position/rate and pending seek. cleared entries record withdrawal of the player from its Now Playing session. None of these entries acknowledges what iOS rendered; the held scrub gesture is not recorded. Correlate event_monotonic_ms, revision, and native sequence with the native player events. The sequence is sampled without incrementing it. A separate bounded background queue writes the traces, so row insertion order and the attached Rust state can lag the native callback; use native timestamps for ordering. droppedBefore is a cumulative loss count for the process, including queue overflow and storage failures. Diagnostic failure never changes playback. Periodic audio callbacks continue to checkpoint native playback in Rust. The application does not manually write system elapsed time or playback rate. Native protocol version 5 reports interrupted seeks as seek_cancelled, with the matching request ID and actual position, and desktop Stop as stop_requested, distinct from Pause. See the playback contract and Simulator test commands for the bounded event-order tests, production-controller host and remaining system-display verification requirements.

Playback intents, native observations and effect completions enter one Rust coordinator. It prepares and admits commands in one order; a separate worker awaits native effects while observations continue to be reduced. Seek effects target the captured AVPlayerItem, and requested positions remain distinct from observed audio positions. The execution protocol records the invariants, exhaustive finite models, regression evidence and assumptions behind this ordering.

Audio-session category/activation/deactivation calls run on one background serial worker; AVPlayer and Now Playing updates remain on main. Apple's AVAudioSession.h documents setActive as blocking and warns against using a thread that cannot tolerate a long block. The controller has one in-flight operation and one replaceable latest request, including its continuation, rather than queuing setup/teardown for every tap. Requests before the worker starts also coalesce. Completions resolve the latest intent on main, so an obsolete activation cannot start playback after Stop. An Apple call already in progress cannot be cancelled; a subsequent Play may wait for that call and activation, but does not replay the intervening taps. Stop's rewind is cancelled/retired by newer transport or queue work, and its callback cannot mark newer playback stopped.

ios_audio_session diagnostics record set_category, activate, and deactivate individually, with monotonic start time, elapsedMs, callback thread, and a tagged success/error-code outcome. These use the existing bounded diagnostic worker and loss counter without changing playback state. The elapsed time excludes tracing and main queue delivery; database insertion time is not the duration of the platform call. Deterministic regressions cover 2,001 rapid intents, in-flight and delayed-completion races, failure handling, and obsolete Stop rewinds. The synchronous baseline failed the caller-responsiveness regression before the scheduler change; the unchanged test passes with background dispatch and latest-intent coalescing.

Current scope

Metadata import is M4A-only. FLAC, MP3, the Android document provider, and native Android audio remain future work. Scrobbling storage/HTTP and the cross-platform playback state machine are implemented. Linux uses GStreamer/MPRIS; the Swift adapter still requires its macOS Xcode verification pass.