Added Visualisation engine on rhai

This commit is contained in:
Ultradesu
2026-07-23 18:48:58 +03:00
parent af2542b668
commit 32a038dba6
19 changed files with 2685 additions and 65 deletions
Generated
+99
View File
@@ -50,6 +50,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
@@ -671,6 +673,26 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -916,6 +938,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1552,6 +1580,7 @@ dependencies = [
"lofty",
"music-dht",
"ratatui",
"rhai",
"rodio",
"rusqlite",
"serde",
@@ -3195,6 +3224,15 @@ dependencies = [
"memoffset 0.9.1",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin 0.5.2",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -4327,6 +4365,35 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "rhai"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf"
dependencies = [
"ahash",
"bitflags 2.13.1",
"no-std-compat",
"num-traits",
"once_cell",
"rhai_codegen",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4821,6 +4888,17 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "socket2"
version = "0.4.10"
@@ -4877,6 +4955,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spin"
version = "0.9.9"
@@ -5280,6 +5364,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "thin-vec"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -5362,6 +5452,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.3"
+1
View File
@@ -20,6 +20,7 @@ lofty = "0.22"
# peers (same protocol as furumi-fd).
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
ratatui = "0.30.1"
rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
serde = { version = "1.0.228", features = ["derive"] }
+1
View File
@@ -90,6 +90,7 @@ Common key bindings:
| `+`, `-` | Volume up / down |
| `s` | Toggle shuffle |
| `r` | Cycle repeat mode |
| `Shift-L` | Toggle fullscreen visualizer |
| `x` | Like / unlike |
| `a` | Add track next |
| `Shift-A` | Add track to the end of the queue |
+4 -1
View File
@@ -31,6 +31,7 @@ pub enum Action {
VolumeDown,
ToggleShuffle,
CycleRepeat,
ToggleVisualizer,
ToggleLike,
ToggleTrackSelection,
OpenTrackInfo,
@@ -96,7 +97,8 @@ impl Action {
| Action::VolumeUp
| Action::VolumeDown
| Action::ToggleShuffle
| Action::CycleRepeat => Category::Playback,
| Action::CycleRepeat
| Action::ToggleVisualizer => Category::Playback,
Action::QueueAddNext
| Action::QueueAddLast
| Action::DownloadSelected
@@ -173,6 +175,7 @@ impl Action {
Action::VolumeDown => "Volume down".into(),
Action::ToggleShuffle => "Toggle shuffle".into(),
Action::CycleRepeat => "Cycle repeat mode".into(),
Action::ToggleVisualizer => "Toggle fullscreen visualizer".into(),
Action::ToggleLike => "Like / unlike".into(),
Action::ToggleTrackSelection => "Track line selection".into(),
Action::OpenTrackInfo => "Track info".into(),
+96 -5
View File
@@ -7,7 +7,9 @@ mod popup;
pub mod state;
pub mod update;
use std::path::PathBuf;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
@@ -28,6 +30,7 @@ use state::AppState;
use update::{Effect, update};
const TICK_INTERVAL: Duration = Duration::from_millis(250);
const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50);
/// Handles shared by background tasks; AppState stays pure UI data.
pub struct Runtime {
@@ -40,6 +43,8 @@ pub struct Runtime {
pub fed_resolving: std::sync::Mutex<std::collections::HashSet<i64>>,
/// Caps concurrent artwork loads so they never starve the disk.
pub art_semaphore: Arc<tokio::sync::Semaphore>,
/// The terminal screen was externally disturbed and needs a full repaint.
pub force_redraw: bool,
/// Monotonic sequence for live search; stale responses are dropped.
pub search_seq: Arc<std::sync::atomic::AtomicU64>,
pub player: player::Controller,
@@ -75,6 +80,9 @@ pub async fn run(
status_message: startup_warning,
..AppState::default()
};
if let Err(err) = state.visualizer.load_library() {
state.status_message = Some(format!("visualizations disabled: {err:#}"));
}
let federation = crate::federation::Federation::new(Arc::clone(&library));
state.federation.settings = federation.settings();
@@ -86,6 +94,7 @@ pub async fn run(
fed_status_at: None,
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
force_redraw: false,
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
player: player::spawn(move |event| {
let _ = player_events.send(AppEvent::Player(event));
@@ -108,8 +117,14 @@ pub async fn run(
let mut input = EventStream::new();
let mut tick = tokio::time::interval(TICK_INTERVAL);
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut visual_tick = tokio::time::interval(VISUALIZER_TICK_INTERVAL);
visual_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
if runtime.force_redraw {
terminal.clear()?;
runtime.force_redraw = false;
}
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
tokio::select! {
@@ -121,13 +136,13 @@ pub async fn run(
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
_ = tick.tick() => {
expire_quit_confirmation(&mut state);
if state.player.current.is_some() && !runtime.player_start_pending {
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
state.player.paused = runtime.player.shared.paused();
}
sync_player_shared(&mut state, &runtime);
maybe_prefetch_next(&mut state, &runtime);
push_media_update(&state, &mut runtime, false);
}
_ = visual_tick.tick(), if state.visualizer.active => {
sync_player_shared(&mut state, &runtime);
}
}
if state.should_quit {
@@ -138,6 +153,14 @@ pub async fn run(
}
}
fn sync_player_shared(state: &mut AppState, runtime: &Runtime) {
if state.player.current.is_some() && !runtime.player_start_pending {
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
state.player.paused = runtime.player.shared.paused();
}
state.player.audio_analysis = runtime.player.shared.audio_analysis();
}
const ARTISTS_PREFETCH_MARGIN: usize = 24;
/// How many artist tiles one screen holds right now (grid geometry from the
@@ -522,6 +545,27 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
});
}
}
Effect::OpenVisualizerEditor { path } => match open_visualizer_editor(&path) {
Ok(()) => {
runtime.force_redraw = true;
match state.visualizer.load_library() {
Ok(()) => {
clamp_settings_cursor(state);
state.status_message =
Some(format!("visualization script saved: {}", path.display()));
}
Err(err) => {
state.status_message = Some(format!("visualizations: {err:#}"));
}
}
}
Err(err) => {
runtime.force_redraw = true;
state.status_message = Some(format!("editor failed: {err:#}"));
let _ = state.visualizer.load_library();
clamp_settings_cursor(state);
}
},
Effect::RemoveQueueIndices {
restart_paused,
stop,
@@ -542,6 +586,52 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
}
fn clamp_settings_cursor(state: &mut AppState) {
let last = state::settings_rows(state).len().saturating_sub(1);
state.settings_cursor = state.settings_cursor.min(last);
}
fn open_visualizer_editor(path: &Path) -> Result<()> {
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableBracketedPaste
);
let status = if cfg!(windows) {
Command::new("cmd")
.args(["/C", &format!("{editor} {}", path.display())])
.status()
} else {
Command::new("sh")
.arg("-c")
.arg(format!("{editor} {}", shell_quote(path)))
.status()
};
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableBracketedPaste
);
let _ = crossterm::terminal::enable_raw_mode();
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => anyhow::bail!("editor exited with {status}"),
Err(err) => Err(err.into()),
}
}
fn shell_quote(path: &Path) -> String {
let value = path.to_string_lossy();
format!("'{}'", value.replace('\'', "'\\''"))
}
/// Start playing `queue[queue_pos]`: open the local file in a background
/// task and hand the reader to the audio thread.
fn play_current(state: &mut AppState, runtime: &mut Runtime) {
@@ -578,6 +668,7 @@ fn start_current_audio(
state.player.playing = true;
state.player.paused = paused;
state.player.position_secs = position_secs.max(0.0);
state.player.audio_analysis = player::AudioAnalysisSnapshot::default();
state.player.track_started_at = same_track_started_at.or_else(|| Some(now_epoch_seconds()));
state.player.prefetched_pos = None;
state.status_message = Some(format!("{}{}", track.title, track.artist_line()));
+38 -4
View File
@@ -543,7 +543,7 @@ impl FedInputField {
}
}
/// Rows of the Federation tab, in display order.
/// Rows of the federation block inside Settings, in display order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedRow {
Toggle,
@@ -565,10 +565,40 @@ impl FedRow {
];
}
/// The Federation tab: settings mirror + the latest status snapshot.
/// Rows of the Settings tab, in display order. Federation rows are fixed;
/// visualization script rows are derived from the scripts currently found in
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
Federation(FedRow),
VisualizationClock,
VisualizationScript(usize),
VisualizationNew,
VisualizationEdit,
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = Vec::new();
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::VisualizationClock);
rows.extend(
state
.visualizer
.scripts
.iter()
.enumerate()
.map(|(index, _)| SettingsRow::VisualizationScript(index)),
);
rows.push(SettingsRow::VisualizationNew);
if !state.visualizer.scripts.is_empty() {
rows.push(SettingsRow::VisualizationEdit);
}
rows
}
/// Federation settings mirror + the latest status snapshot for Settings.
#[derive(Debug, Default)]
pub struct FederationTab {
pub cursor: usize,
pub settings: crate::federation::FedSettings,
pub status: Option<crate::federation::FedStatus>,
}
@@ -635,7 +665,7 @@ impl Tab {
Tab::Global => "Global",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Federation => "Federation",
Tab::Federation => "Settings",
Tab::Logs => "Logs",
}
}
@@ -704,6 +734,7 @@ pub struct PlayerBar {
pub playing: bool,
pub paused: bool,
pub position_secs: f64,
pub audio_analysis: crate::player::AudioAnalysisSnapshot,
/// Epoch seconds when the current track started (for history reports).
pub track_started_at: Option<i64>,
/// Queue index already enqueued in the audio thread for gapless play.
@@ -725,6 +756,7 @@ impl Default for PlayerBar {
playing: false,
paused: false,
position_secs: 0.0,
audio_analysis: crate::player::AudioAnalysisSnapshot::default(),
track_started_at: None,
prefetched_pos: None,
original_order: None,
@@ -747,7 +779,9 @@ pub struct AppState {
pub help_visible: bool,
pub pending_keys: Option<String>,
pub status_message: Option<String>,
pub settings_cursor: usize,
pub player: PlayerBar,
pub visualizer: crate::visualizer::VisualizerState,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
+113 -18
View File
@@ -6,7 +6,7 @@ use crate::library::models::TrackItem;
use super::state::{
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
release_display_order, release_rows,
release_display_order, release_rows, settings_rows,
};
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
@@ -43,7 +43,7 @@ pub enum Effect {
restart_paused: Option<bool>,
stop: bool,
},
/// Persist the Federation-tab settings and start/stop the node.
/// Persist the federation settings and start/stop the node.
FedApplySettings,
/// Force an immediate library publish into the DHT.
FedSyncNow,
@@ -59,6 +59,10 @@ pub enum Effect {
FedFetchTrackInfo {
tracks: Vec<(i64, crate::federation::FedTrack)>,
},
/// Temporarily leaves the TUI and opens a visualization script in $EDITOR.
OpenVisualizerEditor {
path: std::path::PathBuf,
},
}
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
@@ -154,6 +158,20 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
state.player.repeat = state.player.repeat.next();
Some(Effect::SetOptions)
}
Action::ToggleVisualizer => {
if state.visualizer.active {
state.visualizer.close();
} else if state.player.current.is_some() {
state.visualizer.open();
state.help_visible = false;
state.popup = None;
state.cmdline.active = false;
state.pending_keys = None;
} else {
state.status_message = Some("nothing playing — start a track first".into());
}
None
}
Action::MoveUp => {
move_selection(state, 0, -1);
None
@@ -219,6 +237,10 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
Action::Select => select_current(state),
Action::Back if state.visualizer.active => {
state.visualizer.close();
None
}
Action::Back if state.track_selection.is_active() => {
state.track_selection.clear();
state.status_message = Some("selection cleared".into());
@@ -1460,9 +1482,8 @@ fn page_step(state: &AppState) -> isize {
fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
if state.active_tab == Tab::Federation {
if dy != 0 {
let last = super::state::FedRow::ALL.len() as isize - 1;
state.federation.cursor =
(state.federation.cursor as isize + dy).clamp(0, last) as usize;
let last = settings_rows(state).len() as isize - 1;
state.settings_cursor = (state.settings_cursor as isize + dy).clamp(0, last) as usize;
}
return;
}
@@ -1719,7 +1740,7 @@ fn current_view_len(state: &AppState) -> usize {
return state.player.queue.len();
}
if state.active_tab == Tab::Federation {
return super::state::FedRow::ALL.len();
return settings_rows(state).len();
}
match state.global.stack.last() {
None => state.global.artists.len(),
@@ -1766,8 +1787,8 @@ fn jump_selection(state: &mut AppState, first: bool) {
return;
}
if state.active_tab == Tab::Federation {
let last = super::state::FedRow::ALL.len() - 1;
state.federation.cursor = if first { 0 } else { last };
let last = settings_rows(state).len().saturating_sub(1);
state.settings_cursor = if first { 0 } else { last };
return;
}
if state.active_tab == Tab::Logs {
@@ -2267,12 +2288,12 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
names
}
/// Enter on the Federation tab: toggle switches, open text inputs, run
/// Enter on Settings: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup};
match FedRow::ALL.get(state.federation.cursor)? {
FedRow::Toggle => {
use super::state::{FedInputField, FedRow, Popup, SettingsRow};
match settings_rows(state).get(state.settings_cursor).copied()? {
SettingsRow::Federation(FedRow::Toggle) => {
let settings = &mut state.federation.settings;
if !settings.enabled && settings.network_id.trim().is_empty() {
state.popup = Some(Popup::FedInput {
@@ -2284,7 +2305,7 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
settings.enabled = !settings.enabled;
Some(Effect::FedApplySettings)
}
FedRow::NetworkId => {
SettingsRow::Federation(FedRow::NetworkId) => {
state.popup = Some(Popup::FedInput {
field: FedInputField::NetworkId,
input: crate::app::input::LineEdit::new(
@@ -2293,19 +2314,64 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
});
None
}
FedRow::SaveOnListen => {
SettingsRow::Federation(FedRow::SaveOnListen) => {
state.federation.settings.save_on_listen = !state.federation.settings.save_on_listen;
Some(Effect::FedApplySettings)
}
FedRow::SyncNow => Some(Effect::FedSyncNow),
FedRow::ShowTicket => Some(Effect::FedShowTicket),
FedRow::Connect => {
SettingsRow::Federation(FedRow::SyncNow) => Some(Effect::FedSyncNow),
SettingsRow::Federation(FedRow::ShowTicket) => Some(Effect::FedShowTicket),
SettingsRow::Federation(FedRow::Connect) => {
state.popup = Some(Popup::FedInput {
field: FedInputField::ConnectTicket,
input: crate::app::input::LineEdit::default(),
});
None
}
SettingsRow::VisualizationClock => {
match state.visualizer.toggle_clock() {
Ok(()) => {
state.status_message = Some(format!(
"visualization clock {}",
if state.visualizer.config.show_clock {
"on"
} else {
"off"
}
));
}
Err(err) => state.status_message = Some(format!("visualization settings: {err:#}")),
}
None
}
SettingsRow::VisualizationScript(index) => {
match state.visualizer.select_script(index) {
Ok(()) => {
let name = state
.visualizer
.scripts
.get(index)
.map(|script| script.name.clone())
.unwrap_or_else(|| "visualization".to_string());
state.status_message = Some(format!("visualization: {name}"));
}
Err(err) => state.status_message = Some(format!("visualization settings: {err:#}")),
}
None
}
SettingsRow::VisualizationNew => match state.visualizer.create_script() {
Ok(path) => Some(Effect::OpenVisualizerEditor { path }),
Err(err) => {
state.status_message = Some(format!("visualization script: {err:#}"));
None
}
},
SettingsRow::VisualizationEdit => match state.visualizer.selected_script_path() {
Some(path) => Some(Effect::OpenVisualizerEditor { path }),
None => {
state.status_message = Some("no visualization script selected".into());
None
}
},
}
}
@@ -2397,7 +2463,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) {
state.fed_artist_view = None;
}
Tab::Playlists => state.playlists.opened = None,
Tab::Federation => state.federation.cursor = 0,
Tab::Federation => state.settings_cursor = 0,
Tab::Logs => {
state.logs.follow = true;
state.logs.selected_seq = None;
@@ -2792,6 +2858,35 @@ mod tests {
}
}
#[test]
fn visualizer_requires_current_track() {
let mut state = AppState::default();
assert_eq!(update(&mut state, Action::ToggleVisualizer), None);
assert!(!state.visualizer.active);
assert_eq!(
state.status_message.as_deref(),
Some("nothing playing — start a track first")
);
}
#[test]
fn visualizer_toggles_and_back_closes_it() {
let mut state = AppState::default();
state.player.current = Some(test_track(7));
state.help_visible = true;
assert_eq!(update(&mut state, Action::ToggleVisualizer), None);
assert!(state.visualizer.active);
assert!(state.visualizer.started_at.is_some());
assert!(!state.help_visible);
assert_eq!(update(&mut state, Action::Back), None);
assert!(!state.visualizer.active);
assert!(state.visualizer.started_at.is_none());
}
#[test]
fn add_to_playlist_from_release_carries_selected_track() {
use crate::app::state::{PlaylistAddTarget, Popup};
+4
View File
@@ -194,6 +194,10 @@ command = "ToggleShuffle"
key_sequence = "r"
command = "CycleRepeat"
[[keymaps]]
key_sequence = "shift-l"
command = "ToggleVisualizer"
[[keymaps]]
key_sequence = "x"
command = "ToggleLike"
+32 -1
View File
@@ -20,6 +20,7 @@ pub enum KeyContext {
Search,
Playlists,
Queue,
#[serde(rename = "settings", alias = "federation")]
Federation,
Logs,
}
@@ -32,7 +33,7 @@ impl KeyContext {
KeyContext::Search => "search",
KeyContext::Playlists => "playlists",
KeyContext::Queue => "queue",
KeyContext::Federation => "federation",
KeyContext::Federation => "settings",
KeyContext::Logs => "logs",
}
}
@@ -459,6 +460,36 @@ mod tests {
);
}
#[test]
fn default_visualizer_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let shift_l = KeyCombination::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(shift_l, KeyContext::Library),
KeyResolution::Action(Action::ToggleVisualizer)
);
}
#[test]
fn settings_context_keeps_federation_alias() {
let settings = parse_bindings(
r#"
[[keymaps]]
key_sequence = "z"
command = "ToggleHelp"
context = "settings"
[[keymaps]]
key_sequence = "x"
command = "ToggleHelp"
context = "federation"
"#,
)
.unwrap();
assert_eq!(settings[0].context, KeyContext::Federation);
assert_eq!(settings[1].context, KeyContext::Federation);
}
#[test]
fn user_binding_overrides_default() {
let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
+10 -1
View File
@@ -265,6 +265,13 @@ impl Federation {
.as_ref()
.map(|d| d.data_dir().join("federation-media"))
.unwrap_or_else(|| PathBuf::from("federation-media"));
let initial_error = [&data_dir, &cache_dir, &media_dir]
.into_iter()
.find_map(|dir| {
std::fs::create_dir_all(dir)
.err()
.map(|err| format!("cannot create {}: {err}", dir.display()))
});
Arc::new(Self {
library,
data_dir,
@@ -274,7 +281,7 @@ impl Federation {
settings: std::sync::Mutex::new(load_settings()),
running: tokio::sync::Mutex::new(None),
last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(initial_error),
})
}
@@ -330,6 +337,8 @@ impl Federation {
}
stop_running(guard.take()).await;
}
std::fs::create_dir_all(&self.data_dir)
.with_context(|| format!("creating {}", self.data_dir.display()))?;
let config = MusicDhtConfig::builder()
.data_dir(&self.data_dir)
+1
View File
@@ -7,6 +7,7 @@ mod media;
mod player;
mod share;
mod ui;
mod visualizer;
use std::io;
+455
View File
@@ -0,0 +1,455 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicI16, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use rodio::source::SeekError;
use rodio::{ChannelCount, Sample, SampleRate, Source};
const LEVEL_SCALE: f32 = 1_000_000.0;
const SCOPE_SAMPLES: usize = 256;
const SCOPE_SAMPLE_SCALE: f32 = i16::MAX as f32;
const TARGET_ANALYSIS_HZ: f32 = 30.0;
const TARGET_SCOPE_HZ: f32 = 240.0;
#[derive(Debug, Clone, Default)]
pub struct AudioAnalysisSnapshot {
pub sequence: u64,
pub energy: f64,
pub bass: f64,
pub mid: f64,
pub treble: f64,
pub beat: f64,
pub scope: Vec<f64>,
}
#[derive(Debug)]
pub struct AnalyzerShared {
sequence: AtomicU64,
energy: AtomicU32,
bass: AtomicU32,
mid: AtomicU32,
treble: AtomicU32,
beat: AtomicU32,
scope_write: AtomicUsize,
scope: Box<[AtomicI16]>,
}
impl Default for AnalyzerShared {
fn default() -> Self {
Self {
sequence: AtomicU64::new(0),
energy: AtomicU32::new(0),
bass: AtomicU32::new(0),
mid: AtomicU32::new(0),
treble: AtomicU32::new(0),
beat: AtomicU32::new(0),
scope_write: AtomicUsize::new(0),
scope: (0..SCOPE_SAMPLES)
.map(|_| AtomicI16::new(0))
.collect::<Vec<_>>()
.into_boxed_slice(),
}
}
}
impl AnalyzerShared {
pub fn clear(&self) {
self.energy.store(0, Ordering::Relaxed);
self.bass.store(0, Ordering::Relaxed);
self.mid.store(0, Ordering::Relaxed);
self.treble.store(0, Ordering::Relaxed);
self.beat.store(0, Ordering::Relaxed);
self.scope_write.store(0, Ordering::Relaxed);
for sample in self.scope.iter() {
sample.store(0, Ordering::Relaxed);
}
self.sequence.store(0, Ordering::Relaxed);
}
pub fn snapshot(&self) -> AudioAnalysisSnapshot {
let write = self.scope_write.load(Ordering::Relaxed);
let len = self.scope.len().max(1);
let scope = (0..self.scope.len())
.map(|offset| {
let index = (write + offset) % len;
f64::from(self.scope[index].load(Ordering::Relaxed)) / f64::from(i16::MAX)
})
.collect();
AudioAnalysisSnapshot {
sequence: self.sequence.load(Ordering::Relaxed),
energy: load_norm(&self.energy),
bass: load_norm(&self.bass),
mid: load_norm(&self.mid),
treble: load_norm(&self.treble),
beat: load_norm(&self.beat),
scope,
}
}
fn store_levels(&self, energy: f32, bass: f32, mid: f32, treble: f32, beat: f32) {
store_norm(&self.energy, energy);
store_norm(&self.bass, bass);
store_norm(&self.mid, mid);
store_norm(&self.treble, treble);
store_norm(&self.beat, beat);
self.sequence.fetch_add(1, Ordering::Relaxed);
}
fn push_scope(&self, sample: f32) {
let index = self.scope_write.fetch_add(1, Ordering::Relaxed) % self.scope.len();
let value = (sample.clamp(-1.0, 1.0) * SCOPE_SAMPLE_SCALE).round() as i16;
self.scope[index].store(value, Ordering::Relaxed);
}
}
pub struct AnalyzedSource<S> {
input: S,
shared: Arc<AnalyzerShared>,
state: AnalyzerState,
}
impl<S> AnalyzedSource<S>
where
S: Source,
{
pub fn new(input: S, shared: Arc<AnalyzerShared>) -> Self {
let channels = input.channels();
let sample_rate = input.sample_rate();
Self {
input,
shared,
state: AnalyzerState::new(channels, sample_rate),
}
}
}
impl<S> Iterator for AnalyzedSource<S>
where
S: Source,
{
type Item = Sample;
fn next(&mut self) -> Option<Self::Item> {
let sample = self.input.next()?;
self.state
.ensure_format(self.input.channels(), self.input.sample_rate());
self.state.accept_sample(sample as f32, &self.shared);
Some(sample)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.input.size_hint()
}
}
impl<S> Source for AnalyzedSource<S>
where
S: Source,
{
fn current_span_len(&self) -> Option<usize> {
self.input.current_span_len()
}
fn channels(&self) -> ChannelCount {
self.input.channels()
}
fn sample_rate(&self) -> SampleRate {
self.input.sample_rate()
}
fn total_duration(&self) -> Option<Duration> {
self.input.total_duration()
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
let result = self.input.try_seek(pos);
if result.is_ok() {
self.shared.clear();
self.state.reset_filters();
}
result
}
}
#[derive(Debug)]
struct AnalyzerState {
channels: usize,
sample_rate: f32,
bass_alpha: f32,
mid_alpha: f32,
channel_index: usize,
frame_sum: f32,
frame_count: usize,
window_frames: usize,
scope_counter: usize,
scope_stride: usize,
low_bass: f32,
low_mid: f32,
full_sq: f32,
bass_sq: f32,
mid_sq: f32,
treble_sq: f32,
slow_energy: f32,
smooth_energy: f32,
smooth_bass: f32,
smooth_mid: f32,
smooth_treble: f32,
smooth_beat: f32,
}
impl AnalyzerState {
fn new(channels: ChannelCount, sample_rate: SampleRate) -> Self {
let mut state = Self {
channels: channels.get() as usize,
sample_rate: sample_rate.get() as f32,
bass_alpha: 0.0,
mid_alpha: 0.0,
channel_index: 0,
frame_sum: 0.0,
frame_count: 0,
window_frames: 0,
scope_counter: 0,
scope_stride: 0,
low_bass: 0.0,
low_mid: 0.0,
full_sq: 0.0,
bass_sq: 0.0,
mid_sq: 0.0,
treble_sq: 0.0,
slow_energy: 0.0,
smooth_energy: 0.0,
smooth_bass: 0.0,
smooth_mid: 0.0,
smooth_treble: 0.0,
smooth_beat: 0.0,
};
state.configure();
state
}
fn ensure_format(&mut self, channels: ChannelCount, sample_rate: SampleRate) {
let channels = channels.get() as usize;
let sample_rate = sample_rate.get() as f32;
if self.channels != channels || (self.sample_rate - sample_rate).abs() >= 1.0 {
self.channels = channels;
self.sample_rate = sample_rate;
self.configure();
self.reset_filters();
}
}
fn configure(&mut self) {
self.channels = self.channels.max(1);
self.sample_rate = self.sample_rate.max(1.0);
self.bass_alpha = lowpass_alpha(180.0, self.sample_rate);
self.mid_alpha = lowpass_alpha(2_400.0, self.sample_rate);
self.window_frames = (self.sample_rate / TARGET_ANALYSIS_HZ).round().max(256.0) as usize;
self.scope_stride = (self.sample_rate / TARGET_SCOPE_HZ).round().max(1.0) as usize;
}
fn reset_filters(&mut self) {
self.channel_index = 0;
self.frame_sum = 0.0;
self.frame_count = 0;
self.scope_counter = 0;
self.low_bass = 0.0;
self.low_mid = 0.0;
self.full_sq = 0.0;
self.bass_sq = 0.0;
self.mid_sq = 0.0;
self.treble_sq = 0.0;
self.slow_energy = 0.0;
self.smooth_energy = 0.0;
self.smooth_bass = 0.0;
self.smooth_mid = 0.0;
self.smooth_treble = 0.0;
self.smooth_beat = 0.0;
}
fn accept_sample(&mut self, sample: f32, shared: &AnalyzerShared) {
let sample = if sample.is_finite() {
sample.clamp(-1.5, 1.5)
} else {
0.0
};
self.frame_sum += sample;
self.channel_index += 1;
if self.channel_index < self.channels {
return;
}
let mono = self.frame_sum / self.channels as f32;
self.channel_index = 0;
self.frame_sum = 0.0;
self.accept_frame(mono, shared);
}
fn accept_frame(&mut self, sample: f32, shared: &AnalyzerShared) {
self.low_bass += self.bass_alpha * (sample - self.low_bass);
self.low_mid += self.mid_alpha * (sample - self.low_mid);
let bass = self.low_bass;
let mid = self.low_mid - self.low_bass;
let treble = sample - self.low_mid;
self.full_sq += sample * sample;
self.bass_sq += bass * bass;
self.mid_sq += mid * mid;
self.treble_sq += treble * treble;
self.frame_count += 1;
self.scope_counter += 1;
if self.scope_counter >= self.scope_stride {
self.scope_counter = 0;
shared.push_scope(sample);
}
if self.frame_count >= self.window_frames {
self.publish_window(shared);
}
}
fn publish_window(&mut self, shared: &AnalyzerShared) {
let frames = self.frame_count.max(1) as f32;
let energy = compress_rms((self.full_sq / frames).sqrt(), 7.5);
let bass = compress_rms((self.bass_sq / frames).sqrt(), 11.0);
let mid = compress_rms((self.mid_sq / frames).sqrt(), 15.0);
let treble = compress_rms((self.treble_sq / frames).sqrt(), 22.0);
if self.slow_energy == 0.0 {
self.slow_energy = energy;
} else {
self.slow_energy = self.slow_energy * 0.94 + energy * 0.06;
}
let beat_raw = ((energy - self.slow_energy * 1.18) * 5.5).clamp(0.0, 1.0);
self.smooth_energy = smooth_level(self.smooth_energy, energy, 0.35, 0.82);
self.smooth_bass = smooth_level(self.smooth_bass, bass, 0.30, 0.80);
self.smooth_mid = smooth_level(self.smooth_mid, mid, 0.35, 0.82);
self.smooth_treble = smooth_level(self.smooth_treble, treble, 0.28, 0.76);
self.smooth_beat = smooth_level(self.smooth_beat, beat_raw, 0.18, 0.70);
shared.store_levels(
self.smooth_energy,
self.smooth_bass,
self.smooth_mid,
self.smooth_treble,
self.smooth_beat,
);
self.full_sq = 0.0;
self.bass_sq = 0.0;
self.mid_sq = 0.0;
self.treble_sq = 0.0;
self.frame_count = 0;
}
}
fn lowpass_alpha(cutoff_hz: f32, sample_rate: f32) -> f32 {
1.0 - (-std::f32::consts::TAU * cutoff_hz / sample_rate.max(1.0)).exp()
}
fn compress_rms(rms: f32, scale: f32) -> f32 {
(1.0 - (-rms.max(0.0) * scale).exp()).clamp(0.0, 1.0)
}
fn smooth_level(previous: f32, next: f32, attack: f32, release: f32) -> f32 {
let keep = if next > previous { attack } else { release };
previous * keep + next * (1.0 - keep)
}
fn store_norm(target: &AtomicU32, value: f32) {
target.store(
(value.clamp(0.0, 1.0) * LEVEL_SCALE).round() as u32,
Ordering::Relaxed,
);
}
fn load_norm(source: &AtomicU32) -> f64 {
f64::from(source.load(Ordering::Relaxed)) / f64::from(LEVEL_SCALE)
}
#[cfg(test)]
mod tests {
use super::*;
struct TestSource {
samples: Vec<Sample>,
cursor: usize,
channels: ChannelCount,
sample_rate: SampleRate,
}
impl TestSource {
fn sine(frames: usize, channels: u16, sample_rate: u32, frequency: f32) -> Self {
let channels_count = channels.max(1);
let mut samples = Vec::with_capacity(frames * usize::from(channels_count));
for frame in 0..frames {
let time = frame as f32 / sample_rate as f32;
let sample = (std::f32::consts::TAU * frequency * time).sin() * 0.5;
for _ in 0..channels_count {
samples.push(sample as Sample);
}
}
Self {
samples,
cursor: 0,
channels: ChannelCount::new(channels_count).unwrap(),
sample_rate: SampleRate::new(sample_rate).unwrap(),
}
}
}
impl Iterator for TestSource {
type Item = Sample;
fn next(&mut self) -> Option<Self::Item> {
let sample = self.samples.get(self.cursor).copied()?;
self.cursor += 1;
Some(sample)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.samples.len().saturating_sub(self.cursor);
(remaining, Some(remaining))
}
}
impl Source for TestSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.samples.len().saturating_sub(self.cursor))
}
fn channels(&self) -> ChannelCount {
self.channels
}
fn sample_rate(&self) -> SampleRate {
self.sample_rate
}
fn total_duration(&self) -> Option<Duration> {
let frames = self.samples.len() / usize::from(self.channels.get());
Some(Duration::from_secs_f64(
frames as f64 / f64::from(self.sample_rate.get()),
))
}
}
#[test]
fn analyzed_source_publishes_levels_and_scope() {
let shared = Arc::new(AnalyzerShared::default());
let source = TestSource::sine(4_096, 2, 48_000, 110.0);
let analyzed = AnalyzedSource::new(source, Arc::clone(&shared));
for _ in analyzed {}
let snapshot = shared.snapshot();
assert!(snapshot.sequence > 0);
assert!(snapshot.energy > 0.0);
assert!(snapshot.bass > 0.0);
assert!(snapshot.scope.iter().any(|sample| sample.abs() > 0.001));
}
}
+22 -3
View File
@@ -4,6 +4,8 @@
//! through the callback given to `spawn` — this module knows nothing about
//! the UI or app state.
mod analyzer;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
@@ -11,6 +13,8 @@ use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
pub use analyzer::AudioAnalysisSnapshot;
/// Local audio files are read straight from disk.
pub type TrackReader = std::io::BufReader<std::fs::File>;
@@ -58,6 +62,7 @@ enum Command {
pub struct Shared {
position_ms: AtomicU64,
paused: AtomicBool,
analysis: Arc<analyzer::AnalyzerShared>,
}
impl Shared {
@@ -68,6 +73,10 @@ impl Shared {
pub fn paused(&self) -> bool {
self.paused.load(Ordering::Relaxed)
}
pub fn audio_analysis(&self) -> AudioAnalysisSnapshot {
self.analysis.snapshot()
}
}
#[derive(Clone)]
@@ -140,7 +149,7 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
Ok(command) => {
// Commands change the source queue legitimately; resync the
// length so the next tick doesn't read it as a track ending.
handle(command, &mut output, &mut track_loaded, &on_event);
handle(command, &shared, &mut output, &mut track_loaded, &on_event);
last_len = output.as_ref().map_or(0, |out| out.player.len());
}
Err(RecvTimeoutError::Timeout) => {
@@ -170,6 +179,7 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
fn handle(
command: Command,
shared: &Arc<Shared>,
output: &mut Option<Output>,
track_loaded: &mut bool,
on_event: &impl Fn(PlayerEvent),
@@ -208,9 +218,13 @@ fn handle(
}
match builder.build() {
Ok(decoder) => {
shared.analysis.clear();
out.player.stop();
out.player.set_volume(volume);
out.player.append(decoder);
out.player.append(analyzer::AnalyzedSource::new(
decoder,
Arc::clone(&shared.analysis),
));
out.player.play();
*track_loaded = true;
on_event(PlayerEvent::Started);
@@ -232,7 +246,10 @@ fn handle(
builder = builder.with_byte_len(len);
}
match builder.build() {
Ok(decoder) => out.player.append(decoder),
Ok(decoder) => out.player.append(analyzer::AnalyzedSource::new(
decoder,
Arc::clone(&shared.analysis),
)),
Err(err) => {
on_event(PlayerEvent::Failed(format!(
"cannot decode next track: {err}"
@@ -254,6 +271,7 @@ fn handle(
if let Some(out) = output.take() {
out.player.stop();
}
shared.analysis.clear();
*track_loaded = false;
}
Command::Seek(position) => {
@@ -262,6 +280,7 @@ fn handle(
{
tracing::warn!(%err, "seek failed");
}
shared.analysis.clear();
}
Command::SetVolume(volume) => {
if let Some(out) = output {
+1 -1
View File
@@ -197,7 +197,7 @@ mod tests {
.expect("valid link");
assert_eq!(link.label.as_deref(), Some("Артист"));
let link = parse_frid_link(
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
)
.expect("valid link");
assert_eq!(link.label, None);
+142 -31
View File
@@ -1,4 +1,4 @@
//! The Federation tab: settings rows on top, a live status block below.
//! The Settings tab: federation settings, visualization scripts and status.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
@@ -10,23 +10,32 @@ use crate::app::state::{AppState, FedRow};
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::bordered()
.title(" Federation ")
.title(" Settings ")
.title_style(theme::header())
.border_style(theme::dim());
let inner = block.inner(area);
frame.render_widget(block, area);
let rows_height = FedRow::ALL.len() as u16;
let rows_height = (FedRow::ALL.len() + state.visualizer.scripts.len() + 6) as u16;
let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height),
Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
draw_settings_rows(frame, rows_area, state);
draw_status(frame, status_area, state);
}
fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
let settings = &state.federation.settings;
let on_off = |on: bool| if on { "on" } else { "off" };
for (index, row) in FedRow::ALL.iter().enumerate() {
let mut y = area.y;
let mut cursor = 0usize;
draw_section(frame, area, &mut y, "Federation");
for row in FedRow::ALL {
let (label, value) = match row {
FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()),
FedRow::NetworkId => (
@@ -45,34 +54,136 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
FedRow::ShowTicket => ("Show my connection ticket", "".to_string()),
FedRow::Connect => ("Connect to a peer by ticket…", "".to_string()),
};
let selected = index == state.federation.cursor;
let rect = Rect {
x: rows_area.x,
y: rows_area.y + index as u16,
width: rows_area.width,
height: 1,
};
if rect.y >= rows_area.y + rows_area.height {
break;
}
let marker = if selected { "" } else { " " };
let label_width = 48usize;
let line = Line::from(vec![
Span::styled(marker, theme::accent()),
Span::styled(
format!("{label:<label_width$}"),
if selected {
theme::accent()
} else {
ratatui::style::Style::default()
},
),
Span::styled(value, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), rect);
draw_row(
frame,
area,
&mut y,
cursor,
state.settings_cursor,
label,
value,
);
cursor += 1;
}
draw_status(frame, status_area, state);
y = y.saturating_add(1);
draw_section(frame, area, &mut y, "Visualizations");
draw_row(
frame,
area,
&mut y,
cursor,
state.settings_cursor,
"Show clock",
if state.visualizer.config.show_clock {
"[x]".to_string()
} else {
"[ ]".to_string()
},
);
cursor += 1;
for (index, script) in state.visualizer.scripts.iter().enumerate() {
let selected_script = state
.visualizer
.selected_script_index()
.is_some_and(|selected| selected == index);
let label = if selected_script {
format!("* {}", script.name)
} else {
format!(" {}", script.name)
};
let value = script
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
draw_row(
frame,
area,
&mut y,
cursor,
state.settings_cursor,
&label,
value,
);
cursor += 1;
}
draw_row(
frame,
area,
&mut y,
cursor,
state.settings_cursor,
"+ New visualization script",
"".to_string(),
);
cursor += 1;
if state.visualizer.selected_script().is_some() {
draw_row(
frame,
area,
&mut y,
cursor,
state.settings_cursor,
"Edit selected visualization",
"".to_string(),
);
}
}
fn draw_section(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str) {
if *y >= area.y + area.height {
return;
}
let rect = Rect {
x: area.x,
y: *y,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(Line::styled(title, theme::header())), rect);
*y = (*y).saturating_add(1);
}
fn draw_row(
frame: &mut Frame,
area: Rect,
y: &mut u16,
row_index: usize,
cursor: usize,
label: &str,
value: String,
) {
if *y >= area.y + area.height {
return;
}
let selected = row_index == cursor;
let rect = Rect {
x: area.x,
y: *y,
width: area.width,
height: 1,
};
let marker = if selected { "" } else { " " };
let label_width = 48usize;
let line = Line::from(vec![
Span::styled(marker, theme::accent()),
Span::styled(
format!("{label:<label_width$}"),
if selected {
theme::accent()
} else {
ratatui::style::Style::default()
},
),
Span::styled(value, theme::dim()),
]);
frame.render_widget(Paragraph::new(line), rect);
*y = (*y).saturating_add(1);
}
fn status_line(label: &str, value: String) -> Line<'static> {
+5
View File
@@ -17,6 +17,11 @@ use crate::app::state::{AppState, Tab, TrackSelectionScope};
use crate::config::keymap::Keymap;
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.visualizer.active {
crate::visualizer::draw(frame, state);
return;
}
let [tabs_area, main_area, status_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
+393
View File
@@ -0,0 +1,393 @@
// name: Pulsing sphere
// protocol: furumi-visualizer-v2
// bundle-version: 5
//
// A second bundled example with a very different structure from
// scope_spectrum.rhai: instead of a waveform-first visual, it renders a
// hollow terminal "sphere" whose radius, halo and palette respond to audio.
//
// Script entry point:
// fn render(input) -> Array<Map>
//
// Input map fields supplied by Furumi:
// width, height terminal size in cells
// time, position, progress animation time and playback progress
// volume, paused player state
// energy, bass, mid, treble smoothed audio bands in the 0.0..1.0 range
// beat transient pulse estimate in the 0.0..1.0 range
// seed stable per-track number in the 0.0..1.0 range
// samples recent mono waveform samples in the -1.0..1.0 range
// show_clock, clock user setting and formatted clock text
// track_title, track_artist metadata strings
//
// Useful numeric helpers available to scripts:
// to_int, to_float Rhai conversions
// sin, cos, tan, sqrt, abs, pow
// Scripts cannot import modules; keep every visualization self-contained.
//
// Draw command maps returned from render(input):
// #{ op: "clear", bg: 0x000000 }
// #{ op: "cell", x: 10, y: 4, ch: "*", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "hline", x: 0, y: 4, w: 20, ch: "-", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "vline", x: 10, y: 0, h: 12, ch: "|", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "rect", x: 10, y: 6, w: 2, h: 4, ch: "#", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "trace", x: 0, ys: [4, 5, 3], ch: "*", line_ch: "|", fg: 0xffffff, line_fg: 0x558888, bg: 0x000000 }
// #{ op: "text", x: 2, y: 1, text: "hello", fg: 0xffffff, bg: 0x000000 }
//
// This script intentionally uses every command type. The sphere itself stays
// hollow: trace commands draw its spectral outline and orbit, hline draws
// latitudes, rect/vline draw the small energy meter, cells create stars and
// burst nodes, and labels use text.
fn clamp(value, low, high) {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
fn min(left, right) {
if left < right { left } else { right }
}
fn max(left, right) {
if left > right { left } else { right }
}
fn rgb(r, g, b) {
let rr = to_int(clamp(r, 0, 255));
let gg = to_int(clamp(g, 0, 255));
let bb = to_int(clamp(b, 0, 255));
rr * 65536 + gg * 256 + bb
}
fn lerp(left, right, mix) {
left * (1.0 - mix) + right * mix
}
fn sample_at(samples, nx) {
let len = samples.len();
if len <= 0 {
0.0
} else {
let pos = clamp(nx, 0.0, 1.0) * (len - 1);
let left = to_int(pos);
let right = if left + 1 < len { left + 1 } else { left };
let mix = pos - to_float(left);
lerp(samples[left], samples[right], mix)
}
}
fn cell(x, y, ch, fg, bg) {
#{ op: "cell", x: x, y: y, ch: ch, fg: fg, bg: bg }
}
fn hline(x, y, w, ch, fg, bg) {
#{ op: "hline", x: x, y: y, w: w, ch: ch, fg: fg, bg: bg }
}
fn vline(x, y, h, ch, fg, bg) {
#{ op: "vline", x: x, y: y, h: h, ch: ch, fg: fg, bg: bg }
}
fn rect(x, y, w, h, ch, fg, bg) {
#{ op: "rect", x: x, y: y, w: w, h: h, ch: ch, fg: fg, bg: bg }
}
fn trace(x, ys, ch, line_ch, fg, line_fg, bg) {
#{ op: "trace", x: x, ys: ys, ch: ch, line_ch: line_ch, fg: fg, line_fg: line_fg, bg: bg }
}
fn text(x, y, value, fg, bg) {
#{ op: "text", x: x, y: y, text: value, fg: fg, bg: bg }
}
// A time-varying RGB palette. "shade" is usually 0.0..1.0; larger values are
// allowed and clamped by rgb(). Audio bands shift the hue without Rust knowing
// anything about the visual.
fn palette(input, shade, phase) {
let t = input.time + input.seed * 8.0 + phase;
let bass_push = input.bass * 70.0;
let treble_push = input.treble * 65.0;
rgb(
50 + shade * 130 + sin(t * 0.90) * 55 + bass_push,
70 + shade * 115 + sin(t * 0.63 + 2.1) * 55 + input.mid * 55,
95 + shade * 145 + cos(t * 0.72 + 0.7) * 55 + treble_push
)
}
fn wrap01(value) {
let out = value;
while out < 0.0 {
out += 1.0;
}
while out > 1.0 {
out -= 1.0;
}
out
}
fn spectral_band(input, phase) {
if phase < 0.34 {
input.bass
} else if phase < 0.68 {
input.mid
} else {
input.treble
}
}
// Treat the waveform as if it was wrapped around the sphere. The returned
// value is a radial spike amount: low frequencies push broad parts of the
// contour, raw samples add sharp teeth, and beat makes the edge jump outward.
fn edge_spectrum(input, phase) {
let p = wrap01(phase);
let wave = abs(sample_at(input.samples, p));
let neighbor = abs(sample_at(input.samples, wrap01(p + 0.021)));
let band = spectral_band(input, p);
let flutter = abs(sin(input.time * (5.0 + p * 7.0) + p * 38.0 + input.seed * 9.0));
let fine = abs(cos(input.time * (8.0 + p * 4.0) - p * 71.0));
clamp(
wave * (0.62 + input.energy * 0.36)
+ neighbor * 0.24
+ band * 0.46
+ input.beat * (0.30 + flutter * 0.64)
+ fine * input.energy * 0.18,
0.0,
1.55
)
}
fn push_starfield(cmds, input, bg) {
let stars = 34;
let index = 0;
while index < stars {
let x = (index * 37 + to_int(input.seed * 1000.0)) % max(input.width, 1);
let y = (index * 17 + to_int(input.time * 2.0)) % max(input.height, 1);
let speed = 0.7 + to_float(index % 5) * 0.15;
let twinkle = abs(sin(input.time * speed + to_float(index)));
let fg = rgb(
45 + twinkle * 120 + input.treble * 80,
75 + twinkle * 120,
105 + twinkle * 130
);
let ch = if twinkle + input.beat > 1.25 { "*" } else { "." };
cmds.push(cell(x, y, ch, fg, bg));
index += 1;
}
cmds
}
fn push_orbit(cmds, input, cx, cy, rx, ry, bg) {
let width = max(rx * 2 + 9, 3);
let start_x = max(cx - width / 2, 0);
let max_w = input.width - start_x;
let actual_w = min(width, max_w);
let back = [];
let front = [];
let i = 0;
while i < actual_w {
let nx = to_float(i) / to_float(max(actual_w - 1, 1));
let angle = nx * 6.28318 + input.time * 0.85;
let sample = sample_at(input.samples, nx);
let wobble = (sin(angle + input.bass * 2.0) + sample * 0.28)
* to_float(max(ry, 1))
* (0.28 + input.energy * 0.10);
let y_front = to_int(clamp(
to_float(cy) + wobble + input.beat * 1.5,
0.0,
to_float(input.height - 1)
));
let y_back = to_int(clamp(
to_float(cy) - wobble - input.beat * 1.5,
0.0,
to_float(input.height - 1)
));
front.push(y_front);
back.push(y_back);
i += 1;
}
let back_fg = palette(input, 0.18, 3.4);
let front_fg = palette(input, 0.65 + input.beat * 0.25, 0.5);
cmds.push(trace(start_x, back, ".", ".", back_fg, back_fg, bg));
cmds.push(trace(start_x, front, "*", ".", front_fg, front_fg, bg));
cmds
}
fn push_pulse_ring(cmds, input, cx, cy, rx, ry, bg) {
let ring_rx = rx + 2 + to_int(input.beat * 5.0 + input.energy * 2.0);
let ring_ry = ry + 1 + to_int(input.beat * 3.0 + input.bass * 2.0);
let left = max(cx - ring_rx, 0);
let right = min(cx + ring_rx, input.width - 1);
let width = right - left + 1;
if width <= 2 {
return cmds;
}
let top = [];
let bottom = [];
let i = 0;
while i < width {
let nx = if width > 1 { to_float(i) / to_float(width - 1) * 2.0 - 1.0 } else { 0.0 };
let phase = to_float(i) / to_float(max(width - 1, 1));
let inside = max(1.0 - nx * nx, 0.0);
let spike = edge_spectrum(input, phase) * (1.0 + input.energy * 3.0 + input.beat * 2.0);
let y = sqrt(inside) * (to_float(ring_ry) + spike);
top.push(to_int(clamp(to_float(cy) - y, 0.0, to_float(input.height - 1))));
bottom.push(to_int(clamp(to_float(cy) + y, 0.0, to_float(input.height - 1))));
i += 1;
}
let color = palette(input, 0.14 + input.beat * 0.35, 4.8);
cmds.push(trace(left, top, ".", ".", color, color, bg));
cmds.push(trace(left, bottom, ".", ".", color, color, bg));
cmds
}
fn push_sphere(cmds, input, cx, cy, rx, ry, bg) {
let left = max(cx - rx, 0);
let right = min(cx + rx, input.width - 1);
let width = right - left + 1;
if width <= 2 {
return cmds;
}
let top = [];
let bottom = [];
let glow_top = [];
let glow_bottom = [];
let i = 0;
while i < width {
let nx = if width > 1 { to_float(i) / to_float(width - 1) * 2.0 - 1.0 } else { 0.0 };
let inside = max(1.0 - nx * nx, 0.0);
let phase = to_float(i) / to_float(max(width - 1, 1));
let top_spike = edge_spectrum(input, phase) * (1.0 + input.energy * 3.4 + input.beat * 3.0);
let bottom_spike = edge_spectrum(input, 1.0 - phase) * (1.0 + input.energy * 3.4 + input.beat * 3.0);
let edge = sqrt(inside) * to_float(ry);
let top_y = to_int(clamp(to_float(cy) - edge - top_spike, 0.0, to_float(input.height - 1)));
let bottom_y = to_int(clamp(to_float(cy) + edge + bottom_spike, 0.0, to_float(input.height - 1)));
let glow = 1 + to_int(max(top_spike, bottom_spike) * 0.35 + input.beat * 2.0);
top.push(top_y);
bottom.push(bottom_y);
glow_top.push(clamp(top_y - glow, 0, input.height - 1));
glow_bottom.push(clamp(bottom_y + glow, 0, input.height - 1));
i += 1;
}
let glow = palette(input, 0.22 + input.energy * 0.22, 3.1);
let outline = palette(input, 0.82 + input.beat * 0.45, 0.3);
let bridge = palette(input, 0.42 + input.mid * 0.25, 2.2);
cmds.push(trace(left, glow_top, ".", ".", glow, glow, bg));
cmds.push(trace(left, glow_bottom, ".", ".", glow, glow, bg));
cmds.push(trace(left, top, "*", ".", outline, bridge, bg));
cmds.push(trace(left, bottom, "*", ".", outline, bridge, bg));
let equator = palette(input, 0.50 + input.bass * 0.25, 5.6);
cmds.push(hline(left, cy, width, "-", equator, bg));
if ry > 4 {
let latitude = palette(input, 0.35 + input.treble * 0.20, 1.8);
let span = max(width - width / 3, 3);
let lat_x = left + (width - span) / 2;
let lat_y = max(cy - ry / 2, 0);
cmds.push(hline(lat_x, lat_y, span, ".", latitude, bg));
cmds.push(hline(lat_x, min(cy + ry / 2, input.height - 1), span, ".", latitude, bg));
}
let nodes = 42;
let node = 0;
while node < nodes {
let phase = to_float(node) / to_float(nodes);
let angle = phase * 6.28318 + input.time * (0.10 + input.treble * 0.08);
let spectrum = edge_spectrum(input, phase);
let burst = spectrum * (1.4 + input.energy * 4.2 + input.beat * 3.5);
let x = to_int(clamp(to_float(cx) + cos(angle) * (to_float(rx) + burst), 0.0, to_float(input.width - 1)));
let y = to_int(clamp(to_float(cy) + sin(angle) * (to_float(ry) + burst * 0.58), 0.0, to_float(input.height - 1)));
let fg = palette(input, 0.48 + spectrum * 0.36 + input.beat * 0.30, angle);
let ch = if spectrum > 1.05 { "*" } else if spectrum > 0.72 { "+" } else { "." };
cmds.push(cell(x, y, ch, fg, bg));
if spectrum > 1.18 {
let spike_x = to_int(clamp(to_float(cx) + cos(angle) * (to_float(rx) + burst + 1.5), 0.0, to_float(input.width - 1)));
let spike_y = to_int(clamp(to_float(cy) + sin(angle) * (to_float(ry) + (burst + 1.5) * 0.58), 0.0, to_float(input.height - 1)));
cmds.push(cell(spike_x, spike_y, ".", fg, bg));
}
node += 1;
}
cmds
}
fn push_meter(cmds, input, bg) {
if input.height < 5 || input.width < 20 {
return cmds;
}
let y = input.height - 2;
let w = min(input.width - 4, 48);
let x = 2;
let base = rgb(18, 34, 42);
let fill = palette(input, 0.65 + input.energy * 0.35, 5.2);
let filled = to_int(clamp(input.energy * to_float(w), 1.0, to_float(w)));
cmds.push(hline(x, y, w, "-", base, bg));
cmds.push(rect(x, y, filled, 1, " ", fill, fill));
let progress = to_int(clamp(input.progress * to_float(w), 0.0, to_float(w)));
if progress > 0 {
cmds.push(hline(x, y - 1, progress, ".", fill, bg));
cmds.push(vline(min(x + progress, x + w - 1), y - 1, 2, "|", fill, bg));
}
cmds
}
fn push_labels(cmds, input, bg) {
let fg = rgb(180 + input.treble * 60, 230, 245);
let dim = rgb(70, 120 + input.energy * 80, 130 + input.energy * 80);
if input.show_clock {
cmds.push(text(1, 0, " " + input.clock + " ", fg, rgb(2, 18, 24)));
}
if input.track_title != "" && input.height > 4 {
cmds.push(text(2, input.height - 1, input.track_title, fg, bg));
}
if input.track_artist != "" && input.height > 5 {
cmds.push(text(2, input.height - 3, input.track_artist, dim, bg));
}
if input.paused {
cmds.push(text(2, 1, "paused", dim, bg));
}
cmds
}
fn render(input) {
let cmds = [];
let bg = rgb(1, 4, 10);
cmds.push(#{ op: "clear", bg: bg });
if input.width <= 8 || input.height <= 6 {
return cmds;
}
cmds = push_starfield(cmds, input, bg);
let cx = input.width / 2;
let cy = input.height / 2;
let max_ry = max((input.height - 6) / 2, 2);
let max_rx = max(input.width / 3, 4);
let base = min(to_float(max_ry), to_float(max_rx) / 2.0) * 0.78;
let activity = clamp(input.energy * 0.72 + input.bass * 0.50 + input.beat * 1.00, 0.0, 1.18);
let pulse = 0.34
+ activity * 0.58
+ input.volume * 0.04
+ input.beat * 0.18
+ abs(sin(input.time * 2.8 + input.position * 0.07)) * (0.03 + input.energy * 0.07);
let ry = max(to_int(base * pulse), 2);
let rx = max(to_int(to_float(ry) * (1.85 + input.treble * 0.28)), 4);
cmds = push_pulse_ring(cmds, input, cx, cy, rx, ry, bg);
cmds = push_orbit(cmds, input, cx, cy, rx, ry, bg);
cmds = push_sphere(cmds, input, cx, cy, rx, ry, bg);
cmds = push_meter(cmds, input, bg);
cmds = push_labels(cmds, input, bg);
cmds
}
+274
View File
@@ -0,0 +1,274 @@
// name: Scope spectrum
// protocol: furumi-visualizer-v2
// bundle-version: 5
//
// This bundled script is both a preset and an API example. It demonstrates
// every drawing command currently understood by Furumi's visualizer runtime:
// clear, cell, hline, vline, rect, trace and text.
//
// Script entry point:
// fn render(input) -> Array<Map>
//
// Important input fields:
// width, height terminal size in cells
// time, position, progress animation time and playback progress
// volume, paused player state
// energy, bass, mid, treble smoothed audio bands in the 0.0..1.0 range
// beat transient pulse estimate in the 0.0..1.0 range
// samples recent mono waveform samples in the -1.0..1.0 range
// show_clock, clock user setting and formatted clock text
// track_title, track_artist metadata for optional labels
//
// Useful numeric helpers available to scripts:
// to_int, to_float Rhai conversions
// sin, cos, tan, sqrt, abs, pow
// Scripts cannot import modules; keep every visualization self-contained.
//
// Return value: an array of command maps. Colors are 0xRRGGBB integers.
// #{ op: "clear", bg: 0x000000 }
// #{ op: "cell", x: 10, y: 4, ch: "*", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "hline", x: 0, y: 4, w: 20, ch: "-", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "vline", x: 10, y: 0, h: 12, ch: "|", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "rect", x: 10, y: 6, w: 2, h: 4, ch: "#", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "trace", x: 0, ys: [4, 5, 3], ch: "*", line_ch: "|", fg: 0xffffff, line_fg: 0x558888, bg: 0x000000 }
// #{ op: "text", x: 2, y: 1, text: "hello", fg: 0xffffff, bg: 0x000000 }
//
// Performance tip: prefer hline/vline/rect/trace for large shapes and reserve
// cell for isolated details. Crossing the Rhai -> Rust boundary once per cell
// is much slower than returning one bulk command.
fn clamp(value, low, high) {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
fn min(left, right) {
if left < right { left } else { right }
}
fn rgb(r, g, b) {
let rr = to_int(clamp(r, 0, 255));
let gg = to_int(clamp(g, 0, 255));
let bb = to_int(clamp(b, 0, 255));
rr * 65536 + gg * 256 + bb
}
fn lerp(left, right, mix) {
left * (1.0 - mix) + right * mix
}
fn sample_at(samples, nx) {
let len = samples.len();
if len <= 0 {
0.0
} else {
let pos = clamp(nx, 0.0, 1.0) * (len - 1);
let left = to_int(pos);
let right = if left + 1 < len { left + 1 } else { left };
let mix = pos - to_float(left);
lerp(samples[left], samples[right], mix)
}
}
fn cell(x, y, ch, fg, bg) {
#{ op: "cell", x: x, y: y, ch: ch, fg: fg, bg: bg }
}
fn hline(x, y, w, ch, fg, bg) {
#{ op: "hline", x: x, y: y, w: w, ch: ch, fg: fg, bg: bg }
}
fn vline(x, y, h, ch, fg, bg) {
#{ op: "vline", x: x, y: y, h: h, ch: ch, fg: fg, bg: bg }
}
fn rect(x, y, w, h, ch, fg, bg) {
#{ op: "rect", x: x, y: y, w: w, h: h, ch: ch, fg: fg, bg: bg }
}
fn trace(x, ys, ch, line_ch, fg, line_fg, bg) {
#{ op: "trace", x: x, ys: ys, ch: ch, line_ch: line_ch, fg: fg, line_fg: line_fg, bg: bg }
}
fn text(x, y, value, fg, bg) {
#{ op: "text", x: x, y: y, text: value, fg: fg, bg: bg }
}
fn draw_grid(width, height, progress, energy, bg) {
let cmds = [];
let grid = rgb(22, 48 + energy * 30, 54 + energy * 35);
let center = height / 2;
let x = 0;
while x < width {
cmds.push(vline(x, 0, height, ".", grid, bg));
x += 12;
}
let y = 0;
while y < height {
cmds.push(hline(0, y, width, ".", grid, bg));
y += 4;
}
cmds.push(hline(0, center, width, "-", grid, bg));
let cross_x = 0;
while cross_x < width {
let cross_y = 0;
while cross_y < height {
cmds.push(cell(cross_x, cross_y, "+", grid, bg));
cross_y += 4;
}
cmds.push(cell(cross_x, center, "+", grid, bg));
cross_x += 12;
}
let scan_x = to_int(progress * to_float(width - 1));
let scan = rgb(25, 120 + energy * 80, 130 + energy * 80);
cmds.push(vline(scan_x, 0, height, "|", scan, bg));
cmds
}
fn draw_scope(input, scope_height, bg) {
let cmds = [];
let width = input.width;
if width <= 0 || scope_height <= 0 {
return cmds;
}
let center = to_float(scope_height - 1) / 2.0;
let half = center;
let fg = rgb(55 + input.beat * 80, 230, 210 + input.treble * 45);
let bridge = rgb(20, 120 + input.energy * 70, 115 + input.energy * 60);
let amplitude = 0.74 + input.energy * 0.55 + input.beat * 0.10;
let ys = [];
let x = 0;
while x < width {
let nx = if width > 1 { to_float(x) / to_float(width - 1) } else { 0.0 };
let sample = sample_at(input.samples, nx) * amplitude;
let y = to_int(clamp(center - sample * half, 0.0, to_float(scope_height - 1)));
ys.push(y);
x += 1;
}
cmds.push(trace(0, ys, "*", "|", fg, bridge, bg));
cmds
}
fn spectrum_value(freq, input) {
let band = if freq < 0.28 {
input.bass
} else if freq < 0.68 {
input.mid
} else {
input.treble
};
let wobble = abs(sin(input.time * (1.7 + freq * 5.0) + input.seed * 17.0 + freq * 31.0));
let harmonic = abs(cos(input.time * 0.63 - freq * 23.0));
clamp(0.06 + band * (0.30 + wobble * 0.48 + harmonic * 0.18) + input.beat * (1.0 - freq) * 0.35, 0.0, 1.0)
}
fn draw_bars(input, top, height, bg) {
let cmds = [];
if height <= 0 {
return cmds;
}
let width = input.width;
let bar_width = if width >= 80 { 2 } else { 1 };
let bars = width / bar_width;
let bar = 0;
while bar < bars {
let freq = to_float(bar) / to_float(bars);
let value = spectrum_value(freq, input);
let bar_height = to_int(clamp(value * to_float(height), 1.0, to_float(height)));
let fg = rgb(60 + (1.0 - freq) * 80, 160 + value * 95, 210 - freq * 110);
let x = bar * bar_width;
let y = top + height - bar_height;
let w = if bar_width > 1 && x + 1 < width { 2 } else { 1 };
if bar_height > 0 {
cmds.push(rect(x, y, w, bar_height, "#", fg, bg));
}
bar += 1;
}
cmds
}
fn append_all(cmds, more) {
for cmd in more {
cmds.push(cmd);
}
cmds
}
fn track_label(input) {
if input.track_artist != "" && input.track_title != "" {
input.track_artist + " - " + input.track_title
} else if input.track_title != "" {
input.track_title
} else {
input.track_artist
}
}
fn draw_track_badge(input, bg) {
let cmds = [];
let label = track_label(input);
if label == "" || input.width <= 8 || input.height <= 4 {
return cmds;
}
let max_text = input.width - 6;
let value = if label.len() > max_text {
label.sub_string(0, max_text)
} else {
label
};
let badge_width = min(value.len() + 4, input.width);
let x = (input.width - badge_width) / 2;
let y = input.height - 1;
let badge_bg = rgb(0, 30 + input.energy * 36, 38 + input.beat * 42);
let badge_fg = rgb(190 + input.treble * 45, 255, 235);
cmds.push(rect(x, y, badge_width, 1, " ", badge_fg, badge_bg));
cmds.push(text(x + 2, y, value, badge_fg, badge_bg));
cmds
}
fn render(input) {
let cmds = [];
let bg = rgb(0, 0, 0);
cmds.push(#{ op: "clear", bg: bg });
if input.width <= 0 || input.height <= 2 {
return cmds;
}
let content_height = input.height - 2;
let bars_height = if content_height > 12 { 8 } else { content_height / 3 };
let separator = if bars_height > 0 { 1 } else { 0 };
let scope_height = content_height - bars_height - separator;
cmds = append_all(cmds, draw_grid(input.width, scope_height, input.progress, input.energy, bg));
cmds = append_all(cmds, draw_scope(input, scope_height, bg));
if bars_height > 0 {
let sep_y = scope_height;
let sep = rgb(20, 68, 74);
cmds.push(hline(0, sep_y, input.width, "-", sep, bg));
cmds = append_all(cmds, draw_bars(input, sep_y + separator, bars_height, bg));
}
if input.show_clock {
let clock_bg = rgb(0, 26, 30);
let clock_fg = rgb(180, 255, 245);
cmds.push(text(1, 0, " " + input.clock + " ", clock_fg, clock_bg));
}
cmds = append_all(cmds, draw_track_badge(input, bg));
cmds
}
+994
View File
@@ -0,0 +1,994 @@
//! Script-backed fullscreen visualizers.
//!
//! Rust owns the audio tap, script loading, sandboxed execution limits and
//! terminal drawing primitives. Visual math lives in `.rhai` files: every
//! script receives the same input map and returns the same list of draw
//! commands.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Instant, SystemTime};
use anyhow::{Context as _, Result};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Clear, Paragraph};
use rhai::{AST, Array, Dynamic, Engine, FLOAT, INT, Map, Scope};
use serde::{Deserialize, Serialize};
use crate::app::state::AppState;
const PRIMARY_SCRIPT_ID: &str = "scope_spectrum";
const BUNDLE_VERSION: u32 = 5;
struct BundledScript {
id: &'static str,
file_name: &'static str,
display_name: &'static str,
source: &'static str,
}
const BUNDLED_SCRIPTS: &[BundledScript] = &[
BundledScript {
id: PRIMARY_SCRIPT_ID,
file_name: "scope_spectrum.rhai",
display_name: "Scope spectrum",
source: include_str!("visualizations/scope_spectrum.rhai"),
},
BundledScript {
id: "pulsing_sphere",
file_name: "pulsing_sphere.rhai",
display_name: "Pulsing sphere",
source: include_str!("visualizations/pulsing_sphere.rhai"),
},
];
#[derive(Debug, Clone)]
pub struct VisualizerScript {
pub id: String,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualizerConfig {
#[serde(default = "default_script_id")]
pub selected: String,
#[serde(default)]
pub show_clock: bool,
}
impl Default for VisualizerConfig {
fn default() -> Self {
Self {
selected: default_script_id(),
show_clock: false,
}
}
}
pub struct VisualizerState {
pub active: bool,
pub started_at: Option<Instant>,
pub config: VisualizerConfig,
pub scripts: Vec<VisualizerScript>,
pub last_error: Option<String>,
runtime: Mutex<ScriptRuntime>,
}
impl std::fmt::Debug for VisualizerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VisualizerState")
.field("active", &self.active)
.field("started_at", &self.started_at)
.field("config", &self.config)
.field("scripts", &self.scripts)
.field("last_error", &self.last_error)
.finish_non_exhaustive()
}
}
impl Default for VisualizerState {
fn default() -> Self {
Self {
active: false,
started_at: None,
config: VisualizerConfig::default(),
scripts: Vec::new(),
last_error: None,
runtime: Mutex::new(ScriptRuntime::default()),
}
}
}
impl VisualizerState {
pub fn open(&mut self) {
self.active = true;
self.started_at = Some(Instant::now());
}
pub fn close(&mut self) {
self.active = false;
self.started_at = None;
}
pub fn load_library(&mut self) -> Result<()> {
ensure_bundled_scripts()?;
self.config = load_config().unwrap_or_else(|err| {
tracing::warn!(%err, "visualization settings failed to load; using defaults");
VisualizerConfig::default()
});
self.refresh_scripts()?;
self.ensure_selected_script();
self.save_config()?;
Ok(())
}
pub fn refresh_scripts(&mut self) -> Result<()> {
let dir = visualizations_dir()?;
let mut scripts = Vec::new();
for entry in
std::fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("rhai") {
continue;
}
let Some(id) = path.file_stem().and_then(|value| value.to_str()) else {
continue;
};
scripts.push(VisualizerScript {
id: id.to_string(),
name: script_name(&path, id),
path: path.clone(),
});
}
scripts.sort_by(|left, right| {
bundled_order(&left.id)
.unwrap_or(usize::MAX)
.cmp(&bundled_order(&right.id).unwrap_or(usize::MAX))
.then_with(|| left.name.cmp(&right.name))
});
self.scripts = scripts;
self.ensure_selected_script();
Ok(())
}
pub fn select_script(&mut self, index: usize) -> Result<()> {
let Some(script) = self.scripts.get(index) else {
anyhow::bail!("visualization script not found");
};
self.config.selected = script.id.clone();
self.save_config()
}
pub fn toggle_clock(&mut self) -> Result<()> {
self.config.show_clock = !self.config.show_clock;
self.save_config()
}
pub fn selected_script(&self) -> Option<&VisualizerScript> {
self.scripts
.iter()
.find(|script| script.id == self.config.selected)
.or_else(|| self.scripts.first())
}
pub fn selected_script_index(&self) -> Option<usize> {
self.scripts
.iter()
.position(|script| script.id == self.config.selected)
}
pub fn selected_script_path(&self) -> Option<PathBuf> {
self.selected_script().map(|script| script.path.clone())
}
pub fn create_script(&mut self) -> Result<PathBuf> {
ensure_bundled_scripts()?;
let dir = visualizations_dir()?;
let mut number = 1;
let path = loop {
let candidate = dir.join(format!("custom_{number}.rhai"));
if !candidate.exists() {
break candidate;
}
number += 1;
};
let starter = primary_bundled_script();
let content = starter.source.replacen(
"// name: Scope spectrum",
&format!("// name: Custom {number}"),
1,
);
std::fs::write(&path, content).with_context(|| format!("creating {}", path.display()))?;
self.refresh_scripts()?;
let id = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(PRIMARY_SCRIPT_ID)
.to_string();
self.config.selected = id;
self.save_config()?;
Ok(path)
}
pub fn save_config(&self) -> Result<()> {
let path = visualizer_config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, toml::to_string_pretty(&self.config)?)
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
fn ensure_selected_script(&mut self) {
if self
.scripts
.iter()
.any(|script| script.id == self.config.selected)
{
return;
}
self.config.selected = self
.scripts
.first()
.map(|script| script.id.clone())
.unwrap_or_else(default_script_id);
}
}
#[derive(Debug, Clone, Default)]
pub struct AudioFeatures {
pub elapsed_secs: f64,
pub position_secs: f64,
pub progress: f64,
pub volume: f64,
pub paused: bool,
pub energy: f64,
pub bass: f64,
pub mid: f64,
pub treble: f64,
pub beat: f64,
pub seed: f64,
pub scope: Vec<f64>,
}
pub fn draw(frame: &mut Frame, state: &AppState) {
let area = frame.area();
let Some(script) = state.visualizer.selected_script() else {
frame.render_widget(Clear, area);
draw_message(frame, area, "no visualization scripts found");
return;
};
let input = input_map(area, state);
let commands = {
let mut runtime = state
.visualizer
.runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
runtime.render(&script.path, input)
};
match commands {
Ok(commands) => render_commands(frame, area, &commands),
Err(err) => {
frame.render_widget(Clear, area);
draw_message(frame, area, &format!("visualizer error: {err}"));
}
}
}
fn input_map(area: Rect, state: &AppState) -> Map {
let features = features_from_state(state);
let mut input = Map::new();
insert_int(&mut input, "width", i64::from(area.width));
insert_int(&mut input, "height", i64::from(area.height));
insert_float(&mut input, "time", features.elapsed_secs);
insert_float(&mut input, "position", features.position_secs);
insert_float(&mut input, "progress", features.progress);
insert_float(&mut input, "volume", features.volume);
input.insert("paused".into(), features.paused.into());
insert_float(&mut input, "energy", features.energy);
insert_float(&mut input, "bass", features.bass);
insert_float(&mut input, "mid", features.mid);
insert_float(&mut input, "treble", features.treble);
insert_float(&mut input, "beat", features.beat);
insert_float(&mut input, "seed", features.seed);
insert_int(
&mut input,
"analysis_sequence",
state.player.audio_analysis.sequence as i64,
);
input.insert(
"samples".into(),
features
.scope
.into_iter()
.map(|sample| Dynamic::from_float(sample as FLOAT))
.collect::<Array>()
.into(),
);
input.insert(
"show_clock".into(),
state.visualizer.config.show_clock.into(),
);
input.insert("clock".into(), clock_label().into());
let (title, artist) = state
.player
.current
.as_ref()
.map(|track| (track.title.clone(), track.artist_line()))
.unwrap_or_else(|| ("".to_string(), "".to_string()));
input.insert("track_title".into(), title.into());
input.insert("track_artist".into(), artist.into());
input
}
fn features_from_state(state: &AppState) -> AudioFeatures {
let elapsed_secs = state
.visualizer
.started_at
.map(|started| started.elapsed().as_secs_f64())
.unwrap_or_default();
let position_secs = state.player.position_secs;
let duration_secs = state
.player
.current
.as_ref()
.map(|track| track.duration_seconds.max(0.0))
.unwrap_or_default();
let progress = if duration_secs > 0.0 {
(position_secs / duration_secs).clamp(0.0, 1.0)
} else {
0.0
};
let seed = state
.player
.current
.as_ref()
.map(track_seed)
.unwrap_or(0.17);
let analysis = &state.player.audio_analysis;
let volume = f64::from(state.player.volume.min(100)) / 100.0;
let output_scale = if state.player.volume == 0 {
0.0
} else {
0.35 + volume * 0.65
};
let active_scale = if state.player.paused { 0.12 } else { 1.0 };
let scale = output_scale * active_scale;
AudioFeatures {
elapsed_secs,
position_secs,
progress,
volume,
paused: state.player.paused,
energy: (analysis.energy * scale).clamp(0.0, 1.0),
bass: (analysis.bass * scale).clamp(0.0, 1.0),
mid: (analysis.mid * scale).clamp(0.0, 1.0),
treble: (analysis.treble * scale).clamp(0.0, 1.0),
beat: if state.player.paused {
0.0
} else {
(analysis.beat * output_scale).clamp(0.0, 1.0)
},
seed,
scope: analysis
.scope
.iter()
.map(|sample| (sample * scale).clamp(-1.0, 1.0))
.collect(),
}
}
fn track_seed(track: &crate::library::models::TrackItem) -> f64 {
let mut hash = 0xcbf29ce484222325u64;
let artist_line = track.artist_line();
for byte in track
.title
.bytes()
.chain(track.release_title.bytes())
.chain(artist_line.bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
(hash % 10_000) as f64 / 10_000.0
}
#[derive(Debug)]
struct ScriptRuntime {
engine: Engine,
cached_path: Option<PathBuf>,
cached_modified: Option<SystemTime>,
ast: Option<AST>,
}
impl Default for ScriptRuntime {
fn default() -> Self {
let mut engine = Engine::new();
engine
.set_max_operations(2_000_000)
.set_max_call_levels(32)
.set_max_variables(512)
.set_max_functions(128)
.set_max_modules(0)
.set_max_expr_depths(64, 64)
.set_max_string_size(1_000_000)
.set_max_array_size(120_000)
.set_max_map_size(1_000_000);
engine.disable_symbol("import");
engine.disable_symbol("export");
engine.register_fn("sin", |value: FLOAT| value.sin());
engine.register_fn("cos", |value: FLOAT| value.cos());
engine.register_fn("tan", |value: FLOAT| value.tan());
engine.register_fn("sqrt", |value: FLOAT| value.sqrt());
engine.register_fn("abs", |value: FLOAT| value.abs());
engine.register_fn("pow", |value: FLOAT, power: FLOAT| value.powf(power));
Self {
engine,
cached_path: None,
cached_modified: None,
ast: None,
}
}
}
impl ScriptRuntime {
fn render(&mut self, path: &Path, input: Map) -> std::result::Result<Vec<DrawCommand>, String> {
self.load(path)?;
let Some(ast) = &self.ast else {
return Err("script did not compile".to_string());
};
let mut scope = Scope::new();
let output = self
.engine
.call_fn::<Array>(&mut scope, ast, "render", (input,))
.map_err(|err| err.to_string())?;
let mut commands = Vec::with_capacity(output.len());
for (index, value) in output.into_iter().enumerate() {
commands.push(
DrawCommand::from_dynamic(value)
.map_err(|err| format!("invalid draw command #{index}: {err}"))?,
);
}
Ok(commands)
}
fn load(&mut self, path: &Path) -> std::result::Result<(), String> {
let modified = std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok();
let cache_hit = self.cached_path.as_deref() == Some(path)
&& self.cached_modified == modified
&& self.ast.is_some();
if cache_hit {
return Ok(());
}
let source = std::fs::read_to_string(path).map_err(|err| format!("{err}"))?;
let ast = self
.engine
.compile(&source)
.map_err(|err| err.to_string())?;
self.cached_path = Some(path.to_path_buf());
self.cached_modified = modified;
self.ast = Some(ast);
Ok(())
}
}
#[derive(Debug, Clone)]
enum DrawCommand {
Clear {
bg: Color,
},
Cell {
x: u16,
y: u16,
ch: String,
fg: Color,
bg: Color,
},
HLine {
x: u16,
y: u16,
w: u16,
ch: String,
fg: Color,
bg: Color,
},
VLine {
x: u16,
y: u16,
h: u16,
ch: String,
fg: Color,
bg: Color,
},
Rect {
x: u16,
y: u16,
w: u16,
h: u16,
ch: String,
fg: Color,
bg: Color,
},
Trace {
x: u16,
ys: Vec<u16>,
ch: String,
line_ch: String,
fg: Color,
line_fg: Color,
bg: Color,
},
Text {
x: u16,
y: u16,
text: String,
fg: Color,
bg: Color,
},
}
impl DrawCommand {
fn from_dynamic(value: Dynamic) -> std::result::Result<Self, String> {
let Some(map) = value.try_cast::<Map>() else {
return Err("expected object map".to_string());
};
let op = map_string(&map, "op")
.or_else(|| map_string(&map, "type"))
.ok_or_else(|| "missing op".to_string())?;
let fg = map_color(&map, "fg").unwrap_or(Color::White);
let bg = map_color(&map, "bg").unwrap_or(Color::Black);
match op.as_str() {
"clear" => Ok(DrawCommand::Clear { bg }),
"cell" => Ok(DrawCommand::Cell {
x: map_u16(&map, "x").ok_or_else(|| "cell.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "cell.y must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"hline" => Ok(DrawCommand::HLine {
x: map_u16(&map, "x").ok_or_else(|| "hline.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "hline.y must be an integer".to_string())?,
w: map_u16(&map, "w").ok_or_else(|| "hline.w must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"vline" => Ok(DrawCommand::VLine {
x: map_u16(&map, "x").ok_or_else(|| "vline.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "vline.y must be an integer".to_string())?,
h: map_u16(&map, "h").ok_or_else(|| "vline.h must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"rect" => Ok(DrawCommand::Rect {
x: map_u16(&map, "x").ok_or_else(|| "rect.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "rect.y must be an integer".to_string())?,
w: map_u16(&map, "w").ok_or_else(|| "rect.w must be an integer".to_string())?,
h: map_u16(&map, "h").ok_or_else(|| "rect.h must be an integer".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| " ".to_string()),
fg,
bg,
}),
"trace" => Ok(DrawCommand::Trace {
x: map_u16(&map, "x").unwrap_or(0),
ys: map_u16_array(&map, "ys")
.ok_or_else(|| "trace.ys must be an integer array".to_string())?,
ch: map_string(&map, "ch").unwrap_or_else(|| "*".to_string()),
line_ch: map_string(&map, "line_ch").unwrap_or_else(|| "|".to_string()),
fg,
line_fg: map_color(&map, "line_fg").unwrap_or(fg),
bg,
}),
"text" => Ok(DrawCommand::Text {
x: map_u16(&map, "x").ok_or_else(|| "text.x must be an integer".to_string())?,
y: map_u16(&map, "y").ok_or_else(|| "text.y must be an integer".to_string())?,
text: map_string(&map, "text").unwrap_or_default(),
fg,
bg,
}),
_ => Err(format!("unknown op {op:?}")),
}
}
}
fn render_commands(frame: &mut Frame, area: Rect, commands: &[DrawCommand]) {
for command in commands {
match command {
DrawCommand::Clear { bg } => fill_rect(frame, area, " ", Style::new().bg(*bg)),
DrawCommand::Cell { x, y, ch, fg, bg } => {
write_cell(frame, area, *x, *y, ch, Style::new().fg(*fg).bg(*bg));
}
DrawCommand::HLine {
x,
y,
w,
ch,
fg,
bg,
} => {
let style = Style::new().fg(*fg).bg(*bg);
for offset in 0..*w {
write_cell(frame, area, x.saturating_add(offset), *y, ch, style);
}
}
DrawCommand::VLine {
x,
y,
h,
ch,
fg,
bg,
} => {
let style = Style::new().fg(*fg).bg(*bg);
for offset in 0..*h {
write_cell(frame, area, *x, y.saturating_add(offset), ch, style);
}
}
DrawCommand::Rect {
x,
y,
w,
h,
ch,
fg,
bg,
} => {
let style = Style::new().fg(*fg).bg(*bg);
for row in 0..*h {
for col in 0..*w {
write_cell(
frame,
area,
x.saturating_add(col),
y.saturating_add(row),
ch,
style,
);
}
}
}
DrawCommand::Trace {
x,
ys,
ch,
line_ch,
fg,
line_fg,
bg,
} => {
let line_style = Style::new().fg(*line_fg).bg(*bg);
let point_style = Style::new().fg(*fg).bg(*bg);
let mut previous_y: Option<u16> = None;
for (index, y) in ys.iter().copied().enumerate() {
let Ok(offset) = u16::try_from(index) else {
break;
};
let x = x.saturating_add(offset);
if let Some(previous) = previous_y {
let from = previous.min(y);
let to = previous.max(y);
for y in from..=to {
write_cell(frame, area, x, y, line_ch, line_style);
}
}
write_cell(frame, area, x, y, ch, point_style);
previous_y = Some(y);
}
}
DrawCommand::Text { x, y, text, fg, bg } => {
for (offset, ch) in text.chars().enumerate() {
let Ok(offset) = u16::try_from(offset) else {
break;
};
write_cell(
frame,
area,
x.saturating_add(offset),
*y,
&ch.to_string(),
Style::new().fg(*fg).bg(*bg),
);
}
}
}
}
}
fn fill_rect(frame: &mut Frame, area: Rect, symbol: &'static str, style: Style) {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
cell.set_symbol(symbol).set_style(style);
}
}
}
}
fn write_cell(frame: &mut Frame, area: Rect, x: u16, y: u16, symbol: &str, style: Style) {
if x >= area.width || y >= area.height {
return;
}
if let Some(cell) = frame
.buffer_mut()
.cell_mut((area.x.saturating_add(x), area.y.saturating_add(y)))
{
cell.set_symbol(symbol).set_style(style);
}
}
fn draw_message(frame: &mut Frame, area: Rect, message: &str) {
frame.render_widget(
Paragraph::new(message.to_string()).style(Style::new().fg(Color::Red).bg(Color::Black)),
area,
);
}
fn map_string(map: &Map, key: &str) -> Option<String> {
map.get(key)?.clone().try_cast::<String>()
}
fn map_u16(map: &Map, key: &str) -> Option<u16> {
let value = map.get(key)?.clone().try_cast::<INT>()?;
u16::try_from(value).ok()
}
fn map_u16_array(map: &Map, key: &str) -> Option<Vec<u16>> {
map.get(key)?
.clone()
.try_cast::<Array>()?
.into_iter()
.map(|value| u16::try_from(value.try_cast::<INT>()?).ok())
.collect()
}
fn map_color(map: &Map, key: &str) -> Option<Color> {
let value = map.get(key)?;
if let Some(rgb) = value.clone().try_cast::<INT>() {
return Some(rgb_color(rgb));
}
let text = value.clone().try_cast::<String>()?;
parse_color(&text)
}
fn rgb_color(value: INT) -> Color {
let value = value.clamp(0, 0x00ff_ffff) as u32;
Color::Rgb(
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
)
}
fn parse_color(text: &str) -> Option<Color> {
let hex = text.trim().trim_start_matches('#').trim_start_matches("0x");
let value = u32::from_str_radix(hex, 16).ok()?;
Some(Color::Rgb(
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
))
}
fn insert_int(map: &mut Map, key: &str, value: i64) {
map.insert(key.into(), Dynamic::from_int(value as INT));
}
fn insert_float(map: &mut Map, key: &str, value: f64) {
map.insert(key.into(), Dynamic::from_float(value as FLOAT));
}
fn clock_label() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!(
"{:02}:{:02}:{:02}",
secs / 3600 % 24,
secs / 60 % 60,
secs % 60
)
}
fn default_script_id() -> String {
PRIMARY_SCRIPT_ID.to_string()
}
fn config_dir() -> Result<PathBuf> {
Ok(crate::config::project_dirs()
.map(|dirs| dirs.config_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")))
}
pub fn visualizations_dir() -> Result<PathBuf> {
Ok(config_dir()?.join("visualizations"))
}
fn visualizer_config_path() -> Result<PathBuf> {
Ok(config_dir()?.join("visualizations.toml"))
}
fn primary_bundled_script() -> &'static BundledScript {
&BUNDLED_SCRIPTS[0]
}
fn bundled_order(id: &str) -> Option<usize> {
BUNDLED_SCRIPTS.iter().position(|script| script.id == id)
}
fn ensure_bundled_scripts() -> Result<()> {
let dir = visualizations_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
for script in BUNDLED_SCRIPTS {
let path = dir.join(script.file_name);
let write_default = match std::fs::read_to_string(&path) {
Ok(existing) => should_replace_bundled_script(&existing, script),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
};
if write_default {
std::fs::write(&path, script.source)
.with_context(|| format!("writing {}", path.display()))?;
}
}
Ok(())
}
fn should_replace_bundled_script(existing: &str, script: &BundledScript) -> bool {
let name_marker = format!("// name: {}", script.display_name);
let version_marker = format!("// bundle-version: {BUNDLE_VERSION}");
existing
.lines()
.take(3)
.any(|line| line.trim() == name_marker)
&& !existing
.lines()
.take(8)
.any(|line| line.trim() == version_marker)
}
fn load_config() -> Result<VisualizerConfig> {
let path = visualizer_config_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(VisualizerConfig::default()),
Err(err) => Err(err).with_context(|| format!("reading {}", path.display())),
}
}
fn script_name(path: &Path, fallback_id: &str) -> String {
if let Ok(text) = std::fs::read_to_string(path) {
for line in text.lines().take(12) {
let Some(name) = line.trim().strip_prefix("// name:") else {
continue;
};
let name = name.trim();
if !name.is_empty() {
return name.to_string();
}
}
}
fallback_id
.split(['_', '-'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn test_input() -> Map {
let mut input = Map::new();
insert_int(&mut input, "width", 80);
insert_int(&mut input, "height", 24);
insert_float(&mut input, "time", 1.0);
insert_float(&mut input, "position", 3.0);
insert_float(&mut input, "progress", 0.25);
insert_float(&mut input, "volume", 0.8);
input.insert("paused".into(), false.into());
insert_float(&mut input, "energy", 0.7);
insert_float(&mut input, "bass", 0.8);
insert_float(&mut input, "mid", 0.5);
insert_float(&mut input, "treble", 0.3);
insert_float(&mut input, "beat", 0.4);
insert_float(&mut input, "seed", 0.17);
insert_int(&mut input, "analysis_sequence", 1);
input.insert(
"samples".into(),
(0..128)
.map(|index| {
let sample = ((index as f64 / 128.0) * std::f64::consts::TAU).sin();
Dynamic::from_float(sample as FLOAT)
})
.collect::<Array>()
.into(),
);
input.insert("show_clock".into(), true.into());
input.insert("clock".into(), "12:34:56".into());
input.insert("track_title".into(), "track".into());
input.insert("track_artist".into(), "artist".into());
input
}
#[test]
fn bundled_scripts_render_commands() {
for script in BUNDLED_SCRIPTS {
let path = std::env::temp_dir().join(format!(
"furumi-test-{}-{}",
std::process::id(),
script.file_name
));
std::fs::write(&path, script.source).unwrap();
let commands = ScriptRuntime::default()
.render(&path, test_input())
.unwrap();
let _ = std::fs::remove_file(&path);
assert!(!commands.is_empty(), "{} returned no commands", script.id);
assert!(
commands.len() < 220,
"{} returned {} commands",
script.id,
commands.len()
);
assert!(
commands.iter().any(
|command| matches!(command, DrawCommand::Text { text, .. } if text.contains("12:34:56"))
),
"{} did not render the configured clock",
script.id
);
if script.id == PRIMARY_SCRIPT_ID {
assert!(
commands.iter().any(
|command| matches!(command, DrawCommand::Text { text, .. } if text.contains("artist - track"))
),
"{} did not render track metadata",
script.id
);
}
}
}
#[test]
fn stale_default_script_is_migrated() {
let scope = primary_bundled_script();
assert!(should_replace_bundled_script(
"// name: Scope spectrum\nfn rgb(r, g, b) { let rr = clamp(r, 0, 255) as int; }",
scope,
));
assert!(should_replace_bundled_script(
"// name: Scope spectrum\n// protocol: furumi-visualizer-v2\nfn rgb(r, g, b) { let rr = to_int(clamp(r, 0, 255)); }",
scope,
));
assert!(!should_replace_bundled_script(scope.source, scope));
assert!(!should_replace_bundled_script(
"// name: Custom 1\nfn rgb(r, g, b) { let rr = clamp(r, 0, 255) as int; }",
scope,
));
}
}