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
if: inputs.build-packages
shell: bash
run: |
mkdir -p staging/clap staging/vst3
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"]
[workspace.package]
version = "0.1.2"
version = "0.1.3"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"

View File

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

View File

@@ -302,6 +302,7 @@ impl Forth {
&resolved_params,
ctx.step_duration(),
delta_secs,
ctx.sr,
outputs,
);
Ok(resolved_sound_val.map(|v| v.into_owned()))
@@ -1542,7 +1543,7 @@ impl Forth {
.unwrap_or(0);
let dev =
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}")
} else {
String::new()
@@ -1741,6 +1742,7 @@ fn emit_output(
params: &[(&str, String)],
step_duration: f64,
nudge_secs: f64,
sr: f64,
outputs: &mut Vec<String>,
) {
use std::fmt::Write;
@@ -1748,6 +1750,7 @@ fn emit_output(
out.push('/');
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");
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('/') {
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 {
@@ -1786,6 +1790,13 @@ fn emit_output(
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 !out.ends_with('/') {
out.push('/');

View File

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

View File

@@ -30,6 +30,7 @@ pub mod transform;
use ratatui::style::Color;
use std::cell::RefCell;
use std::rc::Rc;
/// Entry in the theme registry: id, display label, and palette constructor.
pub struct ThemeEntry {
@@ -66,17 +67,17 @@ pub const THEMES: &[ThemeEntry] = &[
];
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.
pub fn get() -> ThemeColors {
CURRENT_THEME.with(|t| t.borrow().clone())
/// Return the current thread-local theme (cheap Rc clone, not a deep copy).
pub fn get() -> Rc<ThemeColors> {
CURRENT_THEME.with(|t| Rc::clone(&t.borrow()))
}
/// Set the current thread-local theme.
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.

View File

@@ -50,6 +50,7 @@ snare sound 0.5 speed . ( play snare at half speed )
| `slice` | 1+ | Divide sample into N equal slices |
| `pick` | 0+ | Select which slice to play (0-indexed, wraps) |
| `speed` | any | Playback speed multiplier |
| `stretch` | 0+ | Time-stretch factor (pitch-independent) |
| `freq` | Hz | Base frequency for pitch tracking |
| `fit` | seconds | Stretch/compress sample to fit duration |
| `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 )
```
## 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
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 rand::rngs::StdRng;
use rand::SeedableRng;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use cagire_ratatui::CompletionCandidate;
@@ -69,6 +69,7 @@ pub struct App {
pub sample_browser: Option<SampleBrowserState>,
pub midi: MidiState,
pub plugin_mode: bool,
pub dict_keys: HashSet<String>,
}
impl Default for App {
@@ -126,6 +127,7 @@ impl App {
sample_browser: None,
midi: MidiState::new(),
plugin_mode,
dict_keys: HashSet::new(),
}
}

View File

@@ -34,6 +34,7 @@ impl App {
speed,
fill: false,
nudge_secs: 0.0,
sr: 0.0,
cc_access: None,
speed_key: "",
mouse_x: 0.5,
@@ -148,7 +149,9 @@ impl App {
}
let ctx = self.create_step_context(0, link);
match self.script_engine.evaluate(prelude, &ctx) {
Ok(_) => {}
Ok(_) => {
self.dict_keys = self.dict.lock().keys().cloned().collect();
}
Err(e) => {
let fallback = format!("Bank {}", bank + 1);
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);
}

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ use std::sync::Arc;
use std::time::Duration;
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::timing::SyncTime;
@@ -55,10 +55,8 @@ pub fn dispatcher_loop(
link: Arc<LinkState>,
) {
let has_rt = set_realtime_priority();
#[cfg(target_os = "linux")]
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);

View File

@@ -148,6 +148,17 @@ pub fn set_realtime_priority() -> bool {
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.
/// Uses monotonic clock for jitter-free sleeping.
#[cfg(target_os = "linux")]

View File

@@ -1007,6 +1007,7 @@ impl SequencerState {
speed: speed_mult,
fill,
nudge_secs,
sr,
cc_access: self.cc_access.as_deref(),
speed_key,
mouse_x,
@@ -1128,6 +1129,7 @@ impl SequencerState {
speed: speed_mult,
fill,
nudge_secs,
sr,
cc_access: self.cc_access.as_deref(),
speed_key: "",
mouse_x,
@@ -1266,13 +1268,16 @@ fn sequencer_loop(
) {
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 mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
// Lookahead window: ~20ms expressed in beats, recomputed each tick
const LOOKAHEAD_SECS: f64 = 0.02;
// Lookahead window: 20ms normally, 40ms on Linux without RT to compensate for jitter
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
const WAKE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(3);
@@ -1302,7 +1307,7 @@ fn sequencer_loop(
let tempo = state.tempo();
let lookahead_beats = if tempo > 0.0 {
LOOKAHEAD_SECS * tempo / 60.0
lookahead_secs * tempo / 60.0
} else {
0.0
};

View File

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

View File

@@ -75,7 +75,6 @@ fn render_top_layout(
idx += 1;
}
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()
|| !app.project_state.project.banks[app.editor_ctx.bank]
.prelude
@@ -84,10 +83,10 @@ fn render_top_layout(
if has_prelude {
let [script_area, prelude_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(areas[idx]);
render_script_preview(frame, app, snapshot, &user_words, script_area);
render_prelude_preview(frame, app, &user_words, prelude_area);
render_script_preview(frame, app, snapshot, &app.dict_keys, script_area);
render_prelude_preview(frame, app, &app.dict_keys, prelude_area);
} else {
render_script_preview(frame, app, snapshot, &user_words, areas[idx]);
render_script_preview(frame, app, snapshot, &app.dict_keys, areas[idx]);
}
idx += 1;
}
@@ -186,19 +185,12 @@ fn render_viz_area(
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()) {
match panel {
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
VizPanel::Lissajous => render_lissajous(frame, app, *panel_area),
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()
|| !app.project_state.project.banks[app.editor_ctx.bank]
.prelude
@@ -212,10 +204,10 @@ fn render_viz_area(
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
.areas(*panel_area)
};
render_script_preview(frame, app, snapshot, user_words, script_area);
render_prelude_preview(frame, app, user_words, prelude_area);
render_script_preview(frame, app, snapshot, &app.dict_keys, script_area);
render_prelude_preview(frame, app, &app.dict_keys, prelude_area);
} 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)
}
Modal::Editor => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_editor(frame, app, snapshot, &user_words, term)
render_modal_editor(frame, app, snapshot, &app.dict_keys, term)
}
Modal::PatternProps {
bank,

View File

@@ -1,5 +1,3 @@
use std::collections::HashSet;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Style;
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 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 {
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()),
};
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);
@@ -142,7 +140,6 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) {
idx += 1;
}
if has_prelude {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
super::main_view::render_prelude_preview(frame, app, &user_words, areas[idx]);
super::main_view::render_prelude_preview(frame, app, &app.dict_keys, areas[idx]);
}
}

View File

@@ -20,6 +20,7 @@ pub fn default_ctx() -> StepContext<'static> {
speed: 1.0,
fill: false,
nudge_secs: 0.0,
sr: 48000.0,
cc_access: None,
speed_key: "__speed_0_0__",
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 deltas = get_deltas(&outputs);
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]
@@ -152,8 +153,9 @@ fn at_list_deltas() {
let outputs = expect_outputs(r#"0 0.5 at "kick" snd ."#, 2);
let deltas = get_deltas(&outputs);
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[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]
@@ -161,9 +163,10 @@ fn at_three_deltas() {
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" snd ."#, 3);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.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!((deltas[2] - 0.67 * step_dur).abs() < 0.001, "expected delta at 0.67 of step");
assert!(approx_eq(deltas[1], (0.33 * step_dur * sr).round()), "expected delta at 0.33 of step");
assert!(approx_eq(deltas[2], (0.67 * step_dur * sr).round()), "expected delta at 0.67 of step");
}
#[test]
@@ -234,10 +237,11 @@ fn arp_auto_subdivide() {
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25 * step_dur));
assert!(approx_eq(deltas[2], 0.5 * step_dur));
assert!(approx_eq(deltas[3], 0.75 * step_dur));
assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round()));
}
#[test]
@@ -250,10 +254,11 @@ fn arp_with_explicit_at() {
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25 * step_dur));
assert!(approx_eq(deltas[2], 0.5 * step_dur));
assert!(approx_eq(deltas[3], 0.75 * step_dur));
assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round()));
}
#[test]
@@ -273,10 +278,11 @@ fn arp_fewer_deltas_than_notes() {
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
let sr: f64 = 48000.0;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.5 * step_dur));
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[1], (0.5 * step_dur * sr).round()));
assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0
assert!(approx_eq(deltas[3], (0.5 * step_dur * sr).round())); // wraps: 3 % 2 = 1
}
#[test]