Compare commits
3 Commits
d0e37e13e6
...
4772b02f77
| Author | SHA1 | Date | |
|---|---|---|---|
| 4772b02f77 | |||
| 4049c7787c | |||
| 4c635500dd |
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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/"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user