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'
run: |
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)
if: runner.os == 'macOS'
@@ -71,6 +72,9 @@ jobs:
- name: Build
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
run: cargo test --target ${{ matrix.target }}
@@ -91,6 +95,20 @@ jobs:
name: ${{ matrix.artifact }}
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:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
@@ -110,10 +128,19 @@ jobs:
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
if [ -f "$dir/cagire.exe" ]; then
cp "$dir/cagire.exe" "release/${name}.exe"
elif [ -f "$dir/cagire" ]; then
cp "$dir/cagire" "release/${name}"
if [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if [ -f "$dir/cagire-desktop.exe" ]; then
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
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 Rng = Arc<Mutex<StdRng>>;
pub type Stack = Arc<Mutex<Vec<Value>>>;
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]);
#[derive(Clone, Debug)]
pub enum Value {
@@ -140,8 +141,12 @@ impl CmdRegister {
&self.deltas
}
pub(super) fn snapshot(&self) -> Option<(&Value, &[(String, Value)])> {
self.sound.as_ref().map(|s| (s, self.params.as_slice()))
pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
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) {

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

View File

@@ -1,6 +1,9 @@
# 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

View File

@@ -11,7 +11,7 @@ use std::thread::{self, JoinHandle};
use super::AudioCommand;
pub struct ScopeBuffer {
pub samples: [AtomicU32; 64],
pub samples: [AtomicU32; 256],
peak_left: AtomicU32,
peak_right: AtomicU32,
}
@@ -29,12 +29,19 @@ impl ScopeBuffer {
let mut peak_l: 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() {
let idx = i * 2;
let left = data.get(idx).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());
let frame_idx = (i * frames) / self.samples.len();
let left = data.get(frame_idx * 2).copied().unwrap_or(0.0);
atom.store(left.to_bits(), Ordering::Relaxed);
}
@@ -42,7 +49,7 @@ impl ScopeBuffer {
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)))
}
@@ -146,7 +153,7 @@ impl SpectrumAnalyzer {
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 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();
*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 cpu_load: f32,
pub schedule_depth: usize,
pub scope: [f32; 64],
pub scope: [f32; 256],
pub peak_left: f32,
pub peak_right: f32,
pub spectrum: [f32; 32],
@@ -190,7 +190,7 @@ impl Default for Metrics {
peak_voices: 0,
cpu_load: 0.0,
schedule_depth: 0,
scope: [0.0; 64],
scope: [0.0; 256],
peak_left: 0.0,
peak_right: 0.0,
spectrum: [0.0; 32],

View File

@@ -34,7 +34,7 @@ fn auto_delaytime() {
#[test]
fn emit_no_sound() {
expect_error(".", "no sound set");
expect_error(".", "nothing to emit");
}
#[test]
@@ -87,3 +87,22 @@ fn bank_param() {
assert!(outputs[0].contains("sound/loop"));
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/"));
}