Follow-up on the shading and peaks, plus the knobs to tune them.
The gradient now ends at a configured `spectrum_top_color` -- the secondary
purple -- rather than blending toward white, so the top row *is* the top
color. "none" ramps brightness alone.
A peak now fills the gap between the bar and its held maximum instead of
marking it with a rule, so each bar trails a shadow of where it just was.
`spectrum_peak_fill = false` keeps the rule, which is also the fallback
for a font without U+2594.
The dividers make the segments legible without breaking the field apart:
the seam between two bars is the bar itself at 35% brightness rather than
an empty column, and `spectrum_row_gap` eighths are left unlit at the top
of every cell so a full row draws `▇` and each value row reads on its own.
`spectrum_bar_width` is a minimum -- bars take the spare columns of their
slot and seams stay exactly `spectrum_bar_gap` wide, because an uneven bar
reads as an uneven level while an uneven seam is only untidy. Keeping the
seam inside the bar's own cells means all 24 bins still fit a normal pane.
Peaks fall in seconds now, not frames: `update_spectrum` times the interval
between frames and `spectrum_peak_fall` says how long a full-scale shadow
takes to reach the floor (4s, up from an effective 1.7s). The server's frame
rate is its own business, and a per-frame decay silently retunes itself when
it changes. `advance_spectrum` takes the interval so the fall is testable
without a clock, and the renderer floors the divisor itself rather than
trusting the config clamp -- a zero would freeze every shadow on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Modelled on BeSpec's LED mode. Bars are now shaded by height rather than
drawn in one flat color, and a peak-hold marker rides above each column.
Shading is a function of the row alone, so it stays cheap: the color is
computed once per row and the row's cells still coalesce into a couple of
spans. `gradient_color` ramps the configured base from 55% brightness at
the floor to 30% toward white at the top, so a loud bar reads as hot and
not merely tall. Only an `Rgb` base can be interpolated -- a color name or
a palette index is a reference into the terminal's own theme, whose RGB
value is not ours to know -- so those render flat, whatever
`spectrum_gradient` says.
The peaks are per-bin state advanced in `update_spectrum`: a bin at or
above its peak raises it at once, otherwise the peak falls 0.03 a frame,
about 1.7s from full scale at the server's 20 fps. The server keeps
streaming zeroed frames while the audio is idle, so the markers fall away
on pause instead of freezing on screen. A marker is drawn only in a row
the bar does not reach: a cell holds one glyph, so one inside the bar's
own top cell would eat the bar to repeat what its top edge already shows.
Config gains `spectrum_gradient` (default true) and `spectrum_peak_color`
(default the primary blue, `"none"`/`"off"` to draw none), resolved with
`spectrum_color` into one `SpectrumStyle` in place of the old
`set_spectrum_color`. Existing config files take the defaults.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane
borders, so the one moving thing on screen read as chrome.
`spectrum_color` in the client config now picks it, parsed by ratatui's
`Color: FromStr`: a `#rrggbb` triple, a color name, or a 0-255 palette index,
the latter two deferring to the terminal theme. It defaults to `COLOR_RED` —
the red the queue marks the playing track with — so the two agree on what
"now" looks like.
The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX`
for the config file, one being a `Color` and the other a string a user edits.
`the_default_spectrum_color_is_the_queue_red` stops them drifting.
A config file is user input, so an unparsable value warns on stderr and falls
back to the red instead of failing. It resolves in `run`, before the alternate
screen is entered, or the warning would be drawn over and lost.
Existing config files predate the key: they keep working and take the default,
since every field of the `ClapSerde` opt struct is optional.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pane reports `Volume: 85%`, or `Volume: 85% (muted)`.
The display was the small half of this: the TUI was discarding the volume
it was already being sent (`StreamUpdate::Volume(_)` was a FIXME), and
`Init` dropped `volume` and `mute` as well — so the pane would have
started out wrong and corrected itself only once the user touched either
control. Both are wired now.
Muting keeps the level on screen rather than replacing it: the server
reports the level it would unmute to, which is the one the user is about
to adjust. That retires the old `, Muted` suffix.
The line now renders with no track loaded too — shuffle, repeat and
volume describe the server, and an idle player is exactly when you reach
for `K` blind.
Formatting is a pure function so its edges are tested: the wire carries a
float, so NaN and infinity read as `--` rather than `NaN%`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All on by default, so a plain build is unchanged (verified: the default
dependency set for crabidy-server is byte-identical to before). Tailor a
smaller binary with --no-default-features --features …
(architecture/build-features.md).
Compile-time features draw dependency boundaries; the existing
crabidy-server.toml providers list keeps doing per-mount runtime
toggling. The compile-time set bounds the runtime one: a provider built
out cannot be enabled from the config, and naming it earns one startup
warning rather than silence.
- crabidy-server: tidal · youtube · fyyd · abs · soundcloud · jamendo ·
fs · opus · spectrum · web-ui, plus the all-providers group.
- fs is local files *and* persistent state (D5): the /fs mount, the
content store behind /crabidy and /orphans, bookmarks/captures, queue
persistence, and scan. Without it Capture/SaveQueue answer
Unimplemented and scan says which feature is missing — never a panic.
- opus drops symphonia + symphonia-adapter-libopus, and with them the
bundled libopus C build (no more cmake requirement). It also decides
whether scan indexes .opus at all, so scan never indexes what this
build cannot play. An Ogg-Opus file in an opus-less build reports the
missing feature and is skipped like any undecodable file.
- spectrum drops realfft and the FFT task; clients just never receive a
frame. cbd-tui gains notifications (notify-rust, a D-Bus stack).
- crabidy-server/cbd features print the compiled set, and startup logs
it, so a tailored binary is self-describing.
Not gated, deliberately: [auth]/argon2 (a build ignoring configured
hashes would run open — fail-open security hole), and hls.rs /
spectrum_tap.rs / windowed_http.rs (no dependency of their own, so
gating them buys cfg noise and nothing else).
devenv gains check-features: the curated matrix (defaults, nothing, each
provider alone, each axis dropped, both worked examples, the client
crates) all clippy-clean under -D warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three terminal-UI refinements:
- Esc in navigation clears an active / filter (new ClearSearch action);
Enter keeps the filter and returns to navigation, / re-opens editing.
- Captured rows render a trailing down-arrow at the end of the row
(outside the action brackets), visible while browsing any provider.
- v toggles the frequency spectrum at runtime; the spectrum client-config
value still sets the startup default. The server keeps computing and
streaming the bars regardless.
README and the mdbook (clients/tui.md, store.md) updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stage 1-2 of the crabidy-store dev-flow (architecture/crabidy-store.md,
quality/, plan/): one /crabidy provider replacing queues/bookmarks/
captures, with track tomls linking into a content-addressed store that
de-duplicates by provider id and content hash.
Additive, build stays green:
- proto: Track.provider_item_id + is_captured; LibraryNode.is_captured;
LibraryNodeChild.is_captured (swept all literals).
- fsdy: Playable::Store + PlayableSpec.store, 5-way cardinality,
from_track_store, Client.with_store_root + store resolution.
- crabidy_store.rs: StoreSidecar/ProviderEntry/StoreIndex/CrabidyStore
type + method surface (bodies stubbed for the implement stage).
- supersede bookmarks/captures/capture-deletion docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Toggling mute did nothing: the server logged a FIXME and never touched
the player, and the TUI ignored the Mute stream update. Now the player
engine mutes by zeroing the sink volume and remembering the level to
restore (setting the volume unmutes), ToggleMute drives it and
broadcasts the new state, the TUI shows a Muted marker in the
now-playing pane, and the web client mute button already reflected the
Mute update so it now works too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Instead of a fixed info-block height, derive it from the number of info
lines (4 with a track, 3 without) plus the border, and let the spectrum
take all remaining rows. Both regions now size themselves: the info
block is exactly as tall as it needs, the bars fill everything left.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bars were a single glyph row because the info block grew to fill
the now-playing pane. The info block now takes a fixed height and the
spectrum fills the remaining rows, drawn as full-height columns (full
blocks stacked from the bottom, a partial block for the fractional top
cell) instead of one row of sub-cell glyphs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes so the spectrum shows for real audio: the analyzer now
takes the peak magnitude per band with single-sided (2/N) scaling
instead of the mean (averaging diluted a strong component into the
quiet bins around it, leaving near-zero bars that render as blank
spaces); the now-playing rows use a hard Length(1) so the spectrum row
cannot be squeezed out by the info block; and the server logs when it
starts/stops streaming bars so the live path is diagnosable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A row of frequency bars under the track progress, on by default and
toggleable with the client spectrum config option. Because the audio
plays on the server and clients may be remote, the spectrum is produced
server-side, not captured locally: audio-player taps its own output
into a lock-free ring on the audio thread (one store per sample, no
locks), crabidy-server runs a Hann + realfft over 2048 samples at 20fps,
folds it into log-spaced bars, and broadcasts them as a new SpectrumFrame
on the update stream. The task idles when nothing is playing or no
client is listening. The TUI renders block-glyph bars in the now-playing
pane; the web client renders the same bins as CSS bars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Download captures now write straight into captures/<name>: satisfied
entries are reused, uncapturable tracks are recorded as skipped tomls
(a new fourth playable, marked red in the TUI and skipped by playback
with a bounded pass), and a failed run keeps its progress so capturing
the same name resumes it. The capture RPC replies on acceptance and
streams CaptureProgress over the update stream, rendered as status
lines in the library pane; help and the input overlay warn that
captures are slow. Colored list items switch to a dark foreground
under the focused selection bar so they stay readable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
notify-rust defaults the app-name to the executable file name
(cbd-tui), so notification-daemon rules keyed on app-name break
whenever the binary is renamed or wrapped. Set it explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The player position can overrun a stale or wrong duration (streams,
hand-written track files), and ratatui's LineGauge asserts its ratio
into 0..=1 — the TUI died with "ratio should be between 0 and 1"
mid-playback. Clamp instead, with render regression tests for both the
overrun and the zero-duration case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root causes found and fixed:
- QueueManager could panic and kill the playback task permanently:
is_last_track() underflowed on an empty queue, remove_tracks accepted
pos == len (Vec::remove panic) and corrupted positions when removing
multiple tracks (indices shifted mid-loop), shuffle_behind indexed
out of range on an empty play order, insert_tracks shifted play-order
entries by the queue length instead of the inserted count and then
assert!()ed on the resulting inconsistency, and clear() left
play_order stale. All mutation methods are now guarded, multi-remove
works highest-position-first, and an inconsistent play order is
rebuilt instead of panicking. Regression tests cover these cases.
- The tidal access token was only obtained at startup and never
refreshed, so long-running sessions ended with every track fetch
failing (playback just stopped at the next track boundary). Login
state now lives behind a lock; tokens are refreshed proactively
before expiry (5 min margin) and once reactively on a 401, and all
API responses are status-checked (new ClientError::ApiError) instead
of being fed to the JSON decoder blind. The http client also got a
30s timeout so a hung connection cannot wedge the provider loop.
- (from the rodio rewrite, same bug class) end of stream used to be
detected by string-comparing an io::Error message; any other decode
or network error ended the stream silently without an EndOfStream
message, so playback never advanced. EOS is now a guaranteed
callback with a generation counter.
Plus workspace-wide clippy cleanup (zero warnings), cargo-machete
cleanup, and fmt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old pattern passed a Span in every channel message and entered it
with a guard that was held across await points, which misattributed
events from interleaved tasks. Messages are now {span, command} pairs:
the span is captured automatically at send time (Span::current) and the
consumer instruments the whole handler future with a child span
(playback_command/provider_command with a command name field), so events
are attributed correctly across the queue boundary and all the manual
in_current_span() plumbing is gone.
Also:
- server: EnvFilter with RUST_LOG support (default: own crates at
debug, rest at info), log-crate bridge for symphonia/cpal
- cbd-tui: logs to a file under the state dir (the terminal belongs to
the TUI), EnvFilter, no more println into the alternate screen
- tidaldy: fix misused levels (error->debug), structured fields,
payload dumps moved to trace, login flow at info/warn
- no panic on missing notification daemon in the TUI
- no panic on backwards clock steps in QueueManager
- provider init errors propagate instead of expect()
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tonic 0.9 -> 0.14 (tonic-prost/tonic-prost-build split), prost 0.14
- ratatui 0.20 -> 0.30 (Frame no longer generic, Line instead of Spans),
crossterm 0.29
- rodio 0.17 -> 0.22: replace the custom symphonia decoder with rodio's
built-in decoder, seeking (try_seek) and position tracking (get_pos);
end-of-stream is now signalled via an EmptyCallback source with a
generation counter so a replaced track can never emit a stale EOS
- replace the vendored stream-download crate with the published
stream-download 0.24 (rustls), with a 30s open timeout
- reqwest 0.12->0.13 (rustls/webpki-roots/query features), base64 0.22
Engine API, rand 0.10, flume 0.12, thiserror 2, dirs 6, toml 1
- unify everything under [workspace.dependencies]; drop unused deps
(once_cell, serde_json in server; confique, secrecy in tidaldy)
- devenv: add protobuf (protoc) for prost-build
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>