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'
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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/"));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user