995 lines
31 KiB
Rust
995 lines
31 KiB
Rust
//! 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,
|
|
));
|
|
}
|
|
}
|