Commits
Replace symphonia with rockbox playback
Add a "Supported audio formats" table (grouped lossless / lossy / PCM /
game audio) covering the full rockbox-playback codec set, wire it into the
table of contents, and refresh the remaining stale "symphonia" references
throughout the README to point at rockbox-playback.
Document the symphonia → rockbox-playback audio backend swap and the
GPL/stdenv.cc build note under a new 0.6.0 release section.
Update the workspace version, flake build version, README install URLs
and the Debian/RPM packaging manifests from 0.5.0 to 0.6.0.
The local audio renderer previously hand-rolled a symphonia + cpal + rb
stack (queue, shuffle/repeat, resume, ReplayGain, crossfade, EQ/tone DSP,
HTTP streaming) in a 2215-line SymphoniaPlayer. rockbox-playback 0.4 now
provides all of that natively, so swap the implementation for a thin
adapter over its Player.
- Add rockbox-playback = "0.4"; drop symphonia, cpal, rb and the direct
rockbox-dsp dep (all now transitive).
- New rockbox_player.rs (RockboxPlayer) implements Renderer by delegating
to rockbox_playback::Player. Player owns a cpal stream and is !Send +
!Sync, so it lives on a dedicated worker thread driven by FnOnce(&Player)
command closures; the struct holds only the Sender + a Send+Sync metadata
mirror. Delete symphonia_player.rs and voice_dsp.rs.
- Persistence: the engine owns queue order + exact playhead via its own
resume_file; fin's queue.json stays a metadata sidecar so the TUI keeps
server-provided titles/artwork. restore() calls Player::resume() with a
seed-from-sidecar fallback for first-run migration.
- Slim replaygain.rs / crossfade.rs to re-exports of the fin-config types
(tag extraction + fade curves now live in the engine).
- Rewire lib.rs / local.rs and refresh docs; sweep stale "symphonia"
mentions across fin, fin-tui and fin-mediarenderer.
- flake.nix: add stdenv.cc to the package and dev shell — rockbox-codecs /
rockbox-dsp compile Rockbox's C sources via the cc crate.
Note: rockbox-playback is GPL-2.0-or-later, so linking it makes the fin
binary GPL (already true via rockbox-dsp).
The emulated freebsd/netbsd aarch64 VM builds take hours and were
gating the release job. They now run in release-bsd-aarch64.yml with
no dependency on the main Release workflow: each job attaches its
tarball to the already-published release via gh release upload when
it finishes.
Bump the workspace, flake, deb/rpm packaging and README install
snippets to 0.5.0, and document the new MPRIS support in the changelog.
Add MPRIS support on Linux via a new fin-mpris crate
async_trait's boxed renderer futures are Send but not Sync, while
mpris-server's trait_variant::make(Send + Sync) bound requires interface
futures to be both — every method that awaited a renderer call directly
failed to compile on Linux. Route renderer calls through tokio::spawn:
the JoinHandle is Sync, so the interface future never holds the boxed
future. The helper is deliberately not an async fn — async fn arguments
live in the generated future's state, which would re-poison Sync.
MPRIS is a D-Bus spec, not a Linux one — BSD desktops (KDE, GNOME,
wlroots compositors) run a session bus and playerctl ships in
ports/pkgsrc. zbus is pure Rust, so no dbus dev package is needed and
the release VM package lists stay untouched. Headless boxes without a
session bus keep the existing non-fatal warn-and-continue path.
Register org.mpris.MediaPlayer2.fin on the session bus so media keys,
desktop applets and playerctl can drive whatever renderer the TUI holds
(local, Chromecast or UPnP). A poll task diffs Renderer::state()
snapshots into PropertiesChanged/Seeked signals, mirroring the GENA
notify loop in fin-mediarenderer.
The crate body is #![cfg(target_os = "linux")] and its dependencies are
target-gated, so macOS and BSD builds never pull in zbus. No dbus dev
package is needed anywhere: zbus is a pure-Rust D-Bus implementation.
Build natively inside QEMU VMs via cross-platform-actions, since
cargo/cross cannot cross-compile to these targets. FreeBSD/NetBSD
install alsa-lib + pkgconf for cpal's ALSA backend; OpenBSD gets no
audio packages since cpal falls back to the Null host there
(Chromecast/DLNA playback still works). cmake is installed everywhere
for aws-lc-sys (rustls crypto provider), which builds with its bundled
universal bindings on the BSDs, so bindgen isn't needed. mpv is a
runtime-only external process. Manual workflow_dispatch runs now check
out the given tag instead of the dispatching branch.
Shuffle/repeat now work while casting, logs go to a file in TUI mode,
and the workspace layout gained fin-subsonic, fin-media, and
fin-mediarenderer.
set_shuffle/set_repeat fell through to the trait's no-op defaults, so
the z / Shift+R toggles did nothing while casting. Both cast workers
already auto-advance through the shared PlaybackQueue, which honors
shuffle order and repeat modes — the overrides just update that queue
and mirror the new state so the TUI reflects it.
Auto-advance and Next/Previous moved the queue cursor but never wrote
current_index back into the shared PlaybackState, so the Queue screen's
▶ marker and (n/total) counter stuck on the old track.
While the TUI owns the terminal, anything written to stderr (GENA
notify failures, UPnP cast-in traffic, replaygain warnings) breaks
the display. Logs now go to <cache-dir>/fin.log when running the
TUI, and the UPnP cast-in log line is demoted from info to debug.
New fin-mediarenderer crate advertises the machine as a UPnP AV
MediaRenderer while the TUI runs: SSDP presence (alive/byebye +
M-SEARCH answers), device/SCPD description XML over a tiny embedded
HTTP server, AVTransport / RenderingControl / ConnectionManager SOAP
control, and GENA LastChange eventing.
Pushed streams drive the shared LocalRenderer, so audio decodes
in-process via symphonia and video opens in mpv. Cast-in tracks are
badged "⇊ UPnP" in the Now Playing bar with a one-shot status line,
and are excluded from scrobbling (their ids are foreign to the media
server).
Enabled by default; disable with --no-media-renderer,
FIN_NO_MEDIA_RENDERER, or media_renderer.enabled = false. The
friendly name and port are configurable, and the device UDN is
generated once and persisted so control points recognize the machine
across restarts.
Also: LocalRenderer now reports the audio player's real volume while
idle (UPnP GetVolume exposed the 100% snap-back), and a pre-existing
clippy never_loop error in discovery.rs is fixed.
Run nix build on ubuntu-latest and macos-latest for every push to
main and every pull request, using the Determinate Systems Nix
installer.
The apt and yum repos live under https://apt.fury.io/tsiry and
https://yum.fury.io/tsiry, not tsirysndr.
Subsonic has no video API, so the Videos tab only exists on Jellyfin:
the tab bar drops it, Tab/Shift+Tab skip past it, and the `2` key shows
a hint instead. Switching servers while on Videos bounces to Music.
Plays the highlighted container (album, artist, playlist, series) or,
on flat views, the whole visible list (favorites, videos, open
album/playlist tracks) in random order. The pool is Fisher-Yates
shuffled up front so the first track is random too, and shuffle mode
is switched on for anything enqueued later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump rockbox-dsp to 0.2.0 and move gain application into the Rockbox
DSP's pre-gain (PGA) stage, the same fixed-point pipeline as EQ and
tone controls. Tag extraction stays in fin; the f32 linear multiplier
survives only as a fallback for paths the PGA can't reach (the
crossfade-incoming track routed through the voice config, non-stereo
output, and the first primed packet).
Keep a Changelog / SemVer format covering 0.1.0 → 0.3.0, with the
Favorites tab, like/dislike shortcuts, and server-backend badge folded
into 0.3.0.
The header now carries a "◈ Jellyfin" / "≋ Subsonic" badge for the
active server, and each row in the Settings server list shows its
backend between the name and URL — so it's obvious at a glance which
kind the current/other servers speak.
Also adds a getStarred2 deserialization regression test covering the
real-world response shape (status key after the payload, starred
artists present but dropped).
The README tagline/intro, multi-server section, in-app header line,
and the help modal's server-cycle entry all described fin as
Jellyfin-only; the Subsonic backend has been in since 0.3.0.
New ♥ Favorites screen (key 4; Queue/Search/Devices/Settings shift to
5-8) lists everything the user has liked, backed by both servers:
Jellyfin via /Users/{id}/Items?Filters=IsFavorite + FavoriteItems,
Subsonic via getStarred2 + star/unstar — surfaced through new
MediaClient::favorites() / set_favorite() trait methods.
Shift+L likes and Shift+D dislikes the highlighted item, falling back
to the now-playing track on screens without library rows. Disliking
while the Favorites tab is open reloads the list so the row drops
immediately. Help modal, status-bar hints, and README updated.
Two independent bugs, both surfaced as "Listening now works but the
listen never lands":
Jellyfin (root cause the user hit)
- report_stopped shipped `state.position_secs` at the moment we detected
a track transition — but by then the queue had already advanced to
the next track, so PositionTicks was ~0. Jellyfin's ListenBrainz
plugin uses PositionTicks (vs. RunTimeTicks) to gauge whether the
track was actually played through; a zero position looks like a skip
and the listen is silently dropped.
- Snapshot `state.position_secs` into a new App.scrobble_last_position
on every tick where the reported track is still current. At transition
or end-of-playback, feed the snapshot into report_stopped instead of
the (now-zero) live position. The plugin sees a realistic end-of-track
position and records the listen.
Subsonic (belt-and-braces for Navidrome/Airsonic + ListenBrainz)
- Subsonic scrobble calls now include `time=<ms-since-epoch>`. Servers
like Navidrome forward this straight to ListenBrainz as `listened_at`;
without it the submission is either dropped or timestamped
approximately. For submission=true we compute the listen-start as
now − position_secs so the ListenBrainz entry lands close to when the
song actually started playing.
- scrobble() takes the timestamp as a plain argument; scrobble_now_playing
uses the current wall clock, scrobble_submission accepts an
`Option<u64>` from the caller.
Verified with cargo build --workspace --release + cargo test --workspace
--release (140 tests). Live Jellyfin retest expected to record the
listen in "Recently Listened" now.
Two belt-and-braces tightenings so failed session-report calls can never
crash the process:
- The transition arm in emit_scrobble_events used to unwrap
state.now_playing after the derivation proved it was Some. The proof
was sound but implicit; swap it for an explicit `if let Some(...)` so
the safety is textual and survives a future refactor of `now_playing_id`.
- SubsonicClient held its username/password in std::sync::Mutex, which
panics on `.lock().unwrap()` if a prior holder aborted while holding
the guard. Extremely narrow window, but a real panic path. Switch to
parking_lot::Mutex — no poisoning, no unwrap.
Every network call already ran under tokio::spawn with `warn!(?e, ...)`
on Err, so failed reports were already logged instead of propagating.
These two changes remove the last two theoretical panics on the path.
Release build + all 140 tests pass.
Two new crates plus a backend abstraction. `fin login <url>` probes the
URL to detect Jellyfin vs Subsonic and picks the right client without the
user having to say. Both backends now flow through the same `MediaClient`
trait so the TUI, CLI, and player don't have to branch on server kind.
Crates
- fin-subsonic: SubsonicClient speaking Subsonic REST v1.16.1. Salt +
MD5(password + salt) auth (no plaintext ever hits the wire once logged
in). Endpoints: ping, getAlbumList2, getAlbum, getPlaylists,
getPlaylist, search3, stream, getCoverArt, scrobble. Album / Song /
Playlist responses are mapped into fin_jellyfin::BaseItem so downstream
widgets stay backend-blind. Compatible with Airsonic, Navidrome, Gonic,
Astiga, and every server that speaks the standard vocabulary.
- fin-media: hosts the MediaClient async trait plus a `probe_server(url)`
that races GET /System/Info/Public and GET /rest/ping.view — first to
answer wins, so a healthy server responds in one round-trip. Impls for
both JellyfinClient and SubsonicClient live here (orphan rules — the
trait's crate owns them). `login_any(url, user, pass)` bundles probe +
login into a single call that returns Arc<dyn MediaClient>.
`client_from_stored(...)` reconstructs the right concrete client from a
ServerConfig after loading config.toml on startup.
Config
- fin_config: new ServerKind enum (Jellyfin | Subsonic), and ServerConfig
grows a `server_kind` field with #[serde(default)] = Jellyfin so old
configs load unchanged. Legacy single-server migration slots into
Jellyfin.
fin binary
- cmd_login now uses `fin_media::login_any` for auto-detection and stores
the detected server_kind on the ServerConfig. The "signed in as" line
advertises the backend so it's obvious which one was picked.
- make_client returns Arc<dyn MediaClient>, dispatched off server_kind.
- content_type_for_url etc. still use the JellyfinClient statics — they
don't need the abstraction.
fin-tui
- App::jellyfin is now Arc<Mutex<Arc<dyn MediaClient>>>. Zero-change to
every jf().items(...) call site — trait method signatures match.
- switch_server rebuilds via client_from_stored, so switching from a
Jellyfin server to a Subsonic one (or vice versa) picks the right
concrete client.
Scrobble / session-report hooks
- MediaClient trait gains report_started / report_progress /
report_stopped with no-op defaults.
- JellyfinClient impl delegates to /Sessions/Playing[/Progress|/Stopped].
- SubsonicClient impl fires /rest/scrobble.view — submission=false on
started, submission=true on stopped. progress is a Subsonic no-op
(there's no per-tick endpoint in the standard vocabulary).
- fin-tui event loop tracks the last-reported item id + a 10 s throttle
and emits started / progress / stopped as playback transitions. Only
fires for local audio playback; Chromecast / UPnP receivers manage
their own reporting server-side.
Verified: cargo test --workspace passes 140 tests across 13 crates
(previous baseline was 133 in 12 crates — the +7 are Subsonic-side
MD5/salt/JSON round-trip tests plus the fin-media ServerKind test).
Ships everything on the 0.2.x → 0.3.0 line: symphonia+cpal audio path,
UPnP renderer, queue persistence, shuffle/repeat, ReplayGain, dual-track
crossfade (crossfade + mixed modes), Rockbox EQ + bass/treble, the 10-band
EQ sliders in the TUI, album drill-in (discs + track numbers + year +
sort), and the ? keyboard-shortcuts modal.
- workspace Cargo.toml: 0.2.0 → 0.3.0 (all five crates pick it up via
version.workspace = true).
- flake.nix commonArgs.version bumped to match.
- README install snippets now reference fin_0.3.0_*.deb / fin-0.3.0-*.rpm.
- Debian control files (amd64 + arm64) and the RPM spec updated.
- Cargo.lock refreshed via `cargo build --workspace`.
Ratatui popup over the current screen listing every key fin binds,
grouped by category (Navigation / Playback / Queue / Modes & effects /
Equalizer & tone / Help). ? toggles it; Esc closes it too.
While the modal is open, only ? / Esc / Ctrl+C do anything — every
other key is swallowed so you can't accidentally start music while
reading. The popup is drawn LAST in the frame so it lands on top of
the player bar and status line, and is backed by ratatui::Clear so
the underlying UI doesn't bleed through.
Widget layout is data-driven off a static HELP_SECTIONS table so tests
can lock the shape and typos surface at compile time. Column layout
pads the key side to the widest key across all sections, which keeps
"Enter" and "Shift+↑ / Shift+↓" column-aligned regardless of section.
Sizing: the popup grows to the terminal height (minus a 2-row margin)
capped at 60 rows so every section fits without scrolling — verified
in a 60-row tmux window.
Four unit tests cover HELP_SECTIONS: no empty sections or entries,
? is always documented, and build_lines' first row is a section
header (not a blank spacer).
Bottom-of-screen hint promoted "?: help" to first position and README
keybindings table gained a matching row.
Album drill-in now uses its own renderer instead of the generic list.
Improvements:
- Album title includes the production year in parentheses when
Jellyfin has one (e.g. "17 (2017)").
- Every track row shows its track number, padded to the widest number
in the album so "1." and "12." line up. The subtitle / duration
columns follow the same layout math as the standard list, so they
stay column-aligned with sibling views.
- Multi-disc albums render a "▤ Disc N" header before each disc's
tracks — non-selectable, so Up/Down navigation walks track-index
space and the cursor never lands on a header. Single-disc albums
render as a flat list (no header noise).
- After fetching the tracks, sort_album_tracks() re-orders by
(disc, track number, name) as a safety net. Jellyfin already applies
ParentIndexNumber,IndexNumber,SortName server-side, but a single
mis-tagged track was enough to leak into the disc grouping downstream.
Under the hood the app still tracks a TRACK-index cursor; the album
renderer builds visual rows + a track_to_visual map and drives a local
ListState so the highlight lands on the right visible line even when
disc headers push the visual index above the track index.
Four unit tests cover sort_album_tracks: disc-then-track ordering,
tracks with missing metadata sink to the tail, ties break on name,
and already-sorted input is preserved.
Two changes in one commit — they share the tree touched by the
recent EQ / tone / crossfade work.
DSP transients during overlap
- The previous shared-DSP approach routed both tracks through the same
`CODEC_IDX_AUDIO` config every tick. Rockbox biquad delay lines are
per-config, so alternating between the two tracks corrupted the state
and produced the "small noises" the user reported.
- Add a `VoiceDsp` wrapper around Rockbox's second config
(`CODEC_IDX_VOICE`). Coefficients (`dsp_set_eq_coefs`, `tone_*`) are
global — same EQ curve on both configs — but the delay lines are
independent, so the two tracks stop stepping on each other's state.
- A new `DspProcess` trait lets `decode_one_packet` and `apply_dsp`
accept either config without duplicating the packet loop.
- Both configs are flushed at crossfade preload start and at promotion
so stale delay-line state doesn't leak between tracks.
Unit tests (+52 across the workspace, 73→125)
- fin-player::queue: RepeatMode label/next cycle, advance()/back()
under Off/One/All, peek_next_item across all repeat modes, shuffle
preserves the current item, reshuffle_all pins current at index 0.
- fin-player::persist: PersistedQueue round-trip via write_atomically
+ load, malformed / missing file returns None, legacy JSON with
missing fields loads with #[serde(default)] filled in, no `.tmp`
sibling left behind.
- fin-config: RepeatMode / ReplayGainMode / CrossfadeMode label + cycle
+ is_active helpers; default_eq_band_settings has 10 monotonic ISO
bands; ensure_eq_bands fills empty and pads short lists, leaves full
lists alone.
- fin-tui::screens: Screen::ALL matches tab order, next/prev wrap and
are inverses; icon/label lookups; fmt_dur across the H:MM:SS
threshold; RowLayout sums to total width, hides subtitle on narrow
terminals, gives title ~55% of the middle; truncate honors width
budgets and produces bare "…" at width 1.
Wires the second Rockbox DSP stage into the audio path — the same singleton
that hosts the EQ. Both stages now apply to the outgoing AND incoming
crossfade tracks (not just current) — the biquad state gets briefly stirred
at the switch each tick, but the transient is sub-millisecond at 48 kHz and
inaudible in practice.
Config
- fin-config: bass / treble (dB, ±24) + bass_cutoff / treble_cutoff (Hz,
0 = Rockbox defaults 200 / 3500) at Config's top level, matching the
Rockbox settings.toml schema so presets round-trip. All default to 0.
Audio path
- New PlayerCommand::SetTone forwarded through the Renderer trait and
LocalRenderer. Applied to the DSP via a new apply_tone_to_dsp helper
that sets cutoffs first (so the prescale in set_tone recomputes filter
coefficients on the right shelf frequencies).
- The DSP is now routed for both current AND next during a crossfade —
same coefficients / same shelves on both sides of the overlap.
- The DSP path is skipped entirely when EQ and both tone shelves are 0,
so no f32↔i16 conversion overhead on unaffected sessions.
- Tone state mirrored on PlaybackState.bass_db / treble_db.
TUI
- New keys: b / Shift+B step bass by −1 / +1 dB, y / Shift+Y for treble
(avoids conflict with `t` = cycle server). Values persist immediately.
- Settings screen grew a new "Tone" row showing bass and treble alongside
the existing ReplayGain / Crossfade rows. The top card grew one line.
- EQ card is now 18 rows (was 11) so the vertical sliders have real
resolution; server list falls back to a Min(3) floor if terminal is short.
- Player bar shows a compact "B+3/T-2" badge whenever either shelf is
non-zero.
- Help hint + README ToC + keybindings + a new "Bass & treble"
sub-section under Playback modes covering keys, cutoffs, and the same
GPL note as EQ.
Verified live: bass toggled to +3 dB, treble to −2 dB via B×3 + y×2;
Settings row + player badge update in step; config.toml persists
`bass = 3` / `treble = -2`.
Previously the layout stacked "+2.5" on one row and "dB" on the next.
Reading it took an extra saccade — and the "dB" row was pure whitespace
for the column, cluttering the panel.
Now the top row of each slider column reads "+2.5 dB" as a single line
(number in the band's style, unit in muted style). The bar picks up one
more row of vertical resolution as a side effect.
Links rockbox-dsp 0.1 from crates.io — a Rust wrapper around the fixed-point
Rockbox DSP pipeline (10-band EQ + tone + resampler + more).
Config
- fin-config: eq_enabled + eq_band_settings at the top level, matching the
on-disk shape rockbox uses so presets round-trip. A fresh config is
auto-populated with the ISO-octave flat preset (32 Hz → 16 kHz, Q 7.0,
0 dB) via Config::ensure_eq_bands() on load, so the sliders always show
10 bands and the DSP has something meaningful to process. Old configs
with fewer bands get padded to 10 without touching what's there.
Audio path
- SymphoniaPlayer owns an Option<Dsp> singleton, lazy-initialised on the
first track load. When eq_enabled is true, the *current* track's post-
resample samples run through Dsp (f32 → i16 saturating → dsp_process →
i16 → f32) before volume + ReplayGain + fade multiply. Non-stereo output
bypasses (rockbox-dsp is stereo-only).
- The next track during a crossfade always bypasses — the Rockbox pipeline
is a process-wide singleton (CODEC_IDX_AUDIO), can't run two streams.
- New PlayerCommand::SetEq for live toggle + band updates without a
reload; SetReplayGain-style forwarding through LocalRenderer.
- main.rs applies cfg.eq_enabled + cfg.eq_band_settings on startup.
TUI
- New EqSliders widget: 10 vertical block-char slider columns with a
+N.N dB label above, "dB" unit hint, and a Hz/kHz cutoff label below.
Selected band gets highlighted; a ±24 dB axis label sits in the corner.
- Settings screen grew a 3-row layout — global config, EQ panel, servers.
- Keybindings: E toggles EQ. On Settings: [ / ] cycle bands, Shift+↑/↓
bump the selected band's gain by ±1 dB. Changes persist to config.toml
immediately and are reapplied to the running DSP.
- Player bar shows a compact "EQ" badge when active.
- Help hint + README table of contents + keybindings table updated with
a new "Equalizer" sub-section describing the on-disk format, keys, and
the licensing implication (linking Rockbox DSP makes the binary GPL).
Verified live: Settings screen renders 10 sliders at the ISO cutoffs; E
lights the panel border; ] moves to band 3 (125 Hz); five Shift+Up
presses take it to +5.0 dB with the status line and the slider fill
updating in step; config.toml persists gain = 50 on band 3.
Adds a linked table of contents at the top so readers can jump to any
section. While in there, reflects everything that landed since the
last README pass:
- Audio is in-process (symphonia + cpal) now — mpv is video-only. Any
mention of mpv as the "local renderer" is corrected.
- Features section covers shuffle, repeat, ReplayGain, crossfade
(Crossfade vs Mixed), queue persistence, and remove/clear queue.
- Keybindings table gains z / Shift+R / g / f / Shift+F / d / Shift+C
and the Enter-jumps-on-Queue behaviour.
- New "Playback modes & effects" section with a sub-heading per feature
(Shuffle / Repeat / ReplayGain / Crossfade), each with anchor links
from the ToC.
- New "Queue persistence" section explains queue.json survival across
restarts.
- "Streams & transcoding" split into audio (symphonia) vs video (mpv).
- All-settings table gains a sub-table for the replaygain/crossfade
TOML keys.
- Install snippets mention libasound2/alsa-lib on Linux and clarify
mpv is only a runtime dep when playing video.
When the user played from the Queue screen (or any Play command), the
handler called `queue.replace(items, start_index)` which already sets
current_index at the incoming track. Then the crossfade promotion step
would unconditionally call `queue.advance()`, shifting current_index
one row past the actually-playing track.
Track whether the pending promotion should advance:
- End-approach preload (peek_next_item, next-in-queue): advance = true
- User Play with crossfade (queue already positioned): advance = false
The Next command's snap-promote reads the same flag so hitting Next
mid-Play-crossfade doesn't double-advance either.
Debug log now includes `advance=<bool>` at promotion time.
Verified: enable Mixed 8s, play a queue item at index 6, wait for the
overlap to complete. Queue title stayed (6/16); log shows advance=false.
- Delete the unused drain_ring helper and the Track.ring_capacity field
that only fed it. build_output_stream's tuple shrinks by one slot.
- Shift+F cycles the crossfade duration through 3/5/8/12 s, preserving
the current mode. `f` still toggles the mode.
- Settings row + help hint updated to advertise both keys.
Adjacent tracks now blend for a configurable window. Two modes:
- Crossfade: cosine/sine curves (outgoing^2 + incoming^2 = 1) — perceived
loudness stays constant across the overlap.
- Mixed: no curves, both tracks at full volume — sums additively for a
louder overlap window (DJ-style mix).
The SymphoniaPlayer worker now runs two decoders in parallel during an
overlap. Each track has its own cpal Stream + ring buffer; the OS mixes
them at the audio layer. Fade envelopes are applied per-frame in the
sample push path (using this track's produced_output_frames as the
progress clock).
Data
- fin-config: CrossfadeMode (Off/Crossfade/Mixed) + CrossfadeSettings
(mode + duration_secs, default 5s). #[serde(default)] on the Config
field keeps older configs loading.
- fin-player: crossfade module with fade_at() + FadePair, 6 unit tests
covering endpoint values, midpoint equal-power, sum-of-squares
invariant, and clamping.
Audio path
- Track gets `ended: bool`, `overlap_incoming/outgoing: Option<OverlapContext>`.
On EOF the current track marks itself ended but doesn't advance the queue
— that's the promotion step's job.
- Two ways an overlap starts:
1. End-approach: worker sees decoder-position + duration <= xf duration,
peeks the next queue item, loads it as `next` with fade_in, marks
current with fade_out.
2. User Play/enqueue-jump: while a track's playing and crossfade is on,
the incoming track loads as `next` instead of hard-cutting.
- Promotion fires when either the fade-in completes OR the current track's
fade-out reaches full length OR current ended prematurely.
- Manual Next snap-promotes any pending crossfade for instant response.
- SetReplayGain propagates to both tracks so a mid-overlap RG change hits
both sides.
Renderer + config
- PlaybackState.crossfade mirrored so the TUI can render without a
separate query path.
- Renderer::set_crossfade with a no-op default (Chromecast/UPnP each
handle their own transitions).
- main.rs applies cfg.crossfade on startup before playback begins.
TUI
- `f` cycles Off → Crossfade → Mixed → Off, persisted to config.toml.
Duration edit stays in config.toml.
- Player bar shows ⋈ Ns (Crossfade) or ≈ Ns (Mixed) when active.
- Settings screen row: mode + duration + hint.
- Help text updated.
Verified live: debug log confirms two crossfades executed with
length_frames=240000 (5s at 48 kHz), promoted after exactly 5 s;
Mixed mode swaps to the ≈ glyph. mpv still never spawns for audio.
82 workspace tests pass.
Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN / REPLAYGAIN_*_PEAK
tags off decoded tracks and applies a linear multiplier to samples on
their way to cpal. User picks Off / Track / Album via `g` in the TUI.
Data
- fin-config: new ReplayGainMode enum and ReplayGainSettings struct
(mode, preamp_db, prevent_clip). Config gains a `replaygain` field
with #[serde(default)] so old on-disk configs keep loading. Pure
data, no fin-player dep required.
- fin-player: new replaygain module owns ReplayGainInfo (per-track
tag values) and the linear_gain math — dB→linear, preamp additive
in dB domain, peak-based clip prevention, fallback to the other
scope when the requested one is missing. 11 unit tests cover the
numeric edge cases (clip prevention, preamp, fallback, junk parse).
Audio path
- SymphoniaPlayer stores current ReplayGainSettings in the worker,
extracts ReplayGainInfo from the first packet's metadata on each
track load, and precomputes a linear gain that the sample push
loop folds into the same multiply as user volume.
- New PlayerCommand::SetReplayGain updates the settings live and
recomputes the current track's linear gain in place.
- Renderer trait gains set_replaygain with a no-op default (Chromecast
and UPnP do their own device-side normalization).
TUI
- `g` cycles Off → Track → Album → Off, saves to config, statuses
"ReplayGain: <mode>".
- Player bar shows a compact "RG:track" / "RG:album" badge only when
active; muted when Off (row stays balanced).
- Settings screen has a new row: mode + preamp dB + clip-guard flag
+ "press g to cycle" hint. Panel grew from 7→8 rows to fit.
- Help hint updated.
Verified live: on a track without RG tags, linear=1.0 is logged and the
badge lights up when cycled; config.toml grows a [replaygain] section
that survives restart; 76 workspace tests pass.
Adds session survival plus a few overdue queue controls. The audio queue,
its shuffle/repeat state, and the exact playhead within the current track
now round-trip through cache_dir/queue.json — restore leaves playback
paused so Space resumes from where the user left off.
Playback
- RepeatMode enum (Off/One/All) on PlaybackQueue with advance()/back()
honoring it. Repeat-all wraps in both directions.
- Fisher-Yates shuffle over items after the current position via `rand`,
so shuffling mid-track doesn't jump the playhead.
- Renderer trait gains set_shuffle / set_repeat / restore /
remove_from_queue with no-op defaults for Chromecast + UPnP.
Persistence
- crates/fin-player/src/persist.rs: PersistedQueue serde struct and a
background writer thread that coalesces bursts and atomically renames
a .tmp into place.
- SymphoniaPlayer worker snapshots on every mutating command AND every
~3 s while playing so position stays fresh even if the app is killed.
- Restore filters out video items, remaps the saved index to survive
filtering, and stashes position_secs as a pending_seek applied on the
next track load.
TUI
- z: toggle shuffle. Shift+R: cycle repeat off/all/one.
- On the Queue screen: d removes the highlighted entry (keeps playback
going unless that entry was the current track); Shift+C clears the
whole queue.
- Player bar shows compact ⇄ (shuffle) and ↻/↺ (repeat) glyphs, colored
when active, muted when off.
- Queue rows: currently-playing track gets a ▶ marker in the accent
color, distinct from the cursor's ▍. Title shows (position/total).
- Help hint updated.
Startup
- fin_config::queue_path() lives in the cache dir. main::build_renderer
loads it (silent-none on missing/corrupt) and hands it to
LocalRenderer::with_persist + renderer.restore() before the TUI runs.
Verified end-to-end: 45 s of playback → quit → restart → queue restored
with shuffle+repeat state + 54 s playhead, resumes on Space; cursor and
now-playing markers move independently on the Queue screen; d removes a
non-current entry without interrupting playback.
Pressing Enter on a row in the Queue screen went through the generic
play_selected(PlayMode::PlayNow) path, which called
`renderer.play(selected_item_only, 0)` — replacing the entire queue
with just that one item.
Added a Queue-screen-specific Enter handler that snapshots the current
queue, hands it back to the renderer with the selected index as the
start position, and reports the jump. Same items, playhead moves.
pkg-config's setup hook only scans `buildInputs` for `.pc` files, so
alsa-lib landing in `nativeBuildInputs` made cpal's ALSA link resolve
inconsistently in the dev shell — the cpal build script could run
pkg-config but wouldn't find libasound.pc.
Split the devShell into a proper nativeBuildInputs (tools including
pkg-config) and buildInputs (link-time libs: libiconv on Darwin,
alsa-lib on Linux). crane's commonArgs was already correct; this only
touches the devShell.
The local audio renderer previously hand-rolled a symphonia + cpal + rb
stack (queue, shuffle/repeat, resume, ReplayGain, crossfade, EQ/tone DSP,
HTTP streaming) in a 2215-line SymphoniaPlayer. rockbox-playback 0.4 now
provides all of that natively, so swap the implementation for a thin
adapter over its Player.
- Add rockbox-playback = "0.4"; drop symphonia, cpal, rb and the direct
rockbox-dsp dep (all now transitive).
- New rockbox_player.rs (RockboxPlayer) implements Renderer by delegating
to rockbox_playback::Player. Player owns a cpal stream and is !Send +
!Sync, so it lives on a dedicated worker thread driven by FnOnce(&Player)
command closures; the struct holds only the Sender + a Send+Sync metadata
mirror. Delete symphonia_player.rs and voice_dsp.rs.
- Persistence: the engine owns queue order + exact playhead via its own
resume_file; fin's queue.json stays a metadata sidecar so the TUI keeps
server-provided titles/artwork. restore() calls Player::resume() with a
seed-from-sidecar fallback for first-run migration.
- Slim replaygain.rs / crossfade.rs to re-exports of the fin-config types
(tag extraction + fade curves now live in the engine).
- Rewire lib.rs / local.rs and refresh docs; sweep stale "symphonia"
mentions across fin, fin-tui and fin-mediarenderer.
- flake.nix: add stdenv.cc to the package and dev shell — rockbox-codecs /
rockbox-dsp compile Rockbox's C sources via the cc crate.
Note: rockbox-playback is GPL-2.0-or-later, so linking it makes the fin
binary GPL (already true via rockbox-dsp).
async_trait's boxed renderer futures are Send but not Sync, while
mpris-server's trait_variant::make(Send + Sync) bound requires interface
futures to be both — every method that awaited a renderer call directly
failed to compile on Linux. Route renderer calls through tokio::spawn:
the JoinHandle is Sync, so the interface future never holds the boxed
future. The helper is deliberately not an async fn — async fn arguments
live in the generated future's state, which would re-poison Sync.
MPRIS is a D-Bus spec, not a Linux one — BSD desktops (KDE, GNOME,
wlroots compositors) run a session bus and playerctl ships in
ports/pkgsrc. zbus is pure Rust, so no dbus dev package is needed and
the release VM package lists stay untouched. Headless boxes without a
session bus keep the existing non-fatal warn-and-continue path.
Register org.mpris.MediaPlayer2.fin on the session bus so media keys,
desktop applets and playerctl can drive whatever renderer the TUI holds
(local, Chromecast or UPnP). A poll task diffs Renderer::state()
snapshots into PropertiesChanged/Seeked signals, mirroring the GENA
notify loop in fin-mediarenderer.
The crate body is #![cfg(target_os = "linux")] and its dependencies are
target-gated, so macOS and BSD builds never pull in zbus. No dbus dev
package is needed anywhere: zbus is a pure-Rust D-Bus implementation.
Build natively inside QEMU VMs via cross-platform-actions, since
cargo/cross cannot cross-compile to these targets. FreeBSD/NetBSD
install alsa-lib + pkgconf for cpal's ALSA backend; OpenBSD gets no
audio packages since cpal falls back to the Null host there
(Chromecast/DLNA playback still works). cmake is installed everywhere
for aws-lc-sys (rustls crypto provider), which builds with its bundled
universal bindings on the BSDs, so bindgen isn't needed. mpv is a
runtime-only external process. Manual workflow_dispatch runs now check
out the given tag instead of the dispatching branch.
set_shuffle/set_repeat fell through to the trait's no-op defaults, so
the z / Shift+R toggles did nothing while casting. Both cast workers
already auto-advance through the shared PlaybackQueue, which honors
shuffle order and repeat modes — the overrides just update that queue
and mirror the new state so the TUI reflects it.
New fin-mediarenderer crate advertises the machine as a UPnP AV
MediaRenderer while the TUI runs: SSDP presence (alive/byebye +
M-SEARCH answers), device/SCPD description XML over a tiny embedded
HTTP server, AVTransport / RenderingControl / ConnectionManager SOAP
control, and GENA LastChange eventing.
Pushed streams drive the shared LocalRenderer, so audio decodes
in-process via symphonia and video opens in mpv. Cast-in tracks are
badged "⇊ UPnP" in the Now Playing bar with a one-shot status line,
and are excluded from scrobbling (their ids are foreign to the media
server).
Enabled by default; disable with --no-media-renderer,
FIN_NO_MEDIA_RENDERER, or media_renderer.enabled = false. The
friendly name and port are configurable, and the device UDN is
generated once and persisted so control points recognize the machine
across restarts.
Also: LocalRenderer now reports the audio player's real volume while
idle (UPnP GetVolume exposed the 100% snap-back), and a pre-existing
clippy never_loop error in discovery.rs is fixed.
Plays the highlighted container (album, artist, playlist, series) or,
on flat views, the whole visible list (favorites, videos, open
album/playlist tracks) in random order. The pool is Fisher-Yates
shuffled up front so the first track is random too, and shuffle mode
is switched on for anything enqueued later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump rockbox-dsp to 0.2.0 and move gain application into the Rockbox
DSP's pre-gain (PGA) stage, the same fixed-point pipeline as EQ and
tone controls. Tag extraction stays in fin; the f32 linear multiplier
survives only as a fallback for paths the PGA can't reach (the
crossfade-incoming track routed through the voice config, non-stereo
output, and the first primed packet).
Keep a Changelog / SemVer format covering 0.1.0 → 0.3.0, with the
Favorites tab, like/dislike shortcuts, and server-backend badge folded
into 0.3.0.
The header now carries a "◈ Jellyfin" / "≋ Subsonic" badge for the
active server, and each row in the Settings server list shows its
backend between the name and URL — so it's obvious at a glance which
kind the current/other servers speak.
Also adds a getStarred2 deserialization regression test covering the
real-world response shape (status key after the payload, starred
artists present but dropped).
New ♥ Favorites screen (key 4; Queue/Search/Devices/Settings shift to
5-8) lists everything the user has liked, backed by both servers:
Jellyfin via /Users/{id}/Items?Filters=IsFavorite + FavoriteItems,
Subsonic via getStarred2 + star/unstar — surfaced through new
MediaClient::favorites() / set_favorite() trait methods.
Shift+L likes and Shift+D dislikes the highlighted item, falling back
to the now-playing track on screens without library rows. Disliking
while the Favorites tab is open reloads the list so the row drops
immediately. Help modal, status-bar hints, and README updated.
Two independent bugs, both surfaced as "Listening now works but the
listen never lands":
Jellyfin (root cause the user hit)
- report_stopped shipped `state.position_secs` at the moment we detected
a track transition — but by then the queue had already advanced to
the next track, so PositionTicks was ~0. Jellyfin's ListenBrainz
plugin uses PositionTicks (vs. RunTimeTicks) to gauge whether the
track was actually played through; a zero position looks like a skip
and the listen is silently dropped.
- Snapshot `state.position_secs` into a new App.scrobble_last_position
on every tick where the reported track is still current. At transition
or end-of-playback, feed the snapshot into report_stopped instead of
the (now-zero) live position. The plugin sees a realistic end-of-track
position and records the listen.
Subsonic (belt-and-braces for Navidrome/Airsonic + ListenBrainz)
- Subsonic scrobble calls now include `time=<ms-since-epoch>`. Servers
like Navidrome forward this straight to ListenBrainz as `listened_at`;
without it the submission is either dropped or timestamped
approximately. For submission=true we compute the listen-start as
now − position_secs so the ListenBrainz entry lands close to when the
song actually started playing.
- scrobble() takes the timestamp as a plain argument; scrobble_now_playing
uses the current wall clock, scrobble_submission accepts an
`Option<u64>` from the caller.
Verified with cargo build --workspace --release + cargo test --workspace
--release (140 tests). Live Jellyfin retest expected to record the
listen in "Recently Listened" now.
Two belt-and-braces tightenings so failed session-report calls can never
crash the process:
- The transition arm in emit_scrobble_events used to unwrap
state.now_playing after the derivation proved it was Some. The proof
was sound but implicit; swap it for an explicit `if let Some(...)` so
the safety is textual and survives a future refactor of `now_playing_id`.
- SubsonicClient held its username/password in std::sync::Mutex, which
panics on `.lock().unwrap()` if a prior holder aborted while holding
the guard. Extremely narrow window, but a real panic path. Switch to
parking_lot::Mutex — no poisoning, no unwrap.
Every network call already ran under tokio::spawn with `warn!(?e, ...)`
on Err, so failed reports were already logged instead of propagating.
These two changes remove the last two theoretical panics on the path.
Release build + all 140 tests pass.
Two new crates plus a backend abstraction. `fin login <url>` probes the
URL to detect Jellyfin vs Subsonic and picks the right client without the
user having to say. Both backends now flow through the same `MediaClient`
trait so the TUI, CLI, and player don't have to branch on server kind.
Crates
- fin-subsonic: SubsonicClient speaking Subsonic REST v1.16.1. Salt +
MD5(password + salt) auth (no plaintext ever hits the wire once logged
in). Endpoints: ping, getAlbumList2, getAlbum, getPlaylists,
getPlaylist, search3, stream, getCoverArt, scrobble. Album / Song /
Playlist responses are mapped into fin_jellyfin::BaseItem so downstream
widgets stay backend-blind. Compatible with Airsonic, Navidrome, Gonic,
Astiga, and every server that speaks the standard vocabulary.
- fin-media: hosts the MediaClient async trait plus a `probe_server(url)`
that races GET /System/Info/Public and GET /rest/ping.view — first to
answer wins, so a healthy server responds in one round-trip. Impls for
both JellyfinClient and SubsonicClient live here (orphan rules — the
trait's crate owns them). `login_any(url, user, pass)` bundles probe +
login into a single call that returns Arc<dyn MediaClient>.
`client_from_stored(...)` reconstructs the right concrete client from a
ServerConfig after loading config.toml on startup.
Config
- fin_config: new ServerKind enum (Jellyfin | Subsonic), and ServerConfig
grows a `server_kind` field with #[serde(default)] = Jellyfin so old
configs load unchanged. Legacy single-server migration slots into
Jellyfin.
fin binary
- cmd_login now uses `fin_media::login_any` for auto-detection and stores
the detected server_kind on the ServerConfig. The "signed in as" line
advertises the backend so it's obvious which one was picked.
- make_client returns Arc<dyn MediaClient>, dispatched off server_kind.
- content_type_for_url etc. still use the JellyfinClient statics — they
don't need the abstraction.
fin-tui
- App::jellyfin is now Arc<Mutex<Arc<dyn MediaClient>>>. Zero-change to
every jf().items(...) call site — trait method signatures match.
- switch_server rebuilds via client_from_stored, so switching from a
Jellyfin server to a Subsonic one (or vice versa) picks the right
concrete client.
Scrobble / session-report hooks
- MediaClient trait gains report_started / report_progress /
report_stopped with no-op defaults.
- JellyfinClient impl delegates to /Sessions/Playing[/Progress|/Stopped].
- SubsonicClient impl fires /rest/scrobble.view — submission=false on
started, submission=true on stopped. progress is a Subsonic no-op
(there's no per-tick endpoint in the standard vocabulary).
- fin-tui event loop tracks the last-reported item id + a 10 s throttle
and emits started / progress / stopped as playback transitions. Only
fires for local audio playback; Chromecast / UPnP receivers manage
their own reporting server-side.
Verified: cargo test --workspace passes 140 tests across 13 crates
(previous baseline was 133 in 12 crates — the +7 are Subsonic-side
MD5/salt/JSON round-trip tests plus the fin-media ServerKind test).
Ships everything on the 0.2.x → 0.3.0 line: symphonia+cpal audio path,
UPnP renderer, queue persistence, shuffle/repeat, ReplayGain, dual-track
crossfade (crossfade + mixed modes), Rockbox EQ + bass/treble, the 10-band
EQ sliders in the TUI, album drill-in (discs + track numbers + year +
sort), and the ? keyboard-shortcuts modal.
- workspace Cargo.toml: 0.2.0 → 0.3.0 (all five crates pick it up via
version.workspace = true).
- flake.nix commonArgs.version bumped to match.
- README install snippets now reference fin_0.3.0_*.deb / fin-0.3.0-*.rpm.
- Debian control files (amd64 + arm64) and the RPM spec updated.
- Cargo.lock refreshed via `cargo build --workspace`.
Ratatui popup over the current screen listing every key fin binds,
grouped by category (Navigation / Playback / Queue / Modes & effects /
Equalizer & tone / Help). ? toggles it; Esc closes it too.
While the modal is open, only ? / Esc / Ctrl+C do anything — every
other key is swallowed so you can't accidentally start music while
reading. The popup is drawn LAST in the frame so it lands on top of
the player bar and status line, and is backed by ratatui::Clear so
the underlying UI doesn't bleed through.
Widget layout is data-driven off a static HELP_SECTIONS table so tests
can lock the shape and typos surface at compile time. Column layout
pads the key side to the widest key across all sections, which keeps
"Enter" and "Shift+↑ / Shift+↓" column-aligned regardless of section.
Sizing: the popup grows to the terminal height (minus a 2-row margin)
capped at 60 rows so every section fits without scrolling — verified
in a 60-row tmux window.
Four unit tests cover HELP_SECTIONS: no empty sections or entries,
? is always documented, and build_lines' first row is a section
header (not a blank spacer).
Bottom-of-screen hint promoted "?: help" to first position and README
keybindings table gained a matching row.
Album drill-in now uses its own renderer instead of the generic list.
Improvements:
- Album title includes the production year in parentheses when
Jellyfin has one (e.g. "17 (2017)").
- Every track row shows its track number, padded to the widest number
in the album so "1." and "12." line up. The subtitle / duration
columns follow the same layout math as the standard list, so they
stay column-aligned with sibling views.
- Multi-disc albums render a "▤ Disc N" header before each disc's
tracks — non-selectable, so Up/Down navigation walks track-index
space and the cursor never lands on a header. Single-disc albums
render as a flat list (no header noise).
- After fetching the tracks, sort_album_tracks() re-orders by
(disc, track number, name) as a safety net. Jellyfin already applies
ParentIndexNumber,IndexNumber,SortName server-side, but a single
mis-tagged track was enough to leak into the disc grouping downstream.
Under the hood the app still tracks a TRACK-index cursor; the album
renderer builds visual rows + a track_to_visual map and drives a local
ListState so the highlight lands on the right visible line even when
disc headers push the visual index above the track index.
Four unit tests cover sort_album_tracks: disc-then-track ordering,
tracks with missing metadata sink to the tail, ties break on name,
and already-sorted input is preserved.
Two changes in one commit — they share the tree touched by the
recent EQ / tone / crossfade work.
DSP transients during overlap
- The previous shared-DSP approach routed both tracks through the same
`CODEC_IDX_AUDIO` config every tick. Rockbox biquad delay lines are
per-config, so alternating between the two tracks corrupted the state
and produced the "small noises" the user reported.
- Add a `VoiceDsp` wrapper around Rockbox's second config
(`CODEC_IDX_VOICE`). Coefficients (`dsp_set_eq_coefs`, `tone_*`) are
global — same EQ curve on both configs — but the delay lines are
independent, so the two tracks stop stepping on each other's state.
- A new `DspProcess` trait lets `decode_one_packet` and `apply_dsp`
accept either config without duplicating the packet loop.
- Both configs are flushed at crossfade preload start and at promotion
so stale delay-line state doesn't leak between tracks.
Unit tests (+52 across the workspace, 73→125)
- fin-player::queue: RepeatMode label/next cycle, advance()/back()
under Off/One/All, peek_next_item across all repeat modes, shuffle
preserves the current item, reshuffle_all pins current at index 0.
- fin-player::persist: PersistedQueue round-trip via write_atomically
+ load, malformed / missing file returns None, legacy JSON with
missing fields loads with #[serde(default)] filled in, no `.tmp`
sibling left behind.
- fin-config: RepeatMode / ReplayGainMode / CrossfadeMode label + cycle
+ is_active helpers; default_eq_band_settings has 10 monotonic ISO
bands; ensure_eq_bands fills empty and pads short lists, leaves full
lists alone.
- fin-tui::screens: Screen::ALL matches tab order, next/prev wrap and
are inverses; icon/label lookups; fmt_dur across the H:MM:SS
threshold; RowLayout sums to total width, hides subtitle on narrow
terminals, gives title ~55% of the middle; truncate honors width
budgets and produces bare "…" at width 1.
Wires the second Rockbox DSP stage into the audio path — the same singleton
that hosts the EQ. Both stages now apply to the outgoing AND incoming
crossfade tracks (not just current) — the biquad state gets briefly stirred
at the switch each tick, but the transient is sub-millisecond at 48 kHz and
inaudible in practice.
Config
- fin-config: bass / treble (dB, ±24) + bass_cutoff / treble_cutoff (Hz,
0 = Rockbox defaults 200 / 3500) at Config's top level, matching the
Rockbox settings.toml schema so presets round-trip. All default to 0.
Audio path
- New PlayerCommand::SetTone forwarded through the Renderer trait and
LocalRenderer. Applied to the DSP via a new apply_tone_to_dsp helper
that sets cutoffs first (so the prescale in set_tone recomputes filter
coefficients on the right shelf frequencies).
- The DSP is now routed for both current AND next during a crossfade —
same coefficients / same shelves on both sides of the overlap.
- The DSP path is skipped entirely when EQ and both tone shelves are 0,
so no f32↔i16 conversion overhead on unaffected sessions.
- Tone state mirrored on PlaybackState.bass_db / treble_db.
TUI
- New keys: b / Shift+B step bass by −1 / +1 dB, y / Shift+Y for treble
(avoids conflict with `t` = cycle server). Values persist immediately.
- Settings screen grew a new "Tone" row showing bass and treble alongside
the existing ReplayGain / Crossfade rows. The top card grew one line.
- EQ card is now 18 rows (was 11) so the vertical sliders have real
resolution; server list falls back to a Min(3) floor if terminal is short.
- Player bar shows a compact "B+3/T-2" badge whenever either shelf is
non-zero.
- Help hint + README ToC + keybindings + a new "Bass & treble"
sub-section under Playback modes covering keys, cutoffs, and the same
GPL note as EQ.
Verified live: bass toggled to +3 dB, treble to −2 dB via B×3 + y×2;
Settings row + player badge update in step; config.toml persists
`bass = 3` / `treble = -2`.
Previously the layout stacked "+2.5" on one row and "dB" on the next.
Reading it took an extra saccade — and the "dB" row was pure whitespace
for the column, cluttering the panel.
Now the top row of each slider column reads "+2.5 dB" as a single line
(number in the band's style, unit in muted style). The bar picks up one
more row of vertical resolution as a side effect.
Links rockbox-dsp 0.1 from crates.io — a Rust wrapper around the fixed-point
Rockbox DSP pipeline (10-band EQ + tone + resampler + more).
Config
- fin-config: eq_enabled + eq_band_settings at the top level, matching the
on-disk shape rockbox uses so presets round-trip. A fresh config is
auto-populated with the ISO-octave flat preset (32 Hz → 16 kHz, Q 7.0,
0 dB) via Config::ensure_eq_bands() on load, so the sliders always show
10 bands and the DSP has something meaningful to process. Old configs
with fewer bands get padded to 10 without touching what's there.
Audio path
- SymphoniaPlayer owns an Option<Dsp> singleton, lazy-initialised on the
first track load. When eq_enabled is true, the *current* track's post-
resample samples run through Dsp (f32 → i16 saturating → dsp_process →
i16 → f32) before volume + ReplayGain + fade multiply. Non-stereo output
bypasses (rockbox-dsp is stereo-only).
- The next track during a crossfade always bypasses — the Rockbox pipeline
is a process-wide singleton (CODEC_IDX_AUDIO), can't run two streams.
- New PlayerCommand::SetEq for live toggle + band updates without a
reload; SetReplayGain-style forwarding through LocalRenderer.
- main.rs applies cfg.eq_enabled + cfg.eq_band_settings on startup.
TUI
- New EqSliders widget: 10 vertical block-char slider columns with a
+N.N dB label above, "dB" unit hint, and a Hz/kHz cutoff label below.
Selected band gets highlighted; a ±24 dB axis label sits in the corner.
- Settings screen grew a 3-row layout — global config, EQ panel, servers.
- Keybindings: E toggles EQ. On Settings: [ / ] cycle bands, Shift+↑/↓
bump the selected band's gain by ±1 dB. Changes persist to config.toml
immediately and are reapplied to the running DSP.
- Player bar shows a compact "EQ" badge when active.
- Help hint + README table of contents + keybindings table updated with
a new "Equalizer" sub-section describing the on-disk format, keys, and
the licensing implication (linking Rockbox DSP makes the binary GPL).
Verified live: Settings screen renders 10 sliders at the ISO cutoffs; E
lights the panel border; ] moves to band 3 (125 Hz); five Shift+Up
presses take it to +5.0 dB with the status line and the slider fill
updating in step; config.toml persists gain = 50 on band 3.
Adds a linked table of contents at the top so readers can jump to any
section. While in there, reflects everything that landed since the
last README pass:
- Audio is in-process (symphonia + cpal) now — mpv is video-only. Any
mention of mpv as the "local renderer" is corrected.
- Features section covers shuffle, repeat, ReplayGain, crossfade
(Crossfade vs Mixed), queue persistence, and remove/clear queue.
- Keybindings table gains z / Shift+R / g / f / Shift+F / d / Shift+C
and the Enter-jumps-on-Queue behaviour.
- New "Playback modes & effects" section with a sub-heading per feature
(Shuffle / Repeat / ReplayGain / Crossfade), each with anchor links
from the ToC.
- New "Queue persistence" section explains queue.json survival across
restarts.
- "Streams & transcoding" split into audio (symphonia) vs video (mpv).
- All-settings table gains a sub-table for the replaygain/crossfade
TOML keys.
- Install snippets mention libasound2/alsa-lib on Linux and clarify
mpv is only a runtime dep when playing video.
When the user played from the Queue screen (or any Play command), the
handler called `queue.replace(items, start_index)` which already sets
current_index at the incoming track. Then the crossfade promotion step
would unconditionally call `queue.advance()`, shifting current_index
one row past the actually-playing track.
Track whether the pending promotion should advance:
- End-approach preload (peek_next_item, next-in-queue): advance = true
- User Play with crossfade (queue already positioned): advance = false
The Next command's snap-promote reads the same flag so hitting Next
mid-Play-crossfade doesn't double-advance either.
Debug log now includes `advance=<bool>` at promotion time.
Verified: enable Mixed 8s, play a queue item at index 6, wait for the
overlap to complete. Queue title stayed (6/16); log shows advance=false.
- Delete the unused drain_ring helper and the Track.ring_capacity field
that only fed it. build_output_stream's tuple shrinks by one slot.
- Shift+F cycles the crossfade duration through 3/5/8/12 s, preserving
the current mode. `f` still toggles the mode.
- Settings row + help hint updated to advertise both keys.
Adjacent tracks now blend for a configurable window. Two modes:
- Crossfade: cosine/sine curves (outgoing^2 + incoming^2 = 1) — perceived
loudness stays constant across the overlap.
- Mixed: no curves, both tracks at full volume — sums additively for a
louder overlap window (DJ-style mix).
The SymphoniaPlayer worker now runs two decoders in parallel during an
overlap. Each track has its own cpal Stream + ring buffer; the OS mixes
them at the audio layer. Fade envelopes are applied per-frame in the
sample push path (using this track's produced_output_frames as the
progress clock).
Data
- fin-config: CrossfadeMode (Off/Crossfade/Mixed) + CrossfadeSettings
(mode + duration_secs, default 5s). #[serde(default)] on the Config
field keeps older configs loading.
- fin-player: crossfade module with fade_at() + FadePair, 6 unit tests
covering endpoint values, midpoint equal-power, sum-of-squares
invariant, and clamping.
Audio path
- Track gets `ended: bool`, `overlap_incoming/outgoing: Option<OverlapContext>`.
On EOF the current track marks itself ended but doesn't advance the queue
— that's the promotion step's job.
- Two ways an overlap starts:
1. End-approach: worker sees decoder-position + duration <= xf duration,
peeks the next queue item, loads it as `next` with fade_in, marks
current with fade_out.
2. User Play/enqueue-jump: while a track's playing and crossfade is on,
the incoming track loads as `next` instead of hard-cutting.
- Promotion fires when either the fade-in completes OR the current track's
fade-out reaches full length OR current ended prematurely.
- Manual Next snap-promotes any pending crossfade for instant response.
- SetReplayGain propagates to both tracks so a mid-overlap RG change hits
both sides.
Renderer + config
- PlaybackState.crossfade mirrored so the TUI can render without a
separate query path.
- Renderer::set_crossfade with a no-op default (Chromecast/UPnP each
handle their own transitions).
- main.rs applies cfg.crossfade on startup before playback begins.
TUI
- `f` cycles Off → Crossfade → Mixed → Off, persisted to config.toml.
Duration edit stays in config.toml.
- Player bar shows ⋈ Ns (Crossfade) or ≈ Ns (Mixed) when active.
- Settings screen row: mode + duration + hint.
- Help text updated.
Verified live: debug log confirms two crossfades executed with
length_frames=240000 (5s at 48 kHz), promoted after exactly 5 s;
Mixed mode swaps to the ≈ glyph. mpv still never spawns for audio.
82 workspace tests pass.
Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN / REPLAYGAIN_*_PEAK
tags off decoded tracks and applies a linear multiplier to samples on
their way to cpal. User picks Off / Track / Album via `g` in the TUI.
Data
- fin-config: new ReplayGainMode enum and ReplayGainSettings struct
(mode, preamp_db, prevent_clip). Config gains a `replaygain` field
with #[serde(default)] so old on-disk configs keep loading. Pure
data, no fin-player dep required.
- fin-player: new replaygain module owns ReplayGainInfo (per-track
tag values) and the linear_gain math — dB→linear, preamp additive
in dB domain, peak-based clip prevention, fallback to the other
scope when the requested one is missing. 11 unit tests cover the
numeric edge cases (clip prevention, preamp, fallback, junk parse).
Audio path
- SymphoniaPlayer stores current ReplayGainSettings in the worker,
extracts ReplayGainInfo from the first packet's metadata on each
track load, and precomputes a linear gain that the sample push
loop folds into the same multiply as user volume.
- New PlayerCommand::SetReplayGain updates the settings live and
recomputes the current track's linear gain in place.
- Renderer trait gains set_replaygain with a no-op default (Chromecast
and UPnP do their own device-side normalization).
TUI
- `g` cycles Off → Track → Album → Off, saves to config, statuses
"ReplayGain: <mode>".
- Player bar shows a compact "RG:track" / "RG:album" badge only when
active; muted when Off (row stays balanced).
- Settings screen has a new row: mode + preamp dB + clip-guard flag
+ "press g to cycle" hint. Panel grew from 7→8 rows to fit.
- Help hint updated.
Verified live: on a track without RG tags, linear=1.0 is logged and the
badge lights up when cycled; config.toml grows a [replaygain] section
that survives restart; 76 workspace tests pass.
Adds session survival plus a few overdue queue controls. The audio queue,
its shuffle/repeat state, and the exact playhead within the current track
now round-trip through cache_dir/queue.json — restore leaves playback
paused so Space resumes from where the user left off.
Playback
- RepeatMode enum (Off/One/All) on PlaybackQueue with advance()/back()
honoring it. Repeat-all wraps in both directions.
- Fisher-Yates shuffle over items after the current position via `rand`,
so shuffling mid-track doesn't jump the playhead.
- Renderer trait gains set_shuffle / set_repeat / restore /
remove_from_queue with no-op defaults for Chromecast + UPnP.
Persistence
- crates/fin-player/src/persist.rs: PersistedQueue serde struct and a
background writer thread that coalesces bursts and atomically renames
a .tmp into place.
- SymphoniaPlayer worker snapshots on every mutating command AND every
~3 s while playing so position stays fresh even if the app is killed.
- Restore filters out video items, remaps the saved index to survive
filtering, and stashes position_secs as a pending_seek applied on the
next track load.
TUI
- z: toggle shuffle. Shift+R: cycle repeat off/all/one.
- On the Queue screen: d removes the highlighted entry (keeps playback
going unless that entry was the current track); Shift+C clears the
whole queue.
- Player bar shows compact ⇄ (shuffle) and ↻/↺ (repeat) glyphs, colored
when active, muted when off.
- Queue rows: currently-playing track gets a ▶ marker in the accent
color, distinct from the cursor's ▍. Title shows (position/total).
- Help hint updated.
Startup
- fin_config::queue_path() lives in the cache dir. main::build_renderer
loads it (silent-none on missing/corrupt) and hands it to
LocalRenderer::with_persist + renderer.restore() before the TUI runs.
Verified end-to-end: 45 s of playback → quit → restart → queue restored
with shuffle+repeat state + 54 s playhead, resumes on Space; cursor and
now-playing markers move independently on the Queue screen; d removes a
non-current entry without interrupting playback.
Pressing Enter on a row in the Queue screen went through the generic
play_selected(PlayMode::PlayNow) path, which called
`renderer.play(selected_item_only, 0)` — replacing the entire queue
with just that one item.
Added a Queue-screen-specific Enter handler that snapshots the current
queue, hands it back to the renderer with the selected index as the
start position, and reports the jump. Same items, playhead moves.
pkg-config's setup hook only scans `buildInputs` for `.pc` files, so
alsa-lib landing in `nativeBuildInputs` made cpal's ALSA link resolve
inconsistently in the dev shell — the cpal build script could run
pkg-config but wouldn't find libasound.pc.
Split the devShell into a proper nativeBuildInputs (tools including
pkg-config) and buildInputs (link-time libs: libiconv on Darwin,
alsa-lib on Linux). crane's commonArgs was already correct; this only
touches the devShell.