diff --git a/Cargo.lock b/Cargo.lock index deeb6a6..12f876e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 00695fb..6c0af2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index c1c61ed..c975ee9 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/app/action.rs b/src/app/action.rs index b8df863..3323825 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 53f71a5..f037376 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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>, /// Caps concurrent artwork loads so they never starve the disk. pub art_semaphore: Arc, + /// 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, 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())); diff --git a/src/app/state.rs b/src/app/state.rs index a2ab02a..d8e5125 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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 { + 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, } @@ -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, /// 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, pub status_message: Option, + pub settings_cursor: usize, pub player: PlayerBar, + pub visualizer: crate::visualizer::VisualizerState, pub global: GlobalTab, pub artist_views: HashMap>, pub release_views: HashMap>, diff --git a/src/app/update.rs b/src/app/update.rs index 6e6f198..5b7f4b2 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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, 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 { @@ -154,6 +158,20 @@ pub fn update(state: &mut AppState, action: Action) -> Option { 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 { 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 { - 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 { 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 { }); 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}; diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index da87197..124cdc8 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -194,6 +194,10 @@ command = "ToggleShuffle" key_sequence = "r" command = "CycleRepeat" +[[keymaps]] +key_sequence = "shift-l" +command = "ToggleVisualizer" + [[keymaps]] key_sequence = "x" command = "ToggleLike" diff --git a/src/config/keymap.rs b/src/config/keymap.rs index 4c8e39b..9b2869c 100644 --- a/src/config/keymap.rs +++ b/src/config/keymap.rs @@ -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(); diff --git a/src/federation/mod.rs b/src/federation/mod.rs index a2fc901..242f0e5 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -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) diff --git a/src/main.rs b/src/main.rs index 749521d..ca4f793 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod media; mod player; mod share; mod ui; +mod visualizer; use std::io; diff --git a/src/player/analyzer.rs b/src/player/analyzer.rs new file mode 100644 index 0000000..e2c9163 --- /dev/null +++ b/src/player/analyzer.rs @@ -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, +} + +#[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::>() + .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 { + input: S, + shared: Arc, + state: AnalyzerState, +} + +impl AnalyzedSource +where + S: Source, +{ + pub fn new(input: S, shared: Arc) -> Self { + let channels = input.channels(); + let sample_rate = input.sample_rate(); + Self { + input, + shared, + state: AnalyzerState::new(channels, sample_rate), + } + } +} + +impl Iterator for AnalyzedSource +where + S: Source, +{ + type Item = Sample; + + fn next(&mut self) -> Option { + 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) { + self.input.size_hint() + } +} + +impl Source for AnalyzedSource +where + S: Source, +{ + fn current_span_len(&self) -> Option { + 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 { + 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, + 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 { + let sample = self.samples.get(self.cursor).copied()?; + self.cursor += 1; + Some(sample) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.samples.len().saturating_sub(self.cursor); + (remaining, Some(remaining)) + } + } + + impl Source for TestSource { + fn current_span_len(&self) -> Option { + 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 { + 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)); + } +} diff --git a/src/player/mod.rs b/src/player/mod.rs index 86ee208..fce6c7e 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -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; @@ -58,6 +62,7 @@ enum Command { pub struct Shared { position_ms: AtomicU64, paused: AtomicBool, + analysis: Arc, } 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, shared: Arc, 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, shared: Arc, on_event: impl Fn(PlayerEvent fn handle( command: Command, + shared: &Arc, output: &mut Option, 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 { diff --git a/src/share.rs b/src/share.rs index 7331dd8..bd753a8 100644 --- a/src/share.rs +++ b/src/share.rs @@ -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); diff --git a/src/ui/federation.rs b/src/ui/federation.rs index ae309e5..cad037c 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -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:= 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: Line<'static> { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 92342de..3cfb2b3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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), diff --git a/src/visualizations/pulsing_sphere.rhai b/src/visualizations/pulsing_sphere.rhai new file mode 100644 index 0000000..d233c5a --- /dev/null +++ b/src/visualizations/pulsing_sphere.rhai @@ -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 +// +// 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 +} diff --git a/src/visualizations/scope_spectrum.rhai b/src/visualizations/scope_spectrum.rhai new file mode 100644 index 0000000..f400e50 --- /dev/null +++ b/src/visualizations/scope_spectrum.rhai @@ -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 +// +// 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 +} diff --git a/src/visualizer.rs b/src/visualizer.rs new file mode 100644 index 0000000..9353e0f --- /dev/null +++ b/src/visualizer.rs @@ -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, + pub config: VisualizerConfig, + pub scripts: Vec, + pub last_error: Option, + runtime: Mutex, +} + +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 { + self.scripts + .iter() + .position(|script| script.id == self.config.selected) + } + + pub fn selected_script_path(&self) -> Option { + self.selected_script().map(|script| script.path.clone()) + } + + pub fn create_script(&mut self) -> Result { + 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, +} + +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::() + .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, + cached_modified: Option, + ast: Option, +} + +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, 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::(&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, + 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 { + let Some(map) = value.try_cast::() 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 = 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 { + map.get(key)?.clone().try_cast::() +} + +fn map_u16(map: &Map, key: &str) -> Option { + let value = map.get(key)?.clone().try_cast::()?; + u16::try_from(value).ok() +} + +fn map_u16_array(map: &Map, key: &str) -> Option> { + map.get(key)? + .clone() + .try_cast::()? + .into_iter() + .map(|value| u16::try_from(value.try_cast::()?).ok()) + .collect() +} + +fn map_color(map: &Map, key: &str) -> Option { + let value = map.get(key)?; + if let Some(rgb) = value.clone().try_cast::() { + return Some(rgb_color(rgb)); + } + let text = value.clone().try_cast::()?; + 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 { + 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 { + Ok(crate::config::project_dirs() + .map(|dirs| dirs.config_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from("."))) +} + +pub fn visualizations_dir() -> Result { + Ok(config_dir()?.join("visualizations")) +} + +fn visualizer_config_path() -> Result { + Ok(config_dir()?.join("visualizations.toml")) +} + +fn primary_bundled_script() -> &'static BundledScript { + &BUNDLED_SCRIPTS[0] +} + +fn bundled_order(id: &str) -> Option { + 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 { + 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::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .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::() + .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, + )); + } +}