3 Commits

Author SHA1 Message Date
4772b02f77 Feat: fix scope / spectrum / vumeter
Some checks failed
Deploy Website / deploy (push) Failing after 6s
2026-01-30 21:50:00 +01:00
4049c7787c Feat: extend CI to cover desktop 2026-01-30 21:19:48 +01:00
4c635500dd Feat: extend CI to cover desktop 2026-01-30 20:34:34 +01:00
8 changed files with 114 additions and 34 deletions

View File

@@ -56,7 +56,8 @@ jobs:
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
- name: Install dependencies (macOS) - name: Install dependencies (macOS)
if: runner.os == 'macOS' if: runner.os == 'macOS'
@@ -71,6 +72,9 @@ jobs:
- name: Build - name: Build
run: cargo build --release --target ${{ matrix.target }} run: cargo build --release --target ${{ matrix.target }}
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Run tests - name: Run tests
run: cargo test --target ${{ matrix.target }} run: cargo test --target ${{ matrix.target }}
@@ -91,6 +95,20 @@ jobs:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire.exe path: target/${{ matrix.target }}/release/cagire.exe
- name: Upload desktop artifact (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop
- name: Upload desktop artifact (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop.exe
release: release:
needs: build needs: build
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
@@ -110,10 +128,19 @@ jobs:
mkdir -p release mkdir -p release
for dir in artifacts/*/; do for dir in artifacts/*/; do
name=$(basename "$dir") name=$(basename "$dir")
if [ -f "$dir/cagire.exe" ]; then if [[ "$name" == *-desktop ]]; then
cp "$dir/cagire.exe" "release/${name}.exe" base="${name%-desktop}"
elif [ -f "$dir/cagire" ]; then if [ -f "$dir/cagire-desktop.exe" ]; then
cp "$dir/cagire" "release/${name}" cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
elif [ -f "$dir/cagire-desktop" ]; then
cp "$dir/cagire-desktop" "release/${base}-desktop"
fi
else
if [ -f "$dir/cagire.exe" ]; then
cp "$dir/cagire.exe" "release/${name}.exe"
elif [ -f "$dir/cagire" ]; then
cp "$dir/cagire" "release/${name}"
fi
fi fi
done done

View File

@@ -41,6 +41,7 @@ pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>; pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
pub type Rng = Arc<Mutex<StdRng>>; pub type Rng = Arc<Mutex<StdRng>>;
pub type Stack = Arc<Mutex<Vec<Value>>>; pub type Stack = Arc<Mutex<Vec<Value>>>;
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]);
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Value { pub enum Value {
@@ -140,8 +141,12 @@ impl CmdRegister {
&self.deltas &self.deltas
} }
pub(super) fn snapshot(&self) -> Option<(&Value, &[(String, Value)])> { pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
self.sound.as_ref().map(|s| (s, self.params.as_slice())) if self.sound.is_some() || !self.params.is_empty() {
Some((self.sound.as_ref(), self.params.as_slice()))
} else {
None
}
} }
pub(super) fn clear(&mut self) { pub(super) fn clear(&mut self) {

View File

@@ -147,9 +147,12 @@ impl Forth {
}; };
let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, outputs: &mut Vec<String>| -> Result<Option<Value>, String> { let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, outputs: &mut Vec<String>| -> Result<Option<Value>, String> {
let (sound_val, params) = cmd.snapshot().ok_or("no sound set")?; let (sound_opt, params) = cmd.snapshot().ok_or("nothing to emit")?;
let resolved_sound_val = resolve_cycling(sound_val, emit_idx); let resolved_sound_val = sound_opt.map(|sv| resolve_cycling(sv, emit_idx));
let sound = resolved_sound_val.as_str()?.to_string(); let sound_str = match &resolved_sound_val {
Some(v) => Some(v.as_str()?.to_string()),
None => None,
};
let resolved_params: Vec<(String, String)> = let resolved_params: Vec<(String, String)> =
params.iter().map(|(k, v)| { params.iter().map(|(k, v)| {
let resolved = resolve_cycling(v, emit_idx); let resolved = resolve_cycling(v, emit_idx);
@@ -162,8 +165,8 @@ impl Forth {
} }
(k.clone(), resolved.to_param_string()) (k.clone(), resolved.to_param_string())
}).collect(); }).collect();
emit_output(&sound, &resolved_params, ctx.step_duration(), delta_secs, outputs); emit_output(sound_str.as_deref(), &resolved_params, ctx.step_duration(), delta_secs, outputs);
Ok(Some(resolved_sound_val.into_owned())) Ok(resolved_sound_val.map(|v| v.into_owned()))
}; };
while pc < ops.len() { while pc < ops.len() {
@@ -796,25 +799,33 @@ fn is_tempo_scaled_param(name: &str) -> bool {
} }
fn emit_output( fn emit_output(
sound: &str, sound: Option<&str>,
params: &[(String, String)], params: &[(String, String)],
step_duration: f64, step_duration: f64,
nudge_secs: f64, nudge_secs: f64,
outputs: &mut Vec<String>, outputs: &mut Vec<String>,
) { ) {
let mut pairs = vec![("sound".into(), sound.to_string())]; let mut pairs: Vec<(String, String)> = if let Some(s) = sound {
vec![("sound".into(), s.to_string())]
} else {
vec![]
};
pairs.extend(params.iter().cloned()); pairs.extend(params.iter().cloned());
if nudge_secs > 0.0 { if nudge_secs > 0.0 {
pairs.push(("delta".into(), nudge_secs.to_string())); pairs.push(("delta".into(), nudge_secs.to_string()));
} }
if !pairs.iter().any(|(k, _)| k == "dur") { // Only add default dur if there's a sound (new voice)
if sound.is_some() && !pairs.iter().any(|(k, _)| k == "dur") {
pairs.push(("dur".into(), step_duration.to_string())); pairs.push(("dur".into(), step_duration.to_string()));
} }
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") { // Only add default delaytime if there's a sound (new voice)
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0); if sound.is_some() {
pairs[idx].1 = (ratio * step_duration).to_string(); if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
} else { let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs.push(("delaytime".into(), step_duration.to_string())); pairs[idx].1 = (ratio * step_duration).to_string();
} else {
pairs.push(("delaytime".into(), step_duration.to_string()));
}
} }
for pair in &mut pairs { for pair in &mut pairs {
if is_tempo_scaled_param(&pair.0) { if is_tempo_scaled_param(&pair.0) {

View File

@@ -64,6 +64,10 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
let fine_width = width * 2; let fine_width = width * 2;
let fine_height = height * 4; let fine_height = height * 4;
// Auto-scale: find peak amplitude and normalize to fill height
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
PATTERNS.with(|p| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
let size = width * height; let size = width * height;
@@ -72,7 +76,7 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
for fine_x in 0..fine_width { for fine_x in 0..fine_width {
let sample_idx = (fine_x * data.len()) / fine_width; let sample_idx = (fine_x * data.len()) / fine_width;
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * auto_gain).clamp(-1.0, 1.0);
let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize; let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fine_y = fine_y.min(fine_height - 1); let fine_y = fine_y.min(fine_height - 1);
@@ -117,6 +121,10 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
let fine_width = width * 2; let fine_width = width * 2;
let fine_height = height * 4; let fine_height = height * 4;
// Auto-scale: find peak amplitude and normalize to fill width
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
PATTERNS.with(|p| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
let size = width * height; let size = width * height;
@@ -125,7 +133,7 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
for fine_y in 0..fine_height { for fine_y in 0..fine_height {
let sample_idx = (fine_y * data.len()) / fine_height; let sample_idx = (fine_y * data.len()) / fine_height;
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * auto_gain).clamp(-1.0, 1.0);
let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize; let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fine_x = fine_x.min(fine_width - 1); let fine_x = fine_x.min(fine_width - 1);

View File

@@ -1,6 +1,9 @@
# Welcome to Cagire # Welcome to Cagire
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events. It is made by BuboBubo (Raphaël Maurice Forment): [https://raphaelforment.fr](https://raphaelforment.fr). Cagire is open-source (AGPL-3.0 licensed) and available on GitHub : [https://github.com/BuboBubo/cagire](https://github.com/BuboBubo/cagire). This help view will teach you everything you need to know to start using Cagire and and to live code with it. Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events. It is made by BuboBubo (Raphaël Maurice Forment): [https://raphaelforment.fr](https://raphaelforment.fr). Cagire is open-source (AGPL-3.0 licensed) and available on GitHub : [https://github.com/BuboBubo/cagire](https://github.com/BuboBubo/cagire). This help view will teach you everything you need to know to start using Cagire and and to live code with it. To use Cagire, you will need to understand two things:
1) How the sequencer works: dealing with steps, patterns and banks.
2) How to write a script: how to make sound using code.
## Pages ## Pages

View File

@@ -11,7 +11,7 @@ use std::thread::{self, JoinHandle};
use super::AudioCommand; use super::AudioCommand;
pub struct ScopeBuffer { pub struct ScopeBuffer {
pub samples: [AtomicU32; 64], pub samples: [AtomicU32; 256],
peak_left: AtomicU32, peak_left: AtomicU32,
peak_right: AtomicU32, peak_right: AtomicU32,
} }
@@ -29,12 +29,19 @@ impl ScopeBuffer {
let mut peak_l: f32 = 0.0; let mut peak_l: f32 = 0.0;
let mut peak_r: f32 = 0.0; let mut peak_r: f32 = 0.0;
// Calculate peaks from ALL input frames for accurate VU metering
for chunk in data.chunks(2) {
if let [left, right] = chunk {
peak_l = peak_l.max(left.abs());
peak_r = peak_r.max(right.abs());
}
}
// Downsample for scope display
let frames = data.len() / 2;
for (i, atom) in self.samples.iter().enumerate() { for (i, atom) in self.samples.iter().enumerate() {
let idx = i * 2; let frame_idx = (i * frames) / self.samples.len();
let left = data.get(idx).copied().unwrap_or(0.0); let left = data.get(frame_idx * 2).copied().unwrap_or(0.0);
let right = data.get(idx + 1).copied().unwrap_or(0.0);
peak_l = peak_l.max(left.abs());
peak_r = peak_r.max(right.abs());
atom.store(left.to_bits(), Ordering::Relaxed); atom.store(left.to_bits(), Ordering::Relaxed);
} }
@@ -42,7 +49,7 @@ impl ScopeBuffer {
self.peak_right.store(peak_r.to_bits(), Ordering::Relaxed); self.peak_right.store(peak_r.to_bits(), Ordering::Relaxed);
} }
pub fn read(&self) -> [f32; 64] { pub fn read(&self) -> [f32; 256] {
std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed))) std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed)))
} }
@@ -146,7 +153,7 @@ impl SpectrumAnalyzer {
let hi = self.band_edges[band + 1].max(lo + 1); let hi = self.band_edges[band + 1].max(lo + 1);
let sum: f32 = self.fft_buf[lo..hi].iter().map(|c| c.norm()).sum(); let sum: f32 = self.fft_buf[lo..hi].iter().map(|c| c.norm()).sum();
let avg = sum / (hi - lo) as f32; let avg = sum / (hi - lo) as f32;
let amplitude = avg / (FFT_SIZE as f32 / 2.0); let amplitude = avg / (FFT_SIZE as f32 / 4.0);
let db = 20.0 * amplitude.max(1e-10).log10(); let db = 20.0 * amplitude.max(1e-10).log10();
*mag = ((db + 60.0) / 60.0).clamp(0.0, 1.0); *mag = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
} }

View File

@@ -174,7 +174,7 @@ pub struct Metrics {
pub peak_voices: usize, pub peak_voices: usize,
pub cpu_load: f32, pub cpu_load: f32,
pub schedule_depth: usize, pub schedule_depth: usize,
pub scope: [f32; 64], pub scope: [f32; 256],
pub peak_left: f32, pub peak_left: f32,
pub peak_right: f32, pub peak_right: f32,
pub spectrum: [f32; 32], pub spectrum: [f32; 32],
@@ -190,7 +190,7 @@ impl Default for Metrics {
peak_voices: 0, peak_voices: 0,
cpu_load: 0.0, cpu_load: 0.0,
schedule_depth: 0, schedule_depth: 0,
scope: [0.0; 64], scope: [0.0; 256],
peak_left: 0.0, peak_left: 0.0,
peak_right: 0.0, peak_right: 0.0,
spectrum: [0.0; 32], spectrum: [0.0; 32],

View File

@@ -34,7 +34,7 @@ fn auto_delaytime() {
#[test] #[test]
fn emit_no_sound() { fn emit_no_sound() {
expect_error(".", "no sound set"); expect_error(".", "nothing to emit");
} }
#[test] #[test]
@@ -87,3 +87,22 @@ fn bank_param() {
assert!(outputs[0].contains("sound/loop")); assert!(outputs[0].contains("sound/loop"));
assert!(outputs[0].contains("bank/a")); assert!(outputs[0].contains("bank/a"));
} }
#[test]
fn param_only_emit() {
let outputs = expect_outputs(r#"0 voice 880 freq ."#, 1);
assert!(outputs[0].contains("voice/0"));
assert!(outputs[0].contains("freq/880"));
assert!(!outputs[0].contains("sound/"));
assert!(!outputs[0].contains("dur/"));
assert!(!outputs[0].contains("delaytime/"));
}
#[test]
fn param_only_multiple_params() {
let outputs = expect_outputs(r#"0 voice 440 freq 0.5 gain ."#, 1);
assert!(outputs[0].contains("voice/0"));
assert!(outputs[0].contains("freq/440"));
assert!(outputs[0].contains("gain/0.5"));
assert!(!outputs[0].contains("sound/"));
}