Added Visualisation engine on rhai
This commit is contained in:
Generated
+99
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ mod media;
|
||||
mod player;
|
||||
mod share;
|
||||
mod ui;
|
||||
mod visualizer;
|
||||
|
||||
use std::io;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user