16 Commits

Author SHA1 Message Date
25866f66d4 Feat: UI / UX improvements (top bar) 2026-03-07 19:31:31 +01:00
8b058f2bb9 Feat: CPU meter in top bar 2026-03-07 19:08:54 +01:00
cb82337d24 Feat: add missing LICENSE file 2026-03-07 15:32:23 +01:00
539aa6a9f7 Feat: move CI (GitHub - Gitea) 2026-03-07 14:23:28 +01:00
b7d9436cee Feat: move out of GitHub, remove GitHub references
All checks were successful
Deploy Website / deploy (push) Successful in 25s
2026-03-07 14:17:58 +01:00
3d345d57f5 Merge branch 'main' of https://git.raphaelforment.fr/BuboBubo/cagire
All checks were successful
Deploy Website / deploy (push) Successful in 25s
2026-03-07 14:15:21 +01:00
c6b14bf508 Feat: remove wix 2026-03-07 14:15:13 +01:00
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
44 changed files with 984 additions and 805 deletions

View File

@@ -81,7 +81,7 @@ jobs:
- name: Build .pkg installer
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="${GITEA_REF_NAME#v}"
mkdir -p pkg-root/Applications pkg-root/usr/local/bin
cp -R Cagire.app pkg-root/Applications/
cp cagire pkg-root/usr/local/bin/

View File

@@ -36,9 +36,7 @@ jobs:
- name: Prepare plugin artifacts
run: |
mkdir -p target/bundled
# CLAP: single .so renamed to .clap
cp target/aarch64-unknown-linux-gnu/release/libcagire_plugins.so target/bundled/cagire-plugins.clap
# VST3: correct directory structure
mkdir -p "target/bundled/cagire-plugins.vst3/Contents/aarch64-linux"
cp target/aarch64-unknown-linux-gnu/release/libcagire_plugins.so "target/bundled/cagire-plugins.vst3/Contents/aarch64-linux/cagire-plugins.so"

View File

@@ -0,0 +1,17 @@
name: Build Plugins
on:
workflow_dispatch:
jobs:
linux:
uses: ./.gitea/workflows/build-plugins-linux.yml
macos:
uses: ./.gitea/workflows/build-plugins-macos.yml
windows:
uses: ./.gitea/workflows/build-plugins-windows.yml
rpi:
uses: ./.gitea/workflows/build-plugins-rpi.yml

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/

23
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,23 @@
name: CI
on:
workflow_dispatch:
jobs:
linux:
uses: ./.gitea/workflows/build-linux.yml
with:
run-tests: true
run-clippy: true
macos:
uses: ./.gitea/workflows/build-macos.yml
with:
run-tests: true
run-clippy: true
windows:
uses: ./.gitea/workflows/build-windows.yml
with:
run-tests: true
run-clippy: true

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

@@ -2,23 +2,15 @@ name: Release
on:
workflow_dispatch:
push:
tags: ['v*']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
if: github.server_url == 'https://github.com'
uses: ./.github/workflows/build-linux.yml
uses: ./.gitea/workflows/build-linux.yml
with:
build-packages: true
macos:
if: github.server_url == 'https://github.com'
uses: ./.github/workflows/build-macos.yml
uses: ./.gitea/workflows/build-macos.yml
with:
build-packages: true
matrix: >-
@@ -28,26 +20,21 @@ jobs:
]
windows:
if: github.server_url == 'https://github.com'
uses: ./.github/workflows/build-windows.yml
uses: ./.gitea/workflows/build-windows.yml
with:
build-packages: true
cross:
if: github.server_url == 'https://github.com'
uses: ./.github/workflows/build-cross.yml
uses: ./.gitea/workflows/build-cross.yml
assemble-macos:
needs: macos
uses: ./.github/workflows/assemble-macos.yml
uses: ./.gitea/workflows/assemble-macos.yml
release:
needs: [linux, macos, windows, cross, assemble-macos]
if: startsWith(github.ref, 'refs/tags/v') && github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download all artifacts
@@ -100,8 +87,25 @@ jobs:
fi
done
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true
- name: Create Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
TAG="${GITEA_REF_NAME:-manual-$(date +%Y%m%d-%H%M%S)}"
API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
RELEASE_ID=$(curl -s -X POST "$API_URL" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"draft\": true}" \
| jq -r '.id')
for file in release/*; do
filename=$(basename "$file")
curl -s -X POST "$API_URL/$RELEASE_ID/assets?name=$filename" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file"
done
echo "Release $TAG created as draft with $(ls release | wc -l) assets"

View File

@@ -1,18 +0,0 @@
name: Build Plugins
on:
workflow_call:
workflow_dispatch:
jobs:
linux:
uses: ./.github/workflows/build-plugins-linux.yml
macos:
uses: ./.github/workflows/build-plugins-macos.yml
windows:
uses: ./.github/workflows/build-plugins-windows.yml
rpi:
uses: ./.github/workflows/build-plugins-rpi.yml

View File

@@ -1,28 +0,0 @@
name: CI
on:
push:
tags: ['v*']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
uses: ./.github/workflows/build-linux.yml
with:
run-tests: true
run-clippy: true
macos:
uses: ./.github/workflows/build-macos.yml
with:
run-tests: true
run-clippy: true
windows:
uses: ./.github/workflows/build-windows.yml
with:
run-tests: true
run-clippy: true

View File

@@ -1,59 +0,0 @@
name: Deploy Website
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
if: github.server_url == 'https://github.com'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- 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
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
working-directory: website
- name: Build
run: pnpm build
working-directory: website
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: website/dist
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -3,7 +3,7 @@
## Quick Start
```bash
git clone https://github.com/Bubobubobubobubo/cagire
git clone https://git.raphaelforment.fr/BuboBubo/cagire
cd cagire
cargo build --release
```

991
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,11 @@
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"
repository = "https://github.com/Bubobubobubobubo/cagire"
repository = "https://git.raphaelforment.fr/BuboBubo/cagire"
homepage = "https://cagire.raphaelforment.fr"
description = "Forth-based live coding music sequencer"

View File

@@ -8,7 +8,7 @@
<p align="center">
<a href="https://cagire.raphaelforment.fr">Website</a> &middot;
<a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a> &middot;
<a href="https://git.raphaelforment.fr/BuboBubo/cagire">Gitea</a> &middot;
AGPL-3.0
</p>

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

@@ -58,6 +58,7 @@ pub fn build(p: &Palette) -> ThemeColors {
header: HeaderColors {
tempo_bg: rgb(tint(p.bg, p.tempo_color, 0.30)),
tempo_fg: rgb(p.tempo_color),
beat_bg: rgb(tint(p.bg, p.tempo_color, 0.45)),
bank_bg: rgb(tint(p.bg, p.bank_color, 0.25)),
bank_fg: rgb(p.bank_color),
pattern_bg: rgb(tint(p.bg, p.pattern_color, 0.25)),

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.
@@ -174,6 +175,7 @@ pub struct TileColors {
pub struct HeaderColors {
pub tempo_bg: Color,
pub tempo_fg: Color,
pub beat_bg: Color,
pub bank_bg: Color,
pub bank_fg: Color,
pub pattern_bg: Color,

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

@@ -51,7 +51,7 @@ Section "Cagire (required)" SecCore
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "Publisher" "Raphael Forment"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "DisplayIcon" '"$INSTDIR\cagire-desktop.exe"'
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "URLInfoAbout" "https://github.com/Bubobubobubobubo/cagire"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "URLInfoAbout" "https://git.raphaelforment.fr/BuboBubo/cagire"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "HelpLink" "https://cagire.raphaelforment.fr"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cagire" "NoRepair" 1

View File

@@ -0,0 +1,15 @@
ISC License
Copyright (c) Robbert van der Helm <mail@robbertvanderhelm.nl>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

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

@@ -293,15 +293,14 @@ fn render_header(
let pad = Padding::vertical(1);
let [logo_area, transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] =
let [logo_area, transport_area, tempo_area, bank_area, pattern_area, stats_area] =
Layout::horizontal([
Constraint::Length(5),
Constraint::Min(12),
Constraint::Length(9),
Constraint::Min(14),
Constraint::Min(20),
Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
Constraint::Min(24),
])
.areas(area);
@@ -317,43 +316,76 @@ fn render_header(
logo_area,
);
// Transport block
let (transport_bg, transport_text) = if app.playback.playing {
// Transport block (with fill indicator)
let fill = app.live_keys.fill();
let (transport_bg, transport_label) = if app.playback.playing {
(theme.status.playing_bg, " ▶ PLAYING ")
} else {
(theme.status.stopped_bg, " ■ STOPPED ")
};
let transport_style = Style::new().bg(transport_bg).fg(theme.ui.text_primary);
let fill_span = if fill {
Span::styled("F", Style::new().fg(theme.status.fill_on).bg(transport_bg))
} else {
Span::styled(" ", Style::new().bg(transport_bg))
};
let transport_line = Line::from(vec![
Span::styled(transport_label, Style::new().fg(theme.ui.text_primary).bg(transport_bg)),
fill_span,
Span::styled(" ", Style::new().bg(transport_bg)),
]);
frame.render_widget(
Paragraph::new(transport_text)
.block(Block::default().padding(pad).style(transport_style))
Paragraph::new(transport_line)
.block(Block::default().padding(pad).style(Style::new().bg(transport_bg)))
.alignment(Alignment::Center),
transport_area,
);
// Fill indicator
let fill = app.live_keys.fill();
let fill_fg = if fill {
theme.status.fill_on
} else {
theme.status.fill_off
};
let fill_style = Style::new().bg(theme.status.fill_bg).fg(fill_fg);
// Tempo + bar:beat position block (beat segments as background fills)
let tempo_bg = theme.header.tempo_bg;
let tempo_fg = theme.ui.text_primary;
let quantum = link.quantum();
let quantum_int = quantum.max(1.0) as usize;
// Base background
frame.render_widget(
Paragraph::new(if fill { "F" } else { "·" })
.block(Block::default().padding(pad).style(fill_style))
.alignment(Alignment::Center),
live_area,
Block::default().style(Style::new().bg(tempo_bg)),
tempo_area,
);
// Tempo block
let tempo_style = Style::new()
.bg(theme.header.tempo_bg)
.fg(theme.ui.text_primary)
.add_modifier(Modifier::BOLD);
// Beat segment highlight (like CPU meter but divided into quantum segments)
if app.playback.playing && quantum_int <= 16 {
let phase = link.phase();
let beat_in_bar = phase.floor() as usize;
let seg_w = tempo_area.width / quantum_int as u16;
let seg_x = tempo_area.x + seg_w * beat_in_bar as u16;
let seg_width = if beat_in_bar == quantum_int - 1 {
tempo_area.width - seg_w * beat_in_bar as u16
} else {
seg_w
};
frame.render_widget(
Block::default().style(Style::new().bg(theme.header.beat_bg)),
Rect {
x: seg_x,
width: seg_width,
..tempo_area
},
);
}
// Text overlay
let tempo_text = if app.playback.playing {
let phase = link.phase();
let beat_in_bar = phase.floor() as usize + 1;
let bar = (link.beat() / quantum).floor() as usize + 1;
format!(" {:.1} BPM {bar}:{beat_in_bar} ", link.tempo())
} else {
format!(" {:.1} BPM ─:─ ", link.tempo())
};
frame.render_widget(
Paragraph::new(format!(" {:.1} BPM ", link.tempo()))
.block(Block::default().padding(pad).style(tempo_style))
Paragraph::new(tempo_text)
.block(Block::default().padding(pad))
.style(Style::new().fg(tempo_fg).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center),
tempo_area,
);
@@ -393,42 +425,61 @@ fn render_header(
.get_iter(app.editor_ctx.bank, app.editor_ctx.pattern)
.map(|iter| format!(" · #{}", iter + 1))
.unwrap_or_default();
let pattern_text = format!(
" {} · {} steps{}{}{} ",
pattern_name, pattern.length, speed_info, page_info, iter_info
);
let pattern_style = Style::new()
.bg(theme.header.pattern_bg)
.fg(theme.ui.text_primary);
let pattern_bg = theme.header.pattern_bg;
let active_count = snapshot.active_patterns.len();
let active_info = format!(" · ▶{active_count}");
let active_style = if active_count > 0 {
Style::new().bg(pattern_bg).fg(theme.ui.text_primary)
} else {
Style::new().bg(pattern_bg).fg(theme.ui.text_muted)
};
let pattern_line = Line::from(vec![
Span::styled(
format!(
" {} · {} steps{}{}{} ",
pattern_name, pattern.length, speed_info, page_info, iter_info
),
Style::new().bg(pattern_bg).fg(theme.ui.text_primary),
),
Span::styled(active_info, active_style),
]);
frame.render_widget(
Paragraph::new(pattern_text)
.block(Block::default().padding(pad).style(pattern_style))
Paragraph::new(pattern_line)
.block(Block::default().padding(pad).style(Style::new().bg(pattern_bg)))
.alignment(Alignment::Center),
pattern_area,
);
// Stats block
// Stats block — CPU bar filling the area, text overlaid
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let peers = link.peers();
let voices = app.metrics.active_voices;
let cpu_color = if cpu_pct >= 80.0 {
let cpu_bar_color = if cpu_pct >= 80.0 {
theme.flash.error_fg
} else if cpu_pct >= 50.0 {
theme.ui.accent
} else {
theme.header.stats_fg
theme.meter.low
};
let dim = Style::new()
.bg(theme.header.stats_bg)
.fg(theme.header.stats_fg);
let stats_line = Line::from(vec![
Span::styled(format!(" CPU {cpu_pct:.0}%"), dim.fg(cpu_color)),
Span::styled(format!(" V:{voices} L:{peers} "), dim),
]);
let block_style = Style::new().bg(theme.header.stats_bg);
frame.render_widget(
Paragraph::new(stats_line)
.block(Block::default().padding(pad).style(block_style))
Block::default().style(Style::new().bg(theme.header.stats_bg)),
stats_area,
);
let filled_w = (cpu_pct / 100.0 * stats_area.width as f32).round() as u16;
if filled_w > 0 {
frame.render_widget(
Block::default().style(Style::new().bg(cpu_bar_color)),
Rect {
width: filled_w.min(stats_area.width),
..stats_area
},
);
}
let stats_text = format!("CPU {cpu_pct:.0}% V:{voices}");
frame.render_widget(
Paragraph::new(stats_text)
.block(Block::default().padding(pad))
.style(Style::new().fg(theme.ui.text_primary))
.alignment(Alignment::Center),
stats_area,
);
@@ -675,8 +726,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]

View File

@@ -87,11 +87,11 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<tr>
<td>Windows (x86_64)</td>
<td><a href={`${DL}/cagire-windows-x86_64.zip`}>zip</a></td>
<td><a href={`${DL}/cagire-desktop-windows-x86_64.zip`}>zip</a> · <s>.msi</s></td>
<td><a href={`${DL}/plugins-windows-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/plugins-windows-x86_64-vst3.zip`}>VST3</a></td>
<td><a href={`${DL}/cagire-windows-x86_64-desktop.zip`}>zip</a> · <a href={`${DL}/cagire-windows-x86_64-installer.zip`}>installer</a></td>
<td><a href={`${DL}/cagire-windows-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/cagire-windows-x86_64-vst3.zip`}>VST3</a></td>
</tr>
</table>
<p class="note">Source code and issue tracker on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>. You can also compile the software yourself from source!</p>
<p class="note">Source code and issue tracker on <a href="https://git.raphaelforment.fr/BuboBubo/cagire">Gitea</a>. You can also compile the software yourself from source!</p>
<h2>Documentation</h2>
@@ -128,7 +128,7 @@ const DL = 'https://dlcagire.raphaelforment.fr';
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
<p class="colophon">
<a href="https://raphaelforment.fr">BuboBubo</a> · Audio engine: <a href="https://doux.livecoding.fr">Doux</a> · <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a> · <a href="/docs">Docs</a> · AGPL-3.0 </p>
<a href="https://raphaelforment.fr">BuboBubo</a> · Audio engine: <a href="https://doux.livecoding.fr">Doux</a> · <a href="https://git.raphaelforment.fr/BuboBubo/cagire">Gitea</a> · <a href="/docs">Docs</a> · AGPL-3.0 </p>
<script is:inline src="/script.js"></script>
</body>

View File

@@ -1,20 +0,0 @@
{\rtf1\ansi\deff0\nouicompat{\fonttbl{\f0\fswiss\fcharset0 Helvetica;}}
{\*\generator Msftedit 5.41.21.2510;}\viewkind4\uc1
\pard\sa200\sl276\slmult1\f0\fs20\lang9
CAGIRE - Forth-based Music Sequencer\par
Copyright (c) 2025 Rapha\"el Forment\par
\par
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.\par
\par
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.\par
\par
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see https://www.gnu.org/licenses/.\par
}

View File

@@ -1,146 +0,0 @@
<?xml version='1.0' encoding='windows-1252'?>
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = intel64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='Cagire'
UpgradeCode='F2A3D4E5-6B7C-8D9E-0F1A-2B3C4D5E6F7A'
Manufacturer='Raphael Forment'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Cagire - Forth-based music sequencer'
Manufacturer='Raphael Forment'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='Cagire Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='Cagire'>
<Component Id='CagireCLI' Guid='A1B2C3D4-E5F6-7890-ABCD-EF1234567890' Win64='yes'>
<File
Id='CagireEXE'
Name='cagire.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\cagire.exe'
KeyPath='yes'/>
</Component>
<Component Id='CagireDesktop' Guid='B2C3D4E5-F6A7-8901-BCDE-F12345678901' Win64='yes'>
<File
Id='CagireDesktopEXE'
Name='cagire-desktop.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\cagire-desktop.exe'
KeyPath='yes'/>
</Component>
<Component Id='PathEntry' Guid='C3D4E5F6-A7B8-9012-CDEF-123456789012' Win64='yes' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[APPLICATIONFOLDER]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
</Directory>
</Directory>
<Directory Id='ProgramMenuFolder'>
<Directory Id='ApplicationProgramsFolder' Name='Cagire'>
<Component Id='StartMenuShortcut' Guid='D4E5F6A7-B8C9-0123-DEFA-234567890123' Win64='yes'>
<Shortcut
Id='CagireDesktopShortcut'
Name='Cagire'
Description='Forth-based music sequencer'
Target='[APPLICATIONFOLDER]cagire-desktop.exe'
WorkingDirectory='APPLICATIONFOLDER'
Icon='CagireIcon.exe'/>
<RemoveFolder Id='CleanUpShortcutFolder' On='uninstall'/>
<RegistryValue
Root='HKCU'
Key='Software\Cagire'
Name='installed'
Type='integer'
Value='1'
KeyPath='yes'/>
</Component>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs Cagire CLI and Desktop binaries.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<ComponentRef Id='CagireCLI'/>
<ComponentRef Id='CagireDesktop'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location to the PATH system environment variable. This allows the cagire CLI to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='PathEntry'/>
</Feature>
</Feature>
<Feature
Id='StartMenu'
Title='Start Menu Shortcut'
Description='Add a Cagire shortcut to the Start Menu.'
Level='1'
Absent='allow'>
<ComponentRef Id='StartMenuShortcut'/>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<Icon Id='CagireIcon.exe' SourceFile='assets\Cagire.ico'/>
<Property Id='ARPPRODUCTICON' Value='CagireIcon.exe'/>
<Property Id='ARPHELPLINK' Value='https://cagire.raphaelforment.fr'/>
<Property Id='ARPURLINFOABOUT' Value='https://github.com/Bubobubobubobubo/cagire'/>
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<WixVariable Id='WixUILicenseRtf' Value='wix\License.rtf'/>
</Product>
</Wix>