devalang_wasm/engine/special_vars/
mod.rs

1/// Special variables system for Devalang
2/// Provides runtime-computed variables like $beat, $time, $random, etc.
3use crate::language::syntax::ast::Value;
4use std::collections::HashMap;
5
6// Random number generation helpers
7#[cfg(feature = "cli")]
8fn gen_random_f32() -> f32 {
9    rand::random::<f32>()
10}
11
12#[cfg(feature = "wasm")]
13fn gen_random_f32() -> f32 {
14    let mut buf = [0u8; 4];
15    getrandom::getrandom(&mut buf).unwrap_or_default();
16    let val = u32::from_le_bytes(buf);
17    (val as f32) / (u32::MAX as f32)
18}
19
20#[cfg(feature = "cli")]
21fn gen_random_u32() -> u32 {
22    rand::random::<u32>()
23}
24
25#[cfg(feature = "wasm")]
26fn gen_random_u32() -> u32 {
27    let mut buf = [0u8; 4];
28    getrandom::getrandom(&mut buf).unwrap_or_default();
29    u32::from_le_bytes(buf)
30}
31
32#[cfg(feature = "cli")]
33fn gen_random_bool() -> bool {
34    rand::random::<bool>()
35}
36
37#[cfg(feature = "wasm")]
38fn gen_random_bool() -> bool {
39    gen_random_u32() % 2 == 0
40}
41
42#[cfg(feature = "cli")]
43pub fn gen_range_f32(min: f32, max: f32) -> f32 {
44    use rand::Rng;
45    let mut rng = rand::thread_rng();
46    rng.gen_range(min..max)
47}
48
49#[cfg(feature = "wasm")]
50pub fn gen_range_f32(min: f32, max: f32) -> f32 {
51    min + gen_random_f32() * (max - min)
52}
53
54/// Special variable prefix
55pub const SPECIAL_VAR_PREFIX: char = '$';
56
57/// Special variable categories
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum SpecialVarCategory {
60    Time,     // $time, $beat, $bar
61    Random,   // $random.noise, $random.float, $random.int
62    Music,    // $bpm, $tempo, $duration
63    Position, // $position, $progress
64    Midi,     // $midi.note, $midi.velocity
65    System,   // $sampleRate, $channels
66}
67
68/// Special variable context - holds runtime state
69#[derive(Debug, Clone)]
70pub struct SpecialVarContext {
71    pub current_time: f32,   // Current playback time in seconds
72    pub current_beat: f32,   // Current beat position
73    pub current_bar: f32,    // Current bar position
74    pub bpm: f32,            // Current BPM
75    pub duration: f32,       // Beat duration in seconds
76    pub sample_rate: u32,    // Sample rate
77    pub channels: usize,     // Number of channels
78    pub position: f32,       // Normalized position (0.0-1.0)
79    pub total_duration: f32, // Total duration in seconds
80}
81
82impl Default for SpecialVarContext {
83    fn default() -> Self {
84        Self {
85            current_time: 0.0,
86            current_beat: 0.0,
87            current_bar: 0.0,
88            bpm: 120.0,
89            duration: 0.5,
90            sample_rate: 44100,
91            channels: 2,
92            position: 0.0,
93            total_duration: 0.0,
94        }
95    }
96}
97
98impl SpecialVarContext {
99    pub fn new(bpm: f32, sample_rate: u32) -> Self {
100        Self {
101            bpm,
102            duration: 60.0 / bpm,
103            sample_rate,
104            ..Default::default()
105        }
106    }
107
108    /// Update time-based variables
109    pub fn update_time(&mut self, time: f32) {
110        self.current_time = time;
111        self.current_beat = time / self.duration;
112        self.current_bar = self.current_beat / 4.0;
113
114        if self.total_duration > 0.0 {
115            self.position = (time / self.total_duration).clamp(0.0, 1.0);
116        }
117    }
118
119    /// Update BPM
120    pub fn update_bpm(&mut self, bpm: f32) {
121        self.bpm = bpm;
122        self.duration = 60.0 / bpm;
123    }
124}
125
126/// Check if a variable name is a special variable
127pub fn is_special_var(name: &str) -> bool {
128    name.starts_with(SPECIAL_VAR_PREFIX)
129}
130
131/// Resolve a special variable to its current value
132pub fn resolve_special_var(name: &str, context: &SpecialVarContext) -> Option<Value> {
133    if !is_special_var(name) {
134        return None;
135    }
136
137    // small epsilon to avoid floating-point rounding making exact boundaries fall
138    // just below an integer (e.g., 0.9999999) which would incorrectly floor to previous
139    // bar. Use a tiny epsilon when applying floor for bar calculations.
140    let eps = 1e-6_f32;
141
142    match name {
143        // Time variables
144        "$time" => Some(Value::Number(context.current_time)),
145        "$beat" => Some(Value::Number(context.current_beat)),
146        // Return 1-based bar number (integers) so scripts can compare with ==
147        // e.g., at time 0 the first bar is 1
148        "$bar" => Some(Value::Number((context.current_bar + eps).floor() + 1.0)),
149        "$currentTime" => Some(Value::Number(context.current_time)),
150        "$currentBeat" => Some(Value::Number(context.current_beat)),
151        "$currentBar" => Some(Value::Number((context.current_bar + eps).floor() + 1.0)),
152
153        // Music variables
154        "$bpm" => Some(Value::Number(context.bpm)),
155        "$tempo" => Some(Value::Number(context.bpm)),
156        "$duration" => Some(Value::Number(context.duration)),
157
158        // Position variables
159        "$position" => Some(Value::Number(context.position)),
160        "$progress" => Some(Value::Number(context.position)),
161
162        // System variables
163        "$sampleRate" => Some(Value::Number(context.sample_rate as f32)),
164        "$channels" => Some(Value::Number(context.channels as f32)),
165
166        // Random variables (computed on-demand)
167        #[cfg(any(feature = "cli", feature = "wasm"))]
168        "$random" | "$random.float" => Some(Value::Number(gen_random_f32())),
169        #[cfg(any(feature = "cli", feature = "wasm"))]
170        "$random.noise" => Some(Value::Number(gen_random_f32() * 2.0 - 1.0)), // -1.0 to 1.0
171        #[cfg(any(feature = "cli", feature = "wasm"))]
172        "$random.int" => Some(Value::Number((gen_random_u32() % 100) as f32)),
173        #[cfg(any(feature = "cli", feature = "wasm"))]
174        "$random.bool" => Some(Value::Boolean(gen_random_bool())),
175
176        // Nested random with ranges
177        #[cfg(any(feature = "cli", feature = "wasm"))]
178        _ if name.starts_with("$random.range(") => {
179            // Parse $random.range(min, max)
180            parse_random_range(name)
181        }
182
183        _ => None,
184    }
185}
186
187/// Parse $random.range(min, max) syntax
188#[cfg(any(feature = "cli", feature = "wasm"))]
189fn parse_random_range(name: &str) -> Option<Value> {
190    // Extract content between parentheses
191    let start = name.find('(')?;
192    let end = name.rfind(')')?;
193    let content = &name[start + 1..end];
194
195    // Split by comma
196    let parts: Vec<&str> = content.split(',').map(|s| s.trim()).collect();
197    if parts.len() != 2 {
198        return None;
199    }
200
201    // Parse min and max
202    let min: f32 = parts[0].parse().ok()?;
203    let max: f32 = parts[1].parse().ok()?;
204
205    // Generate random value in range
206    let value = min + gen_random_f32() * (max - min);
207    Some(Value::Number(value))
208}
209
210/// Get all available special variables as a map
211pub fn get_all_special_vars(context: &SpecialVarContext) -> HashMap<String, Value> {
212    let mut vars = HashMap::new();
213
214    // small epsilon to avoid floating-point rounding issues at exact boundaries
215    let eps = 1e-6_f32;
216
217    // Time
218    vars.insert("$time".to_string(), Value::Number(context.current_time));
219    vars.insert("$beat".to_string(), Value::Number(context.current_beat));
220    // Expose bar as 1-based integer (users expect $bar == 1 for first bar)
221    vars.insert(
222        "$bar".to_string(),
223        Value::Number((context.current_bar + eps).floor() + 1.0),
224    );
225    vars.insert(
226        "$currentTime".to_string(),
227        Value::Number(context.current_time),
228    );
229    vars.insert(
230        "$currentBeat".to_string(),
231        Value::Number(context.current_beat),
232    );
233    vars.insert(
234        "$currentBar".to_string(),
235        Value::Number((context.current_bar + eps).floor() + 1.0),
236    );
237
238    // Music
239    vars.insert("$bpm".to_string(), Value::Number(context.bpm));
240    vars.insert("$tempo".to_string(), Value::Number(context.bpm));
241    vars.insert("$duration".to_string(), Value::Number(context.duration));
242
243    // Position
244    vars.insert("$position".to_string(), Value::Number(context.position));
245    vars.insert("$progress".to_string(), Value::Number(context.position));
246
247    // System
248    vars.insert(
249        "$sampleRate".to_string(),
250        Value::Number(context.sample_rate as f32),
251    );
252    vars.insert(
253        "$channels".to_string(),
254        Value::Number(context.channels as f32),
255    );
256
257    vars
258}
259
260/// List all special variable categories with examples
261pub fn list_special_vars() -> HashMap<&'static str, Vec<(&'static str, &'static str)>> {
262    let mut categories = HashMap::new();
263
264    categories.insert(
265        "Time",
266        vec![
267            ("$time", "Current time in seconds"),
268            ("$beat", "Current beat position"),
269            ("$bar", "Current bar position"),
270            ("$currentTime", "Alias for $time"),
271            ("$currentBeat", "Alias for $beat"),
272            ("$currentBar", "Alias for $bar"),
273        ],
274    );
275
276    categories.insert(
277        "Music",
278        vec![
279            ("$bpm", "Current BPM"),
280            ("$tempo", "Alias for $bpm"),
281            ("$duration", "Beat duration in seconds"),
282        ],
283    );
284
285    categories.insert(
286        "Position",
287        vec![
288            ("$position", "Normalized position (0.0-1.0)"),
289            ("$progress", "Alias for $position"),
290        ],
291    );
292
293    categories.insert(
294        "Random",
295        vec![
296            ("$random", "Random float 0.0-1.0"),
297            ("$random.float", "Random float 0.0-1.0"),
298            ("$random.noise", "Random float -1.0 to 1.0"),
299            ("$random.int", "Random integer 0-99"),
300            ("$random.bool", "Random boolean"),
301            ("$random.range(min, max)", "Random float in range"),
302        ],
303    );
304
305    categories.insert(
306        "System",
307        vec![
308            ("$sampleRate", "Sample rate in Hz"),
309            ("$channels", "Number of audio channels"),
310        ],
311    );
312
313    categories
314}
315
316#[cfg(test)]
317#[path = "test_special_vars.rs"]
318mod tests;