9 Commits

Author SHA1 Message Date
Debian
5d755594cb Add Gitea Actions workflow for website deployment
Deploys the Astro website to the VPS nginx container via
the runner's mounted host volume on pushes to main.
2026-03-07 13:05:27 +00:00
6b60b3761b chore: Release
Some checks failed
Deploy Website / deploy (push) Has been skipped
CI / linux (push) Failing after 11m18s
CI / macos (push) Failing after 44s
CI / windows (push) Failing after 44s
Release / linux (push) Has been skipped
Release / macos (push) Has been skipped
Release / assemble-macos (push) Has been skipped
Release / windows (push) Has been skipped
Release / cross (push) Has been skipped
Release / release (push) Has been skipped
2026-03-07 11:53:01 +01:00
63fd2419d3 Update lock file 2026-03-07 11:52:09 +01:00
da92fa6622 Update cargo 2026-03-07 11:49:27 +01:00
8e43e1bb3c Feat: document time stretching
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-07 11:47:54 +01:00
3104a61490 Feat: optimizations 2026-03-07 11:38:49 +01:00
20d72c9b21 Feat: words and default release 2026-03-07 00:10:09 +01:00
09cfa82809 Fix: lots of various fixes
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-06 20:48:50 +01:00
bc1396d61d Fix: Windows BUILD fails again
All checks were successful
Deploy Website / deploy (push) Has been skipped
2026-03-06 10:14:14 +01:00
22 changed files with 789 additions and 449 deletions

View File

@@ -0,0 +1,38 @@
name: Deploy Website
on:
push:
branches: [main]
paths:
- 'website/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
working-directory: website
run: pnpm install
- name: Build
working-directory: website
run: pnpm build
- name: Deploy to host volume
run: |
rm -rf /home/debian/my-services/cagire-website-data/*
cp -r website/dist/* /home/debian/my-services/cagire-website-data/

View File

@@ -113,6 +113,7 @@ jobs:
- name: Prepare plugin artifacts - name: Prepare plugin artifacts
if: inputs.build-packages if: inputs.build-packages
shell: bash
run: | run: |
mkdir -p staging/clap staging/vst3 mkdir -p staging/clap staging/vst3
cp -R target/bundled/cagire-plugins.clap staging/clap/ cp -R target/bundled/cagire-plugins.clap staging/clap/

991
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"] members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"]
[workspace.package] [workspace.package]
version = "0.1.2" version = "0.1.3"
edition = "2021" edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"] authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0" license = "AGPL-3.0"

View File

@@ -63,6 +63,7 @@ pub struct StepContext<'a> {
pub speed: f64, pub speed: f64,
pub fill: bool, pub fill: bool,
pub nudge_secs: f64, pub nudge_secs: f64,
pub sr: f64,
pub cc_access: Option<&'a dyn CcAccess>, pub cc_access: Option<&'a dyn CcAccess>,
pub speed_key: &'a str, pub speed_key: &'a str,
pub mouse_x: f64, pub mouse_x: f64,

View File

@@ -302,6 +302,7 @@ impl Forth {
&resolved_params, &resolved_params,
ctx.step_duration(), ctx.step_duration(),
delta_secs, delta_secs,
ctx.sr,
outputs, outputs,
); );
Ok(resolved_sound_val.map(|v| v.into_owned())) Ok(resolved_sound_val.map(|v| v.into_owned()))
@@ -1542,7 +1543,7 @@ impl Forth {
.unwrap_or(0); .unwrap_or(0);
let dev = let dev =
get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0); get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0);
let delta_suffix = if delta_secs > 0.0 { let delta_suffix = if delta_secs.abs() > 1e-9 {
format!("/delta/{delta_secs}") format!("/delta/{delta_secs}")
} else { } else {
String::new() String::new()
@@ -1741,6 +1742,7 @@ fn emit_output(
params: &[(&str, String)], params: &[(&str, String)],
step_duration: f64, step_duration: f64,
nudge_secs: f64, nudge_secs: f64,
sr: f64,
outputs: &mut Vec<String>, outputs: &mut Vec<String>,
) { ) {
use std::fmt::Write; use std::fmt::Write;
@@ -1748,6 +1750,7 @@ fn emit_output(
out.push('/'); out.push('/');
let has_dur = params.iter().any(|(k, _)| *k == "dur"); let has_dur = params.iter().any(|(k, _)| *k == "dur");
let has_release = params.iter().any(|(k, _)| *k == "release");
let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime"); let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime");
if let Some(s) = sound { if let Some(s) = sound {
@@ -1772,11 +1775,12 @@ fn emit_output(
} }
} }
if nudge_secs > 0.0 { if nudge_secs.abs() > 1e-9 {
if !out.ends_with('/') { if !out.ends_with('/') {
out.push('/'); out.push('/');
} }
let _ = write!(&mut out, "delta/{nudge_secs}"); let delta_ticks = (nudge_secs * sr).round() as i64;
let _ = write!(&mut out, "delta/{delta_ticks}");
} }
if !has_dur { if !has_dur {
@@ -1786,6 +1790,13 @@ fn emit_output(
let _ = write!(&mut out, "dur/{}", step_duration * 4.0); let _ = write!(&mut out, "dur/{}", step_duration * 4.0);
} }
if !has_release {
if !out.ends_with('/') {
out.push('/');
}
let _ = write!(&mut out, "release/{}", 12.0 * step_duration);
}
if sound.is_some() && delaytime_idx.is_none() { if sound.is_some() && delaytime_idx.is_none() {
if !out.ends_with('/') { if !out.ends_with('/') {
out.push('/'); out.push('/');

View File

@@ -166,6 +166,16 @@ pub(super) const WORDS: &[Word] = &[
compile: Param, compile: Param,
varargs: true, varargs: true,
}, },
Word {
name: "stretch",
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Time stretch factor (pitch-independent)",
example: "2 stretch",
compile: Param,
varargs: true,
},
Word { Word {
name: "begin", name: "begin",
aliases: &[], aliases: &[],

View File

@@ -30,6 +30,7 @@ pub mod transform;
use ratatui::style::Color; use ratatui::style::Color;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc;
/// Entry in the theme registry: id, display label, and palette constructor. /// Entry in the theme registry: id, display label, and palette constructor.
pub struct ThemeEntry { pub struct ThemeEntry {
@@ -66,17 +67,17 @@ pub const THEMES: &[ThemeEntry] = &[
]; ];
thread_local! { thread_local! {
static CURRENT_THEME: RefCell<ThemeColors> = RefCell::new(build::build(&(THEMES[0].palette)())); static CURRENT_THEME: RefCell<Rc<ThemeColors>> = RefCell::new(Rc::new(build::build(&(THEMES[0].palette)())));
} }
/// Return the current thread-local theme. /// Return the current thread-local theme (cheap Rc clone, not a deep copy).
pub fn get() -> ThemeColors { pub fn get() -> Rc<ThemeColors> {
CURRENT_THEME.with(|t| t.borrow().clone()) CURRENT_THEME.with(|t| Rc::clone(&t.borrow()))
} }
/// Set the current thread-local theme. /// Set the current thread-local theme.
pub fn set(theme: ThemeColors) { pub fn set(theme: ThemeColors) {
CURRENT_THEME.with(|t| *t.borrow_mut() = theme); CURRENT_THEME.with(|t| *t.borrow_mut() = Rc::new(theme));
} }
/// Complete set of resolved colors for all UI components. /// Complete set of resolved colors for all UI components.

View File

@@ -50,6 +50,7 @@ snare sound 0.5 speed . ( play snare at half speed )
| `slice` | 1+ | Divide sample into N equal slices | | `slice` | 1+ | Divide sample into N equal slices |
| `pick` | 0+ | Select which slice to play (0-indexed, wraps) | | `pick` | 0+ | Select which slice to play (0-indexed, wraps) |
| `speed` | any | Playback speed multiplier | | `speed` | any | Playback speed multiplier |
| `stretch` | 0+ | Time-stretch factor (pitch-independent) |
| `freq` | Hz | Base frequency for pitch tracking | | `freq` | Hz | Base frequency for pitch tracking |
| `fit` | seconds | Stretch/compress sample to fit duration | | `fit` | seconds | Stretch/compress sample to fit duration |
| `cut` | 0+ | Choke group | | `cut` | 0+ | Choke group |
@@ -105,6 +106,24 @@ crow sound -1 speed . ( play backwards at nominal speed )
crow sound -4 speed . ( play backwards, 4 times faster ) crow sound -4 speed . ( play backwards, 4 times faster )
``` ```
## Time Stretching
The `stretch` parameter changes sample duration without affecting pitch, using a phase vocoder algorithm. This contrasts with `speed`, which changes both tempo and pitch together.
```forth
kick sound 2 stretch . ( twice as long, same pitch )
kick sound 0.5 stretch . ( half as long, same pitch )
kick sound 0 stretch . ( freeze — holds at current position )
```
Combine with `slice` and `pick` for pitch-locked breakbeat manipulation:
```forth
break sound 8 slice step pick 2 stretch . ( sliced break, stretched x2, original pitch )
```
Reverse playback is not available with `stretch` — use `speed` for that.
## Fitting to Duration ## Fitting to Duration
The `fit` parameter stretches or compresses a sample to match a target duration in seconds. This adjusts speed automatically. The `fit` parameter stretches or compresses a sample to match a target duration in seconds. This adjusts speed automatically.

View File

@@ -14,7 +14,7 @@ use arc_swap::ArcSwap;
use parking_lot::Mutex; use parking_lot::Mutex;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::SeedableRng; use rand::SeedableRng;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use cagire_ratatui::CompletionCandidate; use cagire_ratatui::CompletionCandidate;
@@ -69,6 +69,7 @@ pub struct App {
pub sample_browser: Option<SampleBrowserState>, pub sample_browser: Option<SampleBrowserState>,
pub midi: MidiState, pub midi: MidiState,
pub plugin_mode: bool, pub plugin_mode: bool,
pub dict_keys: HashSet<String>,
} }
impl Default for App { impl Default for App {
@@ -126,6 +127,7 @@ impl App {
sample_browser: None, sample_browser: None,
midi: MidiState::new(), midi: MidiState::new(),
plugin_mode, plugin_mode,
dict_keys: HashSet::new(),
} }
} }

View File

@@ -34,6 +34,7 @@ impl App {
speed, speed,
fill: false, fill: false,
nudge_secs: 0.0, nudge_secs: 0.0,
sr: 0.0,
cc_access: None, cc_access: None,
speed_key: "", speed_key: "",
mouse_x: 0.5, mouse_x: 0.5,
@@ -148,7 +149,9 @@ impl App {
} }
let ctx = self.create_step_context(0, link); let ctx = self.create_step_context(0, link);
match self.script_engine.evaluate(prelude, &ctx) { match self.script_engine.evaluate(prelude, &ctx) {
Ok(_) => {} Ok(_) => {
self.dict_keys = self.dict.lock().keys().cloned().collect();
}
Err(e) => { Err(e) => {
let fallback = format!("Bank {}", bank + 1); let fallback = format!("Bank {}", bank + 1);
let bank_name = self.project_state.project.banks[bank] let bank_name = self.project_state.project.banks[bank]
@@ -201,6 +204,7 @@ impl App {
} }
} }
} }
self.dict_keys = self.dict.lock().keys().cloned().collect();
self.ui.flash("Preludes evaluated", 150, FlashKind::Info); self.ui.flash("Preludes evaluated", 150, FlashKind::Info);
} }

View File

@@ -616,7 +616,6 @@ fn load_icon() -> egui::IconData {
} }
fn main() -> eframe::Result<()> { fn main() -> eframe::Result<()> {
#[cfg(unix)]
cagire::engine::realtime::lock_memory(); cagire::engine::realtime::lock_memory();
let args = Args::parse(); let args = Args::parse();

View File

@@ -264,10 +264,6 @@ use cpal::Stream;
use crossbeam_channel::{Receiver, Sender}; use crossbeam_channel::{Receiver, Sender};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use doux::{Engine, EngineMetrics}; use doux::{Engine, EngineMetrics};
#[cfg(feature = "cli")]
use std::collections::VecDeque;
#[cfg(feature = "cli")]
use std::sync::Mutex;
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
use super::AudioCommand; use super::AudioCommand;
@@ -360,8 +356,7 @@ pub fn build_stream(
let registry = Arc::clone(&engine.sample_registry); let registry = Arc::clone(&engine.sample_registry);
const INPUT_BUFFER_SIZE: usize = 8192; const INPUT_BUFFER_SIZE: usize = 8192;
let input_buffer: Arc<Mutex<VecDeque<f32>>> = let (input_producer, input_consumer) = HeapRb::<f32>::new(INPUT_BUFFER_SIZE).split();
Arc::new(Mutex::new(VecDeque::with_capacity(INPUT_BUFFER_SIZE)));
let input_device = config let input_device = config
.input_device .input_device
@@ -399,17 +394,12 @@ pub fn build_stream(
input_cfg.channels(), input_cfg.channels(),
input_cfg.sample_rate() input_cfg.sample_rate()
); );
let buf = Arc::clone(&input_buffer); let mut input_producer = input_producer;
let stream = dev let stream = dev
.build_input_stream( .build_input_stream(
&input_cfg.into(), &input_cfg.into(),
move |data: &[f32], _| { move |data: &[f32], _| {
let mut b = buf.lock().unwrap(); input_producer.push_slice(data);
b.extend(data.iter().copied());
let excess = b.len().saturating_sub(INPUT_BUFFER_SIZE);
if excess > 0 {
drop(b.drain(..excess));
}
}, },
{ {
let device_lost = Arc::clone(&device_lost); let device_lost = Arc::clone(&device_lost);
@@ -436,15 +426,18 @@ pub fn build_stream(
let mut cmd_buffer = String::with_capacity(256); let mut cmd_buffer = String::with_capacity(256);
let mut rt_set = false; let mut rt_set = false;
let mut live_scratch = vec![0.0f32; 4096]; let mut live_scratch = vec![0.0f32; 4096];
let input_buf_clone = Arc::clone(&input_buffer); let mut input_consumer = input_consumer;
let stream = device let stream = device
.build_output_stream( .build_output_stream(
&stream_config, &stream_config,
move |data: &mut [f32], _| { move |data: &mut [f32], _| {
if !rt_set { if !rt_set {
super::realtime::set_realtime_priority(); let ok = super::realtime::set_realtime_priority();
rt_set = true; rt_set = true;
if !ok {
super::realtime::warn_no_rt("audio");
}
} }
let buffer_samples = data.len() / channels; let buffer_samples = data.len() / channels;
@@ -488,29 +481,28 @@ pub fn build_stream(
if live_scratch.len() < stereo_len { if live_scratch.len() < stereo_len {
live_scratch.resize(stereo_len, 0.0); live_scratch.resize(stereo_len, 0.0);
} }
let mut buf = input_buf_clone.lock().unwrap();
match input_channels { match input_channels {
0 => { 0 => {
live_scratch[..stereo_len].fill(0.0); live_scratch[..stereo_len].fill(0.0);
} }
1 => { 1 => {
for i in 0..buffer_samples { for i in 0..buffer_samples {
let s = buf.pop_front().unwrap_or(0.0); let s = input_consumer.try_pop().unwrap_or(0.0);
live_scratch[i * 2] = s; live_scratch[i * 2] = s;
live_scratch[i * 2 + 1] = s; live_scratch[i * 2 + 1] = s;
} }
} }
2 => { 2 => {
for sample in &mut live_scratch[..stereo_len] { for sample in &mut live_scratch[..stereo_len] {
*sample = buf.pop_front().unwrap_or(0.0); *sample = input_consumer.try_pop().unwrap_or(0.0);
} }
} }
_ => { _ => {
for i in 0..buffer_samples { for i in 0..buffer_samples {
let l = buf.pop_front().unwrap_or(0.0); let l = input_consumer.try_pop().unwrap_or(0.0);
let r = buf.pop_front().unwrap_or(0.0); let r = input_consumer.try_pop().unwrap_or(0.0);
for _ in 2..input_channels { for _ in 2..input_channels {
buf.pop_front(); input_consumer.try_pop();
} }
live_scratch[i * 2] = l; live_scratch[i * 2] = l;
live_scratch[i * 2 + 1] = r; live_scratch[i * 2 + 1] = r;
@@ -518,11 +510,10 @@ pub fn build_stream(
} }
} }
// Discard excess if input produced more than we consumed // Discard excess if input produced more than we consumed
let excess = buf.len().saturating_sub(INPUT_BUFFER_SIZE / 2); let excess = input_consumer.occupied_len().saturating_sub(INPUT_BUFFER_SIZE / 2);
if excess > 0 { for _ in 0..excess {
drop(buf.drain(..excess)); input_consumer.try_pop();
} }
drop(buf);
engine.metrics.load.set_buffer_time(buffer_time_ns); engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(data, &[], &live_scratch[..stereo_len]); engine.process_block(data, &[], &live_scratch[..stereo_len]);

View File

@@ -6,7 +6,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use super::link::LinkState; use super::link::LinkState;
use super::realtime::{precise_sleep_us, set_realtime_priority}; use super::realtime::{precise_sleep_us, set_realtime_priority, warn_no_rt};
use super::sequencer::MidiCommand; use super::sequencer::MidiCommand;
use super::timing::SyncTime; use super::timing::SyncTime;
@@ -55,10 +55,8 @@ pub fn dispatcher_loop(
link: Arc<LinkState>, link: Arc<LinkState>,
) { ) {
let has_rt = set_realtime_priority(); let has_rt = set_realtime_priority();
#[cfg(target_os = "linux")]
if !has_rt { if !has_rt {
eprintln!("[cagire] Warning: Could not set realtime priority for dispatcher thread."); warn_no_rt("dispatcher");
} }
let mut queue: BinaryHeap<TimedMidiCommand> = BinaryHeap::with_capacity(256); let mut queue: BinaryHeap<TimedMidiCommand> = BinaryHeap::with_capacity(256);

View File

@@ -148,6 +148,17 @@ pub fn set_realtime_priority() -> bool {
false false
} }
#[cfg(target_os = "linux")]
pub fn warn_no_rt(thread_name: &str) {
eprintln!(
"[cagire] Warning: No realtime priority for {thread_name} thread. \
Add user to 'audio' group and configure rtprio limits."
);
}
#[cfg(not(target_os = "linux"))]
pub fn warn_no_rt(_thread_name: &str) {}
/// High-precision sleep using clock_nanosleep on Linux. /// High-precision sleep using clock_nanosleep on Linux.
/// Uses monotonic clock for jitter-free sleeping. /// Uses monotonic clock for jitter-free sleeping.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]

View File

@@ -1007,6 +1007,7 @@ impl SequencerState {
speed: speed_mult, speed: speed_mult,
fill, fill,
nudge_secs, nudge_secs,
sr,
cc_access: self.cc_access.as_deref(), cc_access: self.cc_access.as_deref(),
speed_key, speed_key,
mouse_x, mouse_x,
@@ -1128,6 +1129,7 @@ impl SequencerState {
speed: speed_mult, speed: speed_mult,
fill, fill,
nudge_secs, nudge_secs,
sr,
cc_access: self.cc_access.as_deref(), cc_access: self.cc_access.as_deref(),
speed_key: "", speed_key: "",
mouse_x, mouse_x,
@@ -1266,13 +1268,16 @@ fn sequencer_loop(
) { ) {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
set_realtime_priority(); let has_rt = set_realtime_priority();
if !has_rt {
super::realtime::warn_no_rt("sequencer");
}
let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access); let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
// Lookahead window: ~20ms expressed in beats, recomputed each tick // Lookahead window: 20ms normally, 40ms on Linux without RT to compensate for jitter
const LOOKAHEAD_SECS: f64 = 0.02; let lookahead_secs: f64 = if cfg!(target_os = "linux") && !has_rt { 0.04 } else { 0.02 };
// Wake cadence: how long to sleep between scheduling passes // Wake cadence: how long to sleep between scheduling passes
const WAKE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(3); const WAKE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(3);
@@ -1302,7 +1307,7 @@ fn sequencer_loop(
let tempo = state.tempo(); let tempo = state.tempo();
let lookahead_beats = if tempo > 0.0 { let lookahead_beats = if tempo > 0.0 {
LOOKAHEAD_SECS * tempo / 60.0 lookahead_secs * tempo / 60.0
} else { } else {
0.0 0.0
}; };

View File

@@ -76,7 +76,6 @@ fn main() -> io::Result<()> {
#[cfg(unix)] #[cfg(unix)]
let mut stderr_pipe = redirect_stderr(); let mut stderr_pipe = redirect_stderr();
#[cfg(unix)]
engine::realtime::lock_memory(); engine::realtime::lock_memory();
let args = Args::parse(); let args = Args::parse();

View File

@@ -75,7 +75,6 @@ fn render_top_layout(
idx += 1; idx += 1;
} }
if has_preview { if has_preview {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let has_prelude = !app.project_state.project.prelude.trim().is_empty() let has_prelude = !app.project_state.project.prelude.trim().is_empty()
|| !app.project_state.project.banks[app.editor_ctx.bank] || !app.project_state.project.banks[app.editor_ctx.bank]
.prelude .prelude
@@ -84,10 +83,10 @@ fn render_top_layout(
if has_prelude { if has_prelude {
let [script_area, prelude_area] = let [script_area, prelude_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(areas[idx]); Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(areas[idx]);
render_script_preview(frame, app, snapshot, &user_words, script_area); render_script_preview(frame, app, snapshot, &app.dict_keys, script_area);
render_prelude_preview(frame, app, &user_words, prelude_area); render_prelude_preview(frame, app, &app.dict_keys, prelude_area);
} else { } else {
render_script_preview(frame, app, snapshot, &user_words, areas[idx]); render_script_preview(frame, app, snapshot, &app.dict_keys, areas[idx]);
} }
idx += 1; idx += 1;
} }
@@ -186,19 +185,12 @@ fn render_viz_area(
Orientation::Horizontal Orientation::Horizontal
}; };
let user_words_once: Option<HashSet<String>> = if panels.iter().any(|p| matches!(p, VizPanel::Preview)) {
Some(app.dict.lock().keys().cloned().collect())
} else {
None
};
for (panel, panel_area) in panels.iter().zip(areas.iter()) { for (panel, panel_area) in panels.iter().zip(areas.iter()) {
match panel { match panel {
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation), VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area), VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
VizPanel::Lissajous => render_lissajous(frame, app, *panel_area), VizPanel::Lissajous => render_lissajous(frame, app, *panel_area),
VizPanel::Preview => { VizPanel::Preview => {
let user_words = user_words_once.as_ref().expect("user_words initialized");
let has_prelude = !app.project_state.project.prelude.trim().is_empty() let has_prelude = !app.project_state.project.prelude.trim().is_empty()
|| !app.project_state.project.banks[app.editor_ctx.bank] || !app.project_state.project.banks[app.editor_ctx.bank]
.prelude .prelude
@@ -212,10 +204,10 @@ fn render_viz_area(
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
.areas(*panel_area) .areas(*panel_area)
}; };
render_script_preview(frame, app, snapshot, user_words, script_area); render_script_preview(frame, app, snapshot, &app.dict_keys, script_area);
render_prelude_preview(frame, app, user_words, prelude_area); render_prelude_preview(frame, app, &app.dict_keys, prelude_area);
} else { } else {
render_script_preview(frame, app, snapshot, user_words, *panel_area); render_script_preview(frame, app, snapshot, &app.dict_keys, *panel_area);
} }
} }
} }

View File

@@ -675,8 +675,7 @@ fn render_modal(
.render_centered(frame, term) .render_centered(frame, term)
} }
Modal::Editor => { Modal::Editor => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect(); render_modal_editor(frame, app, snapshot, &app.dict_keys, term)
render_modal_editor(frame, app, snapshot, &user_words, term)
} }
Modal::PatternProps { Modal::PatternProps {
bank, bank,

View File

@@ -1,5 +1,3 @@
use std::collections::HashSet;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
@@ -47,7 +45,7 @@ fn render_editor(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
let editor_area = Rect::new(inner.x, inner.y, inner.width, editor_height); let editor_area = Rect::new(inner.x, inner.y, inner.width, editor_height);
let hint_area = Rect::new(inner.x, inner.y + editor_height, inner.width, 1); let hint_area = Rect::new(inner.x, inner.y + editor_height, inner.width, 1);
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect(); let user_words = &app.dict_keys;
let trace = if app.ui.runtime_highlight && app.playback.playing { let trace = if app.ui.runtime_highlight && app.playback.playing {
snapshot.script_trace() snapshot.script_trace()
@@ -77,7 +75,7 @@ fn render_editor(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
), ),
None => (Vec::new(), Vec::new(), Vec::new()), None => (Vec::new(), Vec::new(), Vec::new()),
}; };
highlight::highlight_line_with_runtime(line, &exec, &sel, &res, &user_words) highlight::highlight_line_with_runtime(line, &exec, &sel, &res, user_words)
}; };
app.script_editor.editor.render(frame, editor_area, &highlighter); app.script_editor.editor.render(frame, editor_area, &highlighter);
@@ -142,7 +140,6 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
idx += 1; idx += 1;
} }
if has_prelude { if has_prelude {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect(); super::main_view::render_prelude_preview(frame, app, &app.dict_keys, areas[idx]);
super::main_view::render_prelude_preview(frame, app, &user_words, areas[idx]);
} }
} }

View File

@@ -20,6 +20,7 @@ pub fn default_ctx() -> StepContext<'static> {
speed: 1.0, speed: 1.0,
fill: false, fill: false,
nudge_secs: 0.0, nudge_secs: 0.0,
sr: 48000.0,
cc_access: None, cc_access: None,
speed_key: "__speed_0_0__", speed_key: "__speed_0_0__",
mouse_x: 0.5, mouse_x: 0.5,

View File

@@ -144,7 +144,8 @@ fn at_single_delta() {
let outputs = expect_outputs(r#"0.5 at "kick" snd ."#, 1); let outputs = expect_outputs(r#"0.5 at "kick" snd ."#, 1);
let deltas = get_deltas(&outputs); let deltas = get_deltas(&outputs);
let step_dur = 0.125; let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[0]); let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], (0.5 * step_dur * sr).round()), "expected delta at 0.5 of step, got {}", deltas[0]);
} }
#[test] #[test]
@@ -152,8 +153,9 @@ fn at_list_deltas() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd ."#, 2); let outputs = expect_outputs(r#"0 0.5 at "kick" snd ."#, 2);
let deltas = get_deltas(&outputs); let deltas = get_deltas(&outputs);
let step_dur = 0.125; let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]); assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
assert!(approx_eq(deltas[1], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[1]); assert!(approx_eq(deltas[1], (0.5 * step_dur * sr).round()), "expected delta at 0.5 of step, got {}", deltas[1]);
} }
#[test] #[test]
@@ -161,9 +163,10 @@ fn at_three_deltas() {
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" snd ."#, 3); let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" snd ."#, 3);
let deltas = get_deltas(&outputs); let deltas = get_deltas(&outputs);
let step_dur = 0.125; let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0"); assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
assert!((deltas[1] - 0.33 * step_dur).abs() < 0.001, "expected delta at 0.33 of step"); assert!(approx_eq(deltas[1], (0.33 * step_dur * sr).round()), "expected delta at 0.33 of step");
assert!((deltas[2] - 0.67 * step_dur).abs() < 0.001, "expected delta at 0.67 of step"); assert!(approx_eq(deltas[2], (0.67 * step_dur * sr).round()), "expected delta at 0.67 of step");
} }
#[test] #[test]
@@ -234,10 +237,11 @@ fn arp_auto_subdivide() {
assert!(approx_eq(notes[3], 71.0)); assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs); let deltas = get_deltas(&outputs);
let step_dur = 0.125; let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0)); assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25 * step_dur)); assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], 0.5 * step_dur)); assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[3], 0.75 * step_dur)); assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round()));
} }
#[test] #[test]
@@ -250,10 +254,11 @@ fn arp_with_explicit_at() {
assert!(approx_eq(notes[3], 71.0)); assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs); let deltas = get_deltas(&outputs);
let step_dur = 0.125; let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0)); assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25 * step_dur)); assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], 0.5 * step_dur)); assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[3], 0.75 * step_dur)); assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round()));
} }
#[test] #[test]
@@ -273,10 +278,11 @@ fn arp_fewer_deltas_than_notes() {
assert!(approx_eq(notes[3], 71.0)); assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs); let deltas = get_deltas(&outputs);
let step_dur = 0.125; let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0)); assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.5 * step_dur)); assert!(approx_eq(deltas[1], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0 assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0
assert!(approx_eq(deltas[3], 0.5 * step_dur)); // wraps: 3 % 2 = 1 assert!(approx_eq(deltas[3], (0.5 * step_dur * sr).round())); // wraps: 3 % 2 = 1
} }
#[test] #[test]