70 Commits

Author SHA1 Message Date
bc5d12e53a Feat: lots of improvements
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-08 13:52:40 +01:00
d6bbae173b Feat: improve website
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-08 02:57:41 +01:00
1f339f1503 Small corrections
Some checks failed
Deploy Website / deploy (push) Failing after 4m51s
2026-02-08 01:33:50 +01:00
8ffe2c22c7 Feat: comfort features 2026-02-08 00:46:56 +01:00
20c32ce0d8 Prepare v0.0.8 release
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-07 13:14:14 +01:00
a326d58d30 Feat: restore Cargo.toml to git version 2026-02-07 13:07:56 +01:00
c72733bac8 WIP: prepare the ground for audio rate modulation 2026-02-07 12:08:11 +01:00
5758b18d58 Feat: trying to get rid of some sequencer bugs 2026-02-07 01:24:38 +01:00
52cc890a67 Feat: website WIP and new words
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-06 16:19:09 +01:00
0f9d750069 Feat: trying to improve bundling and compilation 2026-02-06 00:46:40 +01:00
66ee2e28ff Words and universal macOS installer
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-06 00:37:08 +01:00
6ec3a86568 New themes 2026-02-06 00:19:16 +01:00
51f52be4ce Feat: optimizations 2026-02-05 23:15:46 +01:00
2c98a915fa Space on all views
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-05 18:57:09 +01:00
e42476dd4d Feat: rework audio sample library viewer 2026-02-05 18:37:32 +01:00
3e364a6622 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-05 15:56:52 +01:00
1248f74b25 Feat: update CHANGELOG.md 2026-02-05 15:56:27 +01:00
fc2ab0757b Feat: update CHANGELOG.md
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-05 14:36:12 +01:00
10ed5a629a Feat: background head-preload for sample libraries 2026-02-05 14:35:26 +01:00
88c2b51720 Feat: introduce Forth words for 3-OP Fm synthesis (with feedback)
Some checks failed
Deploy Website / deploy (push) Failing after 4m52s
2026-02-05 12:00:00 +01:00
5cda1a8f95 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-05 01:40:51 +01:00
200832f230 Feat: update CHANGELOG.md before release 2026-02-05 01:40:06 +01:00
91bc9011b2 Feat: new euclidean words and sugar for floating point numbers
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-05 01:30:34 +01:00
de56598fca Feat: prelude and new words
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-05 00:58:53 +01:00
abafea8ddf Feat: refactoring by breaking words in multiple files
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-04 23:50:38 +01:00
e6f776bdf4 Feat: tri is now triangle (disambiguation) 2026-02-04 20:34:37 +01:00
d40d713649 Feat: really good lookahead mechanism for scheduling
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-04 20:28:42 +01:00
767575b25d Removing lookahead concept 2026-02-04 20:01:17 +01:00
82b0668bcf Some kind of refactoring 2026-02-04 19:35:30 +01:00
6cf9d2eec1 Ungoing refactoring 2026-02-04 18:47:40 +01:00
2097997372 Feat: tweak and fix from last night workshop
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-04 09:37:29 +01:00
5579708f69 Feat: add tachyonFX animations 2026-02-04 00:40:15 +01:00
1b01491e87 Fix: prevent 0 division error when loading project 2026-02-03 23:41:27 +01:00
5581ba1881 chore: Release 2026-02-03 17:03:58 +01:00
8983b3f21c Fix: dict popup in editor is less intrusive
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 17:02:07 +01:00
4a7ae83019 Fix: desktop build
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-03 16:00:26 +01:00
61a6d7aad0 Fix: simpler scheduling
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-03 15:55:43 +01:00
1b01e3b805 WIP: improve Linux audio support
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 14:42:03 +01:00
2a57cc415b Fix: JACK stuff
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 14:23:24 +01:00
7c76bdb8d6 clamp audio options
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-03 14:14:28 +01:00
1facc72a67 Fix Linux audio: enable JACK support and RT priority for audio callback
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-03 14:04:34 +01:00
726ea16e92 Wip 2026-02-03 13:52:36 +01:00
154cac6547 Again 2026-02-03 03:25:31 +01:00
3380e454df Again 2026-02-03 03:08:13 +01:00
660f48216a Still searching... 2026-02-03 02:53:34 +01:00
fb1f73ebd6 WIP: not sure 2026-02-03 02:31:55 +01:00
cd223592a7 Insane linux fixes
Some checks failed
Deploy Website / deploy (push) Failing after 4m45s
2026-02-03 01:15:07 +01:00
af81c94207 WIP: even more crazy linux optimizations
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-03 00:38:46 +01:00
b53e4a76ab WIP: optimizations for linux
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-03 00:16:31 +01:00
8c31ed4196 Another round of optimization
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 22:16:00 +01:00
8024c18bb0 Less memory allocations at runtime 2026-02-02 21:55:10 +01:00
194030d953 fixing linux stuff
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 19:26:01 +01:00
e4799c1f42 Merge branch 'main' of github.com:Bubobubobubobubo/cagire
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-02 19:12:37 +01:00
636129688d lookahead 2026-02-02 19:12:32 +01:00
a2ee0e5a50 Fix: Copy register handling for cagire-desktop (Linux) 2026-02-02 18:25:02 +01:00
96ed74c6fe Fix: CPAL version mismatch 2026-02-02 18:08:55 +01:00
a67d982fcd Pattern mute and so on 2026-02-02 16:27:11 +01:00
c9ab7a4f0b chore: Release 2026-02-02 13:44:47 +01:00
772d21a8ed Feat: update CHANGELOG.md
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 13:42:42 +01:00
4396147a8b Euclidean + hue rotation
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 13:25:27 +01:00
c396c39b6b Fix layout 2026-02-02 12:18:22 +01:00
f6b43cb021 Add double-stack words (2dup, 2drop, 2swap, 2over) and forget
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-02 07:46:39 +01:00
60d1d7ca74 Feat: update website to prevent ugliness
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:38:21 +01:00
9864cc6d61 Update changelog for v0.0.3 2026-02-02 01:12:49 +01:00
985ab687d7 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 12m15s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-02-02 01:09:13 +01:00
9b925d881e Feat: update changelog
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 01:08:33 +01:00
71146c7cea Feat: more predictable projet load behavior
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:01:01 +01:00
6b95f31afd Feat: polyphony + iterator reset
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 00:33:46 +01:00
adee8d0d57 Feat: adding some basic music theory
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-01 16:15:09 +01:00
f9c284effd Feat: adding logrand and exprand 2026-02-01 15:16:20 +01:00
142 changed files with 13402 additions and 5160 deletions

View File

@@ -122,9 +122,77 @@ jobs:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop.exe
release:
universal-macos:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-14
timeout-minutes: 10
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: cagire-macos-*
path: artifacts
- name: Create universal CLI binary
run: |
lipo -create \
artifacts/cagire-macos-x86_64/cagire \
artifacts/cagire-macos-aarch64/cagire \
-output cagire
chmod +x cagire
lipo -info cagire
- name: Create universal app bundle
run: |
cd artifacts/cagire-macos-aarch64-desktop
unzip Cagire.app.zip
cd ../cagire-macos-x86_64-desktop
unzip Cagire.app.zip
cd ../..
cp -R artifacts/cagire-macos-aarch64-desktop/Cagire.app Cagire.app
lipo -create \
artifacts/cagire-macos-x86_64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
artifacts/cagire-macos-aarch64-desktop/Cagire.app/Contents/MacOS/cagire-desktop \
-output Cagire.app/Contents/MacOS/cagire-desktop
lipo -info Cagire.app/Contents/MacOS/cagire-desktop
zip -r Cagire.app.zip Cagire.app
- name: Build .pkg installer
run: |
VERSION="${GITHUB_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/
pkgbuild --analyze --root pkg-root component.plist
plutil -replace BundleIsRelocatable -bool NO component.plist
pkgbuild --root pkg-root --identifier com.sova.cagire \
--version "$VERSION" --install-location / \
--component-plist component.plist \
"Cagire-${VERSION}-universal.pkg"
- name: Upload universal CLI
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal
path: cagire
- name: Upload universal app bundle
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-desktop
path: Cagire.app.zip
- name: Upload .pkg installer
uses: actions/upload-artifact@v4
with:
name: cagire-macos-universal-pkg
path: Cagire-*-universal.pkg
release:
needs: [build, universal-macos]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -141,7 +209,13 @@ jobs:
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
if [[ "$name" == *-desktop ]]; then
if [[ "$name" == "cagire-macos-universal-pkg" ]]; then
cp "$dir"/*.pkg release/
elif [[ "$name" == "cagire-macos-universal-desktop" ]]; then
cp "$dir/Cagire.app.zip" "release/cagire-macos-universal-desktop.app.zip"
elif [[ "$name" == "cagire-macos-universal" ]]; then
cp "$dir/cagire" "release/cagire-macos-universal"
elif [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
cp "$dir"/*.deb "release/${base}-desktop.deb"

View File

@@ -4,6 +4,158 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- Resolved value annotations: nondeterministic words (`rand`, `choose`, `cycle`, `bounce`, `wchoose`, `coin`, `chance`, `prob`, `exprand`, `logrand`) now display their resolved value inline (e.g., `choose [sine]`, `rand [7]`, `chance [yes]`) during playback in both Preview and Editor modals.
## [0.0.9] - 2026-02-08
### Website
- Compressed screenshot images: resized to 1600px and converted PNG to WebP (8MB → 538KB).
- Version number displayed in subtitle, read automatically from `Cargo.toml` at build time.
### Added
- Inline sample finder in the editor: press `Ctrl+B` to open a fuzzy-search popup of all sample folder names. Type to filter, `Ctrl+N`/`Ctrl+P` to navigate, `Tab`/`Enter` to insert the folder name at cursor, `Esc` to dismiss. Mutually exclusive with word completion.
- Sample browser now displays the 0-based file index next to each sample name, making it easy to reference samples by index in Forth scripts (e.g., `"drums" bank 0 n`).
### Improved
- Header bar stats block (CPU/voices/Link peers) is now centered like all other header sections.
- CPU percentage changes color when load is high: accent color at 50%+, error color at 80%+.
### Fixed
- Soundless emits (e.g., `1 gain .`) no longer stack infinite voices. All emitted commands now receive a default duration of one beat unless the user explicitly sets `dur`. Use `0 dur` for intentionally infinite voices.
## [0.0.8] - 2026-02-07
### Fixed
- macOS `.pkg` installer bundle relocation: disabled `BundleIsRelocatable` so `Cagire.app` always installs to `/Applications/` instead of being redirected to an existing bundle location.
### Added
- Syntax highlighting for user-defined Forth words: words created with `: name ... ;` now render with a distinct color in both the editor and step preview, instead of dimmed gray.
- Multi-selection in Patterns view: Shift+Up/Down selects adjacent ranges of banks or patterns using anchor-based selection. Works with copy/paste (Ctrl+C/V), reset (Delete), toggle play (`p`), mute (`m`), and solo (`x`). Selection is column-scoped and clears on plain arrows, column switch, or Esc. Single-only actions (rename, pattern props, enter) are disabled during multi-selection.
- Audio-rate modulation DSL: LFO words (`lfo`, `tlfo`, `wlfo`, `qlfo` for sine/triangle/sawtooth/square), transition envelopes (`slide`, `expslide`, `sslide` for linear/exponential/smooth), random modulation (`jit`, `sjit`, `drunk` for random hold/smooth random/drunk walk), and multi-segment envelope (`env`). These produce modulation strings consumed by parameter words for continuous audio-rate control.
- Feedback delay FX words: `feedback`/`fb` (level), `fbtime`/`fbt` (delay time), `fbdamp`/`fbd` (damping), `fblfo` (LFO rate), `fblfodepth` (LFO depth), `fblfoshape` (LFO shape).
- `bounce` word: ping-pong cycle through n items by step runs (e.g., `60 64 67 72 4 bounce` → 60 64 67 72 67 64 60 64...).
- `wchoose` word: weighted random selection from n value/weight pairs (e.g., `60 0.6 64 0.3 67 0.1 3 wchoose`). Supports quotations.
- New themes: **Eden** (dark forest — green-only palette on black), **Georges** (Commodore 64 palette on black), **Ember** (warm dark tones), **Letz Light** (light theme).
- Proper desktop app icon and metadata across all platforms: moved icon to `assets/Cagire.png`, added Windows `.exe` icon and file properties embedding via `winres` build script, added PNG to cargo-bundle icon list for Linux `.deb` packaging.
- Universal macOS `.pkg` installer in CI: combines Intel and Apple Silicon builds into fat binaries via `lipo`, then packages `Cagire.app` and CLI into a single `.pkg` installer.
- Waveform rendering widget in the TUI.
### Improved
- Sample library browser: search now shows folder names only (no files) while typing, sorted by fuzzy match score. After confirming search with Enter, folders can be expanded and collapsed normally. Esc clears the search filter before closing the panel. Left arrow on a file collapses the parent folder. Cursor and scroll position stay valid after expand/collapse operations.
- RAM optimizations saving ~5 MB at startup plus smaller enums and fewer hot-path allocations:
- Removed dead `Step::command` field (~3.1 MB)
- Narrowed `Step::source` from `Option<usize>` to `Option<u8>` (~1.8 MB)
- `Op::SetParam` and `Op::GetContext` now use `&'static str` instead of `String`
- `SourceSpan` fields narrowed from `usize` to `u32`
- Dirty pattern tracking uses fixed `[[bool; 32]; 32]` array instead of `HashSet`
- Boxed `FileBrowserState` in `Modal` enum to shrink all variants
- `StepContext::cc_access` borrows instead of cloning `Arc<dyn CcAccess>`
- Removed unnecessary `Arc` wrapper from `Stack` type
- Variable key cache computes on-demand with reusable buffers instead of pre-allocating 2048 Strings
- Render pipeline: background fill uses `Clear` widget instead of generating blank paragraph lines.
### Fixed
- Sequencer sync: auto-loaded patterns now use PhaseLock instead of Reset, so they align to the global beat grid and stay in sync with manually-started patterns.
- PhaseLock off-by-one: start step calculation now uses the frontier beat instead of the lookahead end, eliminating a systematic 1-step offset.
- Stale pattern cache on load: dirty patterns are now flushed before queued start/stop changes, ensuring pattern data arrives before activation.
- Loading while paused no longer drops auto-started patterns; pending starts are preserved and activate on resume.
### Changed
- Header bar is now always 3 lines tall with vertically centered content and full-height background colors, replacing the previous 1-or-2-line width-dependent layout.
- Help view Welcome page: BigText title is now gated behind `cfg(not(feature = "desktop"))`, falling back to a plain text title in the desktop build (same strategy as the splash screen).
- Space now toggles play/pause on all views, including the Patterns page where it previously toggled pattern play. Pattern play on the Patterns page is now bound to `p`.
## [0.0.7] - 2026-05-02
### Added
- 3-operator FM synthesis words: `fm2` (operator 2 depth), `fm2h` (operator 2 harmonic ratio), `fmalgo` (algorithm: 0=cascade, 1=parallel, 2=branch), `fmfb` (feedback amount). Extends the existing 2-OP FM engine to a full 3-OP architecture with configurable routing and operator feedback.
- Background head-preload for sample libraries. At startup, a background thread decodes the first 4096 frames (~93ms) of every sample into RAM. Short samples (most percussion/drums) are fully captured and play instantly on first trigger. Eliminates first-hit misses for live performance.
- Most changes are on doux side. It makes sense to recompile and release now to ship a version that comes with these improvements.
### Fixed
- Code editor now scrolls vertically to keep the cursor visible. Previously, lines beyond the visible area were clipped and the cursor could move off-screen.
## [0.0.6] - 2026-05-02
### Added
- TachyonFX based animations
- Prelude: project-level Forth script for persistent word definitions. Press `d` to edit, `D` to re-evaluate. Runs automatically on playback start and project load.
- Varargs stack words: `rev`, `shuffle`, `sort` (ascending), `rsort` (descending), `sum`, `prod`. All take a count and operate on the top n items.
- Euclidean rhythm words: `euclid` (k n -- hits) distributes k hits across n steps, `euclidrot` (k n r -- hits) adds rotation offset.
- Shorthand float syntax: `.25` parses as `0.25`, `-.5` parses as `-0.5`.
### Changed
- Split `words.rs` (3,078 lines) into a `words/` directory module with category-based files: `core.rs`, `sound.rs`, `effects.rs`, `sequencing.rs`, `music.rs`, `midi.rs`, plus `compile.rs` and `mod.rs`.
- Renamed `tri` Forth word to `triangle`.
- Sequencer rewritten with prospective lookahead scheduling. Instead of sleeping until a substep, waking late, and detecting past events, the sequencer now pre-computes all events within a ~20ms forward window. Events arrive at doux with positive time deltas, scheduled before they need to fire. Sleep+spin-wait replaced by `recv_timeout(3ms)` on the command channel. Timing no longer depends on OS sleep precision.
- `audio_sample_pos` updated at buffer start instead of end, so `engine_time` reflects current playback position.
- Doux grace period increased from 1ms to 50ms as a safety net (events should never be late with lookahead).
- Flattened model re-export indirection; `script.rs` now exports only `ScriptEngine`.
- Hue rotation step size increased from 1° to 5° for faster adjustment.
- Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion.
- Extracted shared initialization into `src/init.rs`, deduplicating ~140 lines between terminal and desktop binaries.
- Split App dispatch into focused service modules (`help_nav`, `dict_nav`, `euclidean`, `clipboard`, extended `pattern_editor`), reducing `app.rs` by ~310 lines.
- Moved stack preview computation from render path to input time, making editor rendering pure.
- Decoupled script runtime state between UI and sequencer threads, eliminating shared mutexes on the RT path.
### Fixed
- Prelude content no longer leaks into step editor. Closing the prelude editor now restores the current step's content to the buffer.
- Desktop binary now loads color theme and connects MIDI devices on startup (was missing).
- Audio commands no longer silently dropped when channel is full; switched to unbounded channel matching MIDI dispatch pattern.
- PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default.
- Changing pattern properties is now a stage/commit operation.
- Changing pattern speed only happens at pattern boundaries.
- `mlockall` warning no longer appears on macOS; memory locking is now Linux-only.
- `clear` now resets `at` deltas, so subsequent emits default to a single emit at position 0.
## [0.0.5] - 2026-04-02
### Added
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load.
- Realtime thread scheduling (`SCHED_FIFO`) for sequencer thread on Unix systems, improving timing reliability.
- Deep into the Linux hellscape: trying to get reliable performance, better stability, etc.
### Fixed
- Editor completion popup no longer steals arrow keys. Arrow keys always move the cursor; use Ctrl+N/Ctrl+P to navigate the completion list.
## [0.0.4] - 2026-02-02
### Added
- Double-stack words: `2dup`, `2drop`, `2swap`, `2over`.
- `forget` word to remove user-defined words from the dictionary.
- Active patterns panel showing playing patterns with bank, pattern, iteration count, and step position.
- Configurable visualization layout (Top/Bottom/Left/Right) for scope and spectrum placement.
- Euclidean distribution modal to spread a step's script across the pattern using Euclidean rhythms.
- Fairyfloss theme (pastel candy colors by sailorhg).
- Hot Dog Stand theme (classic Windows 3.1 red/yellow).
- Hue rotation option in Options menu to shift all theme colors (0-360°).
### Changed
- Title view now adapts to smaller terminal sizes gracefully.
### Fixed
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
- Updated `cpal` dependency from 0.15 to 0.17 to fix type mismatch with `doux` audio backend.
- Copy/paste (Ctrl+C/V/X) not working in desktop version due to egui intercepting clipboard shortcuts.
## [0.0.3] - 2026-02-02
### Added
- Polyphonic parameters: param words (`note`, `freq`, `gain`, etc.) and sound words now consume the entire stack, enabling polyphony (e.g., `60 64 67 note sine s .` emits 3 voices).
- New random distribution words: `exprand` (exponential) and `logrand` (logarithmic).
- Music theory chord words: `maj`, `m`, `dim`, `aug`, `sus2`, `sus4`, `maj7`, `min7`, `dom7`, `dim7`, `m7b5`, `minmaj7`, `aug7`, `maj6`, `min6`, `dom9`, `maj9`, `min9`, `dom11`, `min11`, `dom13`, `add9`, `add11`, `madd9`, `dom7b9`, `dom7s9`, `dom7b5`, `dom7s5`.
- Playing patterns are now saved with the project and restored on load.
### Changed
- `at` now consumes the entire stack for time offsets; polyphony multiplies with deltas (2 notes × 2 times = 4 voices).
- Iterator (`iter`) now resets when a pattern restarts.
- Project loading now properly resets state: stops all patterns, clears user variables/dictionary, and clears queued changes.
### Removed
- `tcycle` word (replaced by polyphonic parameter behavior).
## [0.0.2] - 2026-02-01
- CI testing and codebase cleanup

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
[workspace.package]
version = "0.0.2"
version = "0.0.8"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"
@@ -37,11 +37,11 @@ required-features = ["desktop"]
default = []
desktop = [
"cagire-forth/desktop",
"egui",
"eframe",
"egui_ratatui",
"soft_ratatui",
"image",
"dep:egui",
"dep:eframe",
"dep:egui_ratatui",
"dep:soft_ratatui",
"dep:image",
]
[dependencies]
@@ -49,15 +49,16 @@ cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" }
doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
doux = { git = "https://github.com/Bubobubobubobubo/doux.git", features = ["native"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"
cpal = "0.15"
cpal = { version = "0.17", features = ["jack"] }
clap = { version = "4", features = ["derive"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tachyonfx = { version = "0.22", features = ["std-duration"] }
tui-big-text = "0.8"
arboard = "3"
minimad = "0.13"
@@ -68,6 +69,8 @@ thread-priority = "1"
ringbuf = "0.4"
arc-swap = "1"
midir = "0.10"
parking_lot = "0.12"
libc = "0.2"
# Desktop-only dependencies (behind feature flag)
egui = { version = "0.33", optional = true }
@@ -76,6 +79,10 @@ egui_ratatui = { version = "2.1", optional = true }
soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true }
image = { version = "0.25", default-features = false, features = ["png"], optional = true }
[target.'cfg(windows)'.build-dependencies]
winres = "0.1"
[profile.release]
opt-level = 3
lto = "fat"
@@ -86,7 +93,7 @@ strip = true
[package.metadata.bundle.bin.cagire-desktop]
name = "Cagire"
identifier = "com.sova.cagire"
icon = ["assets/Cagire.icns", "assets/Cagire.ico"]
icon = ["assets/Cagire.icns", "assets/Cagire.ico", "assets/Cagire.png"]
copyright = "Copyright (c) 2025 Raphaël Forment"
category = "Music"
short_description = "Forth-based music sequencer"

View File

@@ -6,7 +6,7 @@
<img src="cagire_pixel.png" alt="Cagire" width="256">
</p>
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).
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.
## Build

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

11
build.rs Normal file
View File

@@ -0,0 +1,11 @@
fn main() {
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/Cagire.ico")
.set("ProductName", "Cagire")
.set("FileDescription", "Forth-based music sequencer")
.set("LegalCopyright", "Copyright (c) 2025 Raphaël Forment");
res.compile().expect("Failed to compile Windows resources");
}
}

View File

@@ -13,3 +13,5 @@ desktop = []
[dependencies]
rand = "0.8"
parking_lot = "0.12"
arc-swap = "1"

View File

@@ -1,3 +1,6 @@
use std::borrow::Cow;
use std::sync::Arc;
use super::ops::Op;
use super::types::{Dictionary, SourceSpan};
use super::words::compile_word;
@@ -43,7 +46,7 @@ fn tokenize(input: &str) -> Vec<Token> {
}
s.push(ch);
}
tokens.push(Token::Str(s, SourceSpan { start, end }));
tokens.push(Token::Str(s, SourceSpan { start: start as u32, end: end as u32 }));
continue;
}
@@ -64,8 +67,8 @@ fn tokenize(input: &str) -> Vec<Token> {
tokens.push(Token::Word(
";".to_string(),
SourceSpan {
start: pos,
end: pos + 1,
start: pos as u32,
end: (pos + 1) as u32,
},
));
continue;
@@ -83,10 +86,26 @@ fn tokenize(input: &str) -> Vec<Token> {
chars.next();
}
let span = SourceSpan { start, end };
if let Ok(i) = word.parse::<i64>() {
let span = SourceSpan { start: start as u32, end: end as u32 };
// Normalize shorthand float syntax: .25 -> 0.25, -.5 -> -0.5
let word_to_parse: Cow<str> = if word.starts_with('.')
&& word.len() > 1
&& word.as_bytes()[1].is_ascii_digit()
{
Cow::Owned(format!("0{word}"))
} else if word.starts_with("-.")
&& word.len() > 2
&& word.as_bytes()[2].is_ascii_digit()
{
Cow::Owned(format!("-0{}", &word[1..]))
} else {
Cow::Borrowed(&word)
};
if let Ok(i) = word_to_parse.parse::<i64>() {
tokens.push(Token::Int(i, span));
} else if let Ok(f) = word.parse::<f64>() {
} else if let Ok(f) = word_to_parse.parse::<f64>() {
tokens.push(Token::Float(f, span));
} else {
tokens.push(Token::Word(word, span));
@@ -103,22 +122,12 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
while i < tokens.len() {
match &tokens[i] {
Token::Int(n, span) => {
let key = n.to_string();
if let Some(body) = dict.lock().unwrap().get(&key).cloned() {
ops.extend(body);
} else {
ops.push(Op::PushInt(*n, Some(*span)));
}
ops.push(Op::PushInt(*n, Some(*span)));
}
Token::Float(f, span) => {
let key = f.to_string();
if let Some(body) = dict.lock().unwrap().get(&key).cloned() {
ops.extend(body);
} else {
ops.push(Op::PushFloat(*f, Some(*span)));
}
ops.push(Op::PushFloat(*f, Some(*span)));
}
Token::Str(s, span) => ops.push(Op::PushStr(s.clone(), Some(*span))),
Token::Str(s, span) => ops.push(Op::PushStr(Arc::from(s.as_str()), Some(*span))),
Token::Word(w, span) => {
let word = w.as_str();
if word == "{" {
@@ -129,13 +138,13 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
start: span.start,
end: end_span.end,
};
ops.push(Op::Quotation(quote_ops, Some(body_span)));
ops.push(Op::Quotation(Arc::from(quote_ops), Some(body_span)));
} else if word == "}" {
return Err("unexpected }".into());
} else if word == ":" {
let (consumed, name, body) = compile_colon_def(&tokens[i + 1..], dict)?;
i += consumed;
dict.lock().unwrap().insert(name, body);
dict.lock().insert(name, body);
} else if word == ";" {
return Err("unexpected ;".into());
} else if word == "if" {

View File

@@ -6,7 +6,8 @@ mod vm;
mod words;
pub use types::{
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
CcAccess, Dictionary, ExecutionTrace, ResolvedValue, Rng, SourceSpan, StepContext, Value,
Variables, VariablesMap,
};
pub use vm::Forth;
pub use words::{Word, WordCompile, WORDS};
pub use words::{lookup_word, Word, WordCompile, WORDS};

View File

@@ -1,10 +1,12 @@
use std::sync::Arc;
use super::types::SourceSpan;
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
PushInt(i64, Option<SourceSpan>),
PushFloat(f64, Option<SourceSpan>),
PushStr(String, Option<SourceSpan>),
PushStr(Arc<str>, Option<SourceSpan>),
Dup,
Dupn,
Drop,
@@ -13,6 +15,17 @@ pub enum Op {
Rot,
Nip,
Tuck,
Dup2,
Drop2,
Swap2,
Over2,
Rev,
Shuffle,
Sort,
RSort,
Sum,
Prod,
Forget,
Add,
Sub,
Mul,
@@ -47,32 +60,35 @@ pub enum Op {
BranchIfZero(usize, Option<SourceSpan>, Option<SourceSpan>),
Branch(usize),
NewCmd,
SetParam(String),
SetParam(&'static str),
Emit,
Get,
Set,
GetContext(String),
Rand,
GetContext(&'static str),
Rand(Option<SourceSpan>),
ExpRand(Option<SourceSpan>),
LogRand(Option<SourceSpan>),
Seed,
Cycle,
PCycle,
TCycle,
Choose,
ChanceExec,
ProbExec,
Coin,
Cycle(Option<SourceSpan>),
PCycle(Option<SourceSpan>),
Choose(Option<SourceSpan>),
Bounce(Option<SourceSpan>),
WChoose(Option<SourceSpan>),
ChanceExec(Option<SourceSpan>),
ProbExec(Option<SourceSpan>),
Coin(Option<SourceSpan>),
Mtof,
Ftom,
SetTempo,
Every,
Quotation(Vec<Op>, Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
When,
Unless,
Adsr,
Ad,
Apply,
Ramp,
Tri,
Triangle,
Range,
Perlin,
Chain,
@@ -83,9 +99,18 @@ pub enum Op {
SetSpeed,
At,
IntRange,
StepRange,
Generate,
GeomRange,
Euclid,
EuclidRot,
Times,
Chord(&'static [i64]),
// Audio-rate modulation DSL
ModLfo(u8),
ModSlide(u8),
ModRnd(u8),
ModEnv,
// MIDI
MidiEmit,
GetMidiCC,

View File

@@ -0,0 +1,129 @@
pub struct Chord {
pub name: &'static str,
pub intervals: &'static [i64],
}
pub static CHORDS: &[Chord] = &[
// Triads
Chord {
name: "maj",
intervals: &[0, 4, 7],
},
Chord {
name: "m",
intervals: &[0, 3, 7],
},
Chord {
name: "dim",
intervals: &[0, 3, 6],
},
Chord {
name: "aug",
intervals: &[0, 4, 8],
},
Chord {
name: "sus2",
intervals: &[0, 2, 7],
},
Chord {
name: "sus4",
intervals: &[0, 5, 7],
},
// Seventh chords
Chord {
name: "maj7",
intervals: &[0, 4, 7, 11],
},
Chord {
name: "min7",
intervals: &[0, 3, 7, 10],
},
Chord {
name: "dom7",
intervals: &[0, 4, 7, 10],
},
Chord {
name: "dim7",
intervals: &[0, 3, 6, 9],
},
Chord {
name: "m7b5",
intervals: &[0, 3, 6, 10],
},
Chord {
name: "minmaj7",
intervals: &[0, 3, 7, 11],
},
Chord {
name: "aug7",
intervals: &[0, 4, 8, 10],
},
// Sixth chords
Chord {
name: "maj6",
intervals: &[0, 4, 7, 9],
},
Chord {
name: "min6",
intervals: &[0, 3, 7, 9],
},
// Extended chords
Chord {
name: "dom9",
intervals: &[0, 4, 7, 10, 14],
},
Chord {
name: "maj9",
intervals: &[0, 4, 7, 11, 14],
},
Chord {
name: "min9",
intervals: &[0, 3, 7, 10, 14],
},
Chord {
name: "dom11",
intervals: &[0, 4, 7, 10, 14, 17],
},
Chord {
name: "min11",
intervals: &[0, 3, 7, 10, 14, 17],
},
Chord {
name: "dom13",
intervals: &[0, 4, 7, 10, 14, 21],
},
// Add chords
Chord {
name: "add9",
intervals: &[0, 4, 7, 14],
},
Chord {
name: "add11",
intervals: &[0, 4, 7, 17],
},
Chord {
name: "madd9",
intervals: &[0, 3, 7, 14],
},
// Altered dominants
Chord {
name: "dom7b9",
intervals: &[0, 4, 7, 10, 13],
},
Chord {
name: "dom7s9",
intervals: &[0, 4, 7, 10, 15],
},
Chord {
name: "dom7b5",
intervals: &[0, 4, 6, 10],
},
Chord {
name: "dom7s5",
intervals: &[0, 4, 8, 10],
},
];
pub fn lookup(name: &str) -> Option<&'static [i64]> {
CHORDS.iter().find(|c| c.name == name).map(|c| c.intervals)
}

View File

@@ -1,3 +1,4 @@
pub mod chords;
mod scales;
pub use scales::lookup;

View File

@@ -1,6 +1,8 @@
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use super::ops::Op;
@@ -12,17 +14,37 @@ pub trait CcAccess: Send + Sync {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SourceSpan {
pub start: usize,
pub end: usize,
pub start: u32,
pub end: u32,
}
#[derive(Clone, Debug)]
pub enum ResolvedValue {
Int(i64),
Float(f64),
Bool(bool),
Str(Arc<str>),
}
impl ResolvedValue {
pub fn display(&self) -> String {
match self {
ResolvedValue::Int(i) => i.to_string(),
ResolvedValue::Float(f) => format!("{f:.2}"),
ResolvedValue::Bool(b) => if *b { "yes" } else { "no" }.into(),
ResolvedValue::Str(s) => s.to_string(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ExecutionTrace {
pub executed_spans: Vec<SourceSpan>,
pub selected_spans: Vec<SourceSpan>,
pub resolved: Vec<(SourceSpan, ResolvedValue)>,
}
pub struct StepContext {
pub struct StepContext<'a> {
pub step: usize,
pub beat: f64,
pub bank: usize,
@@ -35,7 +57,9 @@ pub struct StepContext {
pub speed: f64,
pub fill: bool,
pub nudge_secs: f64,
pub cc_access: Option<Arc<dyn CcAccess>>,
pub cc_access: Option<&'a dyn CcAccess>,
pub speed_key: &'a str,
pub chain_key: &'a str,
#[cfg(feature = "desktop")]
pub mouse_x: f64,
#[cfg(feature = "desktop")]
@@ -44,25 +68,26 @@ pub struct StepContext {
pub mouse_down: f64,
}
impl StepContext {
impl StepContext<'_> {
pub fn step_duration(&self) -> f64 {
60.0 / self.tempo / 4.0 / self.speed
}
}
pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type VariablesMap = HashMap<String, Value>;
pub type Variables = Arc<ArcSwap<VariablesMap>>;
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)]);
pub type Stack = Mutex<Vec<Value>>;
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(&'static str, Value)]);
#[derive(Clone, Debug)]
pub enum Value {
Int(i64, Option<SourceSpan>),
Float(f64, Option<SourceSpan>),
Str(String, Option<SourceSpan>),
Quotation(Vec<Op>, Option<SourceSpan>),
CycleList(Vec<Value>),
Str(Arc<str>, Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
CycleList(Arc<[Value]>),
}
impl PartialEq for Value {
@@ -116,7 +141,7 @@ impl Value {
match self {
Value::Int(i, _) => i.to_string(),
Value::Float(f, _) => f.to_string(),
Value::Str(s, _) => s.clone(),
Value::Str(s, _) => s.to_string(),
Value::Quotation(..) => String::new(),
Value::CycleList(_) => String::new(),
}
@@ -133,16 +158,24 @@ impl Value {
#[derive(Clone, Debug, Default)]
pub(super) struct CmdRegister {
sound: Option<Value>,
params: Vec<(String, Value)>,
params: Vec<(&'static str, Value)>,
deltas: Vec<Value>,
}
impl CmdRegister {
pub(super) fn new() -> Self {
Self {
sound: None,
params: Vec::with_capacity(16),
deltas: Vec::with_capacity(4),
}
}
pub(super) fn set_sound(&mut self, val: Value) {
self.sound = Some(val);
}
pub(super) fn set_param(&mut self, key: String, val: Value) {
pub(super) fn set_param(&mut self, key: &'static str, val: Value) {
self.params.push((key, val));
}
@@ -154,6 +187,14 @@ impl CmdRegister {
&self.deltas
}
pub(super) fn sound(&self) -> Option<&Value> {
self.sound.as_ref()
}
pub(super) fn params(&self) -> &[(&'static str, Value)] {
&self.params
}
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()))
@@ -165,5 +206,6 @@ impl CmdRegister {
pub(super) fn clear(&mut self) {
self.sound = None;
self.params.clear();
self.deltas.clear();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
use std::sync::Arc;
use crate::ops::Op;
use crate::theory;
use crate::types::{Dictionary, SourceSpan};
use super::{lookup_word, WordCompile::*};
pub(super) fn simple_op(name: &str) -> Option<Op> {
Some(match name {
"dup" => Op::Dup,
"dupn" => Op::Dupn,
"drop" => Op::Drop,
"swap" => Op::Swap,
"over" => Op::Over,
"rot" => Op::Rot,
"nip" => Op::Nip,
"tuck" => Op::Tuck,
"2dup" => Op::Dup2,
"2drop" => Op::Drop2,
"2swap" => Op::Swap2,
"2over" => Op::Over2,
"rev" => Op::Rev,
"shuffle" => Op::Shuffle,
"sort" => Op::Sort,
"rsort" => Op::RSort,
"sum" => Op::Sum,
"prod" => Op::Prod,
"+" => Op::Add,
"-" => Op::Sub,
"*" => Op::Mul,
"/" => Op::Div,
"mod" => Op::Mod,
"neg" => Op::Neg,
"abs" => Op::Abs,
"floor" => Op::Floor,
"ceil" => Op::Ceil,
"round" => Op::Round,
"min" => Op::Min,
"max" => Op::Max,
"pow" => Op::Pow,
"sqrt" => Op::Sqrt,
"sin" => Op::Sin,
"cos" => Op::Cos,
"log" => Op::Log,
"=" => Op::Eq,
"!=" => Op::Ne,
"lt" => Op::Lt,
"gt" => Op::Gt,
"<=" => Op::Le,
">=" => Op::Ge,
"and" => Op::And,
"or" => Op::Or,
"not" => Op::Not,
"xor" => Op::Xor,
"nand" => Op::Nand,
"nor" => Op::Nor,
"ifelse" => Op::IfElse,
"pick" => Op::Pick,
"sound" => Op::NewCmd,
"." => Op::Emit,
"rand" => Op::Rand(None),
"exprand" => Op::ExpRand(None),
"logrand" => Op::LogRand(None),
"seed" => Op::Seed,
"cycle" => Op::Cycle(None),
"pcycle" => Op::PCycle(None),
"choose" => Op::Choose(None),
"bounce" => Op::Bounce(None),
"wchoose" => Op::WChoose(None),
"every" => Op::Every,
"chance" => Op::ChanceExec(None),
"prob" => Op::ProbExec(None),
"coin" => Op::Coin(None),
"mtof" => Op::Mtof,
"ftom" => Op::Ftom,
"?" => Op::When,
"!?" => Op::Unless,
"tempo!" => Op::SetTempo,
"speed!" => Op::SetSpeed,
"at" => Op::At,
"adsr" => Op::Adsr,
"ad" => Op::Ad,
"apply" => Op::Apply,
"ramp" => Op::Ramp,
"triangle" => Op::Triangle,
"range" => Op::Range,
"perlin" => Op::Perlin,
"chain" => Op::Chain,
"loop" => Op::Loop,
"oct" => Op::Oct,
"clear" => Op::ClearCmd,
".." => Op::IntRange,
".," => Op::StepRange,
"gen" => Op::Generate,
"geom.." => Op::GeomRange,
"euclid" => Op::Euclid,
"euclidrot" => Op::EuclidRot,
"times" => Op::Times,
"m." => Op::MidiEmit,
"ccval" => Op::GetMidiCC,
"mclock" => Op::MidiClock,
"mstart" => Op::MidiStart,
"mstop" => Op::MidiStop,
"mcont" => Op::MidiContinue,
"forget" => Op::Forget,
"lfo" => Op::ModLfo(0),
"tlfo" => Op::ModLfo(1),
"wlfo" => Op::ModLfo(2),
"qlfo" => Op::ModLfo(3),
"slide" => Op::ModSlide(0),
"expslide" => Op::ModSlide(1),
"sslide" => Op::ModSlide(2),
"jit" => Op::ModRnd(0),
"sjit" => Op::ModRnd(1),
"drunk" => Op::ModRnd(2),
"env" => Op::ModEnv,
_ => return None,
})
}
fn parse_note_name(name: &str) -> Option<i64> {
let name = name.to_lowercase();
let bytes = name.as_bytes();
if bytes.len() < 2 {
return None;
}
let base = match bytes[0] {
b'c' => 0,
b'd' => 2,
b'e' => 4,
b'f' => 5,
b'g' => 7,
b'a' => 9,
b'b' => 11,
_ => return None,
};
let (modifier, octave_start) = match bytes[1] {
b'#' | b's' => (1, 2),
b'b' if bytes.len() > 2 && bytes[2].is_ascii_digit() => (-1, 2),
b'0'..=b'9' => (0, 1),
_ => return None,
};
let octave_str = &name[octave_start..];
let octave: i64 = octave_str.parse().ok()?;
if !(-1..=9).contains(&octave) {
return None;
}
Some((octave + 1) * 12 + base + modifier)
}
fn parse_interval(name: &str) -> Option<i64> {
let simple = match name {
"P1" | "unison" => 0,
"m2" => 1,
"M2" => 2,
"m3" => 3,
"M3" => 4,
"P4" => 5,
"aug4" | "dim5" | "tritone" => 6,
"P5" => 7,
"m6" => 8,
"M6" => 9,
"m7" => 10,
"M7" => 11,
"P8" => 12,
"m9" => 13,
"M9" => 14,
"m10" => 15,
"M10" => 16,
"P11" => 17,
"aug11" => 18,
"P12" => 19,
"m13" => 20,
"M13" => 21,
"m14" => 22,
"M14" => 23,
"P15" => 24,
_ => return None,
};
Some(simple)
}
fn attach_span(op: &mut Op, span: SourceSpan) {
match op {
Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s)
| Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s)
| Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) => *s = Some(span),
_ => {}
}
}
pub(crate) fn compile_word(
name: &str,
span: Option<SourceSpan>,
ops: &mut Vec<Op>,
dict: &Dictionary,
) -> bool {
match name {
"linramp" => {
ops.push(Op::PushFloat(1.0, span));
ops.push(Op::Ramp);
return true;
}
"expramp" => {
ops.push(Op::PushFloat(3.0, span));
ops.push(Op::Ramp);
return true;
}
"logramp" => {
ops.push(Op::PushFloat(0.3, span));
ops.push(Op::Ramp);
return true;
}
_ => {}
}
if let Some(pattern) = theory::lookup(name) {
ops.push(Op::Degree(pattern));
return true;
}
if let Some(intervals) = theory::chords::lookup(name) {
ops.push(Op::Chord(intervals));
return true;
}
if let Some(word) = lookup_word(name) {
match &word.compile {
Simple => {
if let Some(mut op) = simple_op(word.name) {
if let Some(sp) = span {
attach_span(&mut op, sp);
}
ops.push(op);
}
}
Context(ctx) => ops.push(Op::GetContext(ctx)),
Param => ops.push(Op::SetParam(word.name)),
Probability(p) => {
ops.push(Op::PushFloat(*p, None));
ops.push(Op::ChanceExec(span));
}
}
return true;
}
if let Some(var_name) = name.strip_prefix('@') {
if !var_name.is_empty() {
ops.push(Op::PushStr(Arc::from(var_name), span));
ops.push(Op::Get);
return true;
}
}
if let Some(var_name) = name.strip_prefix('!') {
if !var_name.is_empty() {
ops.push(Op::PushStr(Arc::from(var_name), span));
ops.push(Op::Set);
return true;
}
}
if let Some(midi) = parse_note_name(name) {
ops.push(Op::PushInt(midi, span));
return true;
}
if let Some(semitones) = parse_interval(name) {
ops.push(Op::Dup);
ops.push(Op::PushInt(semitones, span));
ops.push(Op::Add);
return true;
}
if let Some(op) = simple_op(name) {
ops.push(op);
return true;
}
if let Some(body) = dict.lock().get(name) {
ops.extend(body.iter().cloned());
return true;
}
ops.push(Op::PushStr(Arc::from(name), span));
true
}

View File

@@ -0,0 +1,592 @@
use super::{Word, WordCompile::*};
// Stack, Arithmetic, Comparison, Logic, Control, Variables, Definitions
pub(super) const WORDS: &[Word] = &[
// Stack manipulation
Word {
name: "dup",
aliases: &[],
category: "Stack",
stack: "(a -- a a)",
desc: "Duplicate top of stack",
example: "3 dup => 3 3",
compile: Simple,
varargs: false,
},
Word {
name: "dupn",
aliases: &["!"],
category: "Stack",
stack: "(a n -- a a ... a)",
desc: "Duplicate a onto stack n times",
example: "2 4 dupn => 2 2 2 2",
compile: Simple,
varargs: true,
},
Word {
name: "drop",
aliases: &[],
category: "Stack",
stack: "(a --)",
desc: "Remove top of stack",
example: "1 2 drop => 1",
compile: Simple,
varargs: false,
},
Word {
name: "swap",
aliases: &[],
category: "Stack",
stack: "(a b -- b a)",
desc: "Exchange top two items",
example: "1 2 swap => 2 1",
compile: Simple,
varargs: false,
},
Word {
name: "over",
aliases: &[],
category: "Stack",
stack: "(a b -- a b a)",
desc: "Copy second to top",
example: "1 2 over => 1 2 1",
compile: Simple,
varargs: false,
},
Word {
name: "rot",
aliases: &[],
category: "Stack",
stack: "(a b c -- b c a)",
desc: "Rotate top three",
example: "1 2 3 rot => 2 3 1",
compile: Simple,
varargs: false,
},
Word {
name: "nip",
aliases: &[],
category: "Stack",
stack: "(a b -- b)",
desc: "Remove second item",
example: "1 2 nip => 2",
compile: Simple,
varargs: false,
},
Word {
name: "tuck",
aliases: &[],
category: "Stack",
stack: "(a b -- b a b)",
desc: "Copy top under second",
example: "1 2 tuck => 2 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2dup",
aliases: &[],
category: "Stack",
stack: "(a b -- a b a b)",
desc: "Duplicate top two values",
example: "1 2 2dup => 1 2 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2drop",
aliases: &[],
category: "Stack",
stack: "(a b --)",
desc: "Drop top two values",
example: "1 2 3 2drop => 1",
compile: Simple,
varargs: false,
},
Word {
name: "2swap",
aliases: &[],
category: "Stack",
stack: "(a b c d -- c d a b)",
desc: "Swap top two pairs",
example: "1 2 3 4 2swap => 3 4 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2over",
aliases: &[],
category: "Stack",
stack: "(a b c d -- a b c d a b)",
desc: "Copy second pair to top",
example: "1 2 3 4 2over => 1 2 3 4 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "rev",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Reverse top n items",
example: "1 2 3 3 rev => 3 2 1",
compile: Simple,
varargs: true,
},
Word {
name: "shuffle",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Randomly shuffle top n items",
example: "1 2 3 3 shuffle",
compile: Simple,
varargs: true,
},
Word {
name: "sort",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Sort top n items ascending",
example: "3 1 2 3 sort => 1 2 3",
compile: Simple,
varargs: true,
},
Word {
name: "rsort",
aliases: &[],
category: "Stack",
stack: "(..n n -- ..n)",
desc: "Sort top n items descending",
example: "1 2 3 3 rsort => 3 2 1",
compile: Simple,
varargs: true,
},
Word {
name: "sum",
aliases: &[],
category: "Stack",
stack: "(..n n -- total)",
desc: "Sum top n items",
example: "1 2 3 3 sum => 6",
compile: Simple,
varargs: true,
},
Word {
name: "prod",
aliases: &[],
category: "Stack",
stack: "(..n n -- product)",
desc: "Multiply top n items",
example: "2 3 4 3 prod => 24",
compile: Simple,
varargs: true,
},
// Arithmetic
Word {
name: "+",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a+b)",
desc: "Add",
example: "2 3 + => 5",
compile: Simple,
varargs: false,
},
Word {
name: "-",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a-b)",
desc: "Subtract",
example: "5 3 - => 2",
compile: Simple,
varargs: false,
},
Word {
name: "*",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a*b)",
desc: "Multiply",
example: "3 4 * => 12",
compile: Simple,
varargs: false,
},
Word {
name: "/",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a/b)",
desc: "Divide",
example: "10 2 / => 5",
compile: Simple,
varargs: false,
},
Word {
name: "mod",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a%b)",
desc: "Modulo",
example: "7 3 mod => 1",
compile: Simple,
varargs: false,
},
Word {
name: "neg",
aliases: &[],
category: "Arithmetic",
stack: "(a -- -a)",
desc: "Negate",
example: "5 neg => -5",
compile: Simple,
varargs: false,
},
Word {
name: "abs",
aliases: &[],
category: "Arithmetic",
stack: "(a -- |a|)",
desc: "Absolute value",
example: "-5 abs => 5",
compile: Simple,
varargs: false,
},
Word {
name: "floor",
aliases: &[],
category: "Arithmetic",
stack: "(f -- n)",
desc: "Round down to integer",
example: "3.7 floor => 3",
compile: Simple,
varargs: false,
},
Word {
name: "ceil",
aliases: &[],
category: "Arithmetic",
stack: "(f -- n)",
desc: "Round up to integer",
example: "3.2 ceil => 4",
compile: Simple,
varargs: false,
},
Word {
name: "round",
aliases: &[],
category: "Arithmetic",
stack: "(f -- n)",
desc: "Round to nearest integer",
example: "3.5 round => 4",
compile: Simple,
varargs: false,
},
Word {
name: "min",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- min)",
desc: "Minimum of two values",
example: "3 5 min => 3",
compile: Simple,
varargs: false,
},
Word {
name: "max",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- max)",
desc: "Maximum of two values",
example: "3 5 max => 5",
compile: Simple,
varargs: false,
},
Word {
name: "pow",
aliases: &[],
category: "Arithmetic",
stack: "(a b -- a^b)",
desc: "Exponentiation",
example: "2 3 pow => 8",
compile: Simple,
varargs: false,
},
Word {
name: "sqrt",
aliases: &[],
category: "Arithmetic",
stack: "(a -- √a)",
desc: "Square root",
example: "16 sqrt => 4",
compile: Simple,
varargs: false,
},
Word {
name: "sin",
aliases: &[],
category: "Arithmetic",
stack: "(a -- sin(a))",
desc: "Sine (radians)",
example: "3.14159 2 / sin => 1.0",
compile: Simple,
varargs: false,
},
Word {
name: "cos",
aliases: &[],
category: "Arithmetic",
stack: "(a -- cos(a))",
desc: "Cosine (radians)",
example: "0 cos => 1.0",
compile: Simple,
varargs: false,
},
Word {
name: "log",
aliases: &[],
category: "Arithmetic",
stack: "(a -- ln(a))",
desc: "Natural logarithm",
example: "2.718 log => 1.0",
compile: Simple,
varargs: false,
},
// Comparison
Word {
name: "=",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Equal",
example: "3 3 = => 1",
compile: Simple,
varargs: false,
},
Word {
name: "!=",
aliases: &["<>"],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Not equal",
example: "3 4 != => 1",
compile: Simple,
varargs: false,
},
Word {
name: "lt",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Less than",
example: "2 3 lt => 1",
compile: Simple,
varargs: false,
},
Word {
name: "gt",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Greater than",
example: "3 2 gt => 1",
compile: Simple,
varargs: false,
},
Word {
name: "<=",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Less or equal",
example: "3 3 <= => 1",
compile: Simple,
varargs: false,
},
Word {
name: ">=",
aliases: &[],
category: "Comparison",
stack: "(a b -- bool)",
desc: "Greater or equal",
example: "3 3 >= => 1",
compile: Simple,
varargs: false,
},
// Logic
Word {
name: "and",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Logical and",
example: "1 1 and => 1",
compile: Simple,
varargs: false,
},
Word {
name: "or",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Logical or",
example: "0 1 or => 1",
compile: Simple,
varargs: false,
},
Word {
name: "not",
aliases: &[],
category: "Logic",
stack: "(a -- bool)",
desc: "Logical not",
example: "0 not => 1",
compile: Simple,
varargs: false,
},
Word {
name: "xor",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Exclusive or",
example: "1 0 xor => 1",
compile: Simple,
varargs: false,
},
Word {
name: "nand",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Not and",
example: "1 1 nand => 0",
compile: Simple,
varargs: false,
},
Word {
name: "nor",
aliases: &[],
category: "Logic",
stack: "(a b -- bool)",
desc: "Not or",
example: "0 0 nor => 1",
compile: Simple,
varargs: false,
},
Word {
name: "ifelse",
aliases: &[],
category: "Logic",
stack: "(true-quot false-quot bool --)",
desc: "Execute true-quot if true, else false-quot",
example: "{ 1 } { 2 } coin ifelse",
compile: Simple,
varargs: false,
},
Word {
name: "pick",
aliases: &[],
category: "Logic",
stack: "(..quots n --)",
desc: "Execute nth quotation (0-indexed)",
example: "{ 1 } { 2 } { 3 } 2 pick => 3",
compile: Simple,
varargs: true,
},
Word {
name: "?",
aliases: &[],
category: "Logic",
stack: "(quot bool --)",
desc: "Execute quotation if true",
example: "{ 2 distort } 0.5 chance ?",
compile: Simple,
varargs: false,
},
Word {
name: "!?",
aliases: &[],
category: "Logic",
stack: "(quot bool --)",
desc: "Execute quotation if false",
example: "{ 1 distort } 0.5 chance !?",
compile: Simple,
varargs: false,
},
Word {
name: "apply",
aliases: &[],
category: "Logic",
stack: "(quot --)",
desc: "Execute quotation unconditionally",
example: "{ 2 * } apply",
compile: Simple,
varargs: false,
},
// Control
Word {
name: "times",
aliases: &[],
category: "Control",
stack: "(n quot --)",
desc: "Execute quotation n times, @i holds current index",
example: "4 { @i . } times => 0 1 2 3",
compile: Simple,
varargs: false,
},
// Variables
Word {
name: "@<var>",
aliases: &[],
category: "Variables",
stack: "( -- val)",
desc: "Fetch variable value",
example: "@freq => 440",
compile: Simple,
varargs: false,
},
Word {
name: "!<var>",
aliases: &[],
category: "Variables",
stack: "(val --)",
desc: "Store value in variable",
example: "440 !freq",
compile: Simple,
varargs: false,
},
// Definitions
Word {
name: ":",
aliases: &[],
category: "Definitions",
stack: "( -- )",
desc: "Begin word definition",
example: ": kick \"kick\" s emit ;",
compile: Simple,
varargs: false,
},
Word {
name: ";",
aliases: &[],
category: "Definitions",
stack: "( -- )",
desc: "End word definition",
example: ": kick \"kick\" s emit ;",
compile: Simple,
varargs: false,
},
Word {
name: "forget",
aliases: &[],
category: "Definitions",
stack: "(name --)",
desc: "Remove user-defined word from dictionary",
example: "\"double\" forget",
compile: Simple,
varargs: false,
},
];

View File

@@ -0,0 +1,852 @@
use super::{Word, WordCompile::*};
// Filter, Envelope, Reverb, Delay, Lo-fi, Stereo, Mod FX
pub(super) const WORDS: &[Word] = &[
// Envelope
Word {
name: "gain",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set volume (0-1)",
example: "0.8 gain",
compile: Param,
varargs: false,
},
Word {
name: "postgain",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set post gain",
example: "1.2 postgain",
compile: Param,
varargs: false,
},
Word {
name: "velocity",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set velocity",
example: "100 velocity",
compile: Param,
varargs: false,
},
Word {
name: "attack",
aliases: &["att"],
category: "Envelope",
stack: "(f --)",
desc: "Set attack time",
example: "0.01 attack",
compile: Param,
varargs: false,
},
Word {
name: "decay",
aliases: &["dec"],
category: "Envelope",
stack: "(f --)",
desc: "Set decay time",
example: "0.1 decay",
compile: Param,
varargs: false,
},
Word {
name: "sustain",
aliases: &["sus"],
category: "Envelope",
stack: "(f --)",
desc: "Set sustain level",
example: "0.5 sustain",
compile: Param,
varargs: false,
},
Word {
name: "release",
aliases: &["rel"],
category: "Envelope",
stack: "(f --)",
desc: "Set release time",
example: "0.3 release",
compile: Param,
varargs: false,
},
Word {
name: "adsr",
aliases: &[],
category: "Envelope",
stack: "(a d s r --)",
desc: "Set attack, decay, sustain, release",
example: "0.01 0.1 0.5 0.3 adsr",
compile: Simple,
varargs: false,
},
Word {
name: "ad",
aliases: &[],
category: "Envelope",
stack: "(a d --)",
desc: "Set attack, decay (sustain=0)",
example: "0.01 0.1 ad",
compile: Simple,
varargs: false,
},
Word {
name: "penv",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set pitch envelope",
example: "0.5 penv",
compile: Param,
varargs: false,
},
Word {
name: "patt",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set pitch attack",
example: "0.01 patt",
compile: Param,
varargs: false,
},
Word {
name: "pdec",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set pitch decay",
example: "0.1 pdec",
compile: Param,
varargs: false,
},
Word {
name: "psus",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set pitch sustain",
example: "0 psus",
compile: Param,
varargs: false,
},
Word {
name: "prel",
aliases: &[],
category: "Envelope",
stack: "(f --)",
desc: "Set pitch release",
example: "0.1 prel",
compile: Param,
varargs: false,
},
// Filter
Word {
name: "lpf",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass frequency",
example: "2000 lpf",
compile: Param,
varargs: false,
},
Word {
name: "lpq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass resonance",
example: "0.5 lpq",
compile: Param,
varargs: false,
},
Word {
name: "lpe",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass envelope",
example: "0.5 lpe",
compile: Param,
varargs: false,
},
Word {
name: "lpa",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass attack",
example: "0.01 lpa",
compile: Param,
varargs: false,
},
Word {
name: "lpd",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass decay",
example: "0.1 lpd",
compile: Param,
varargs: false,
},
Word {
name: "lps",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass sustain",
example: "0.5 lps",
compile: Param,
varargs: false,
},
Word {
name: "lpr",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set lowpass release",
example: "0.3 lpr",
compile: Param,
varargs: false,
},
Word {
name: "hpf",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass frequency",
example: "100 hpf",
compile: Param,
varargs: false,
},
Word {
name: "hpq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass resonance",
example: "0.5 hpq",
compile: Param,
varargs: false,
},
Word {
name: "hpe",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass envelope",
example: "0.5 hpe",
compile: Param,
varargs: false,
},
Word {
name: "hpa",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass attack",
example: "0.01 hpa",
compile: Param,
varargs: false,
},
Word {
name: "hpd",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass decay",
example: "0.1 hpd",
compile: Param,
varargs: false,
},
Word {
name: "hps",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass sustain",
example: "0.5 hps",
compile: Param,
varargs: false,
},
Word {
name: "hpr",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set highpass release",
example: "0.3 hpr",
compile: Param,
varargs: false,
},
Word {
name: "bpf",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass frequency",
example: "1000 bpf",
compile: Param,
varargs: false,
},
Word {
name: "bpq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass resonance",
example: "0.5 bpq",
compile: Param,
varargs: false,
},
Word {
name: "bpe",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass envelope",
example: "0.5 bpe",
compile: Param,
varargs: false,
},
Word {
name: "bpa",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass attack",
example: "0.01 bpa",
compile: Param,
varargs: false,
},
Word {
name: "bpd",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass decay",
example: "0.1 bpd",
compile: Param,
varargs: false,
},
Word {
name: "bps",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass sustain",
example: "0.5 bps",
compile: Param,
varargs: false,
},
Word {
name: "bpr",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set bandpass release",
example: "0.3 bpr",
compile: Param,
varargs: false,
},
Word {
name: "llpf",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set ladder lowpass frequency",
example: "2000 llpf",
compile: Param,
varargs: false,
},
Word {
name: "llpq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set ladder lowpass resonance",
example: "0.5 llpq",
compile: Param,
varargs: false,
},
Word {
name: "lhpf",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set ladder highpass frequency",
example: "100 lhpf",
compile: Param,
varargs: false,
},
Word {
name: "lhpq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set ladder highpass resonance",
example: "0.5 lhpq",
compile: Param,
varargs: false,
},
Word {
name: "lbpf",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set ladder bandpass frequency",
example: "1000 lbpf",
compile: Param,
varargs: false,
},
Word {
name: "lbpq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set ladder bandpass resonance",
example: "0.5 lbpq",
compile: Param,
varargs: false,
},
Word {
name: "ftype",
aliases: &[],
category: "Filter",
stack: "(n --)",
desc: "Set filter type",
example: "1 ftype",
compile: Param,
varargs: false,
},
Word {
name: "eqlo",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set low shelf gain (dB)",
example: "3 eqlo",
compile: Param,
varargs: false,
},
Word {
name: "eqmid",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set mid peak gain (dB)",
example: "-2 eqmid",
compile: Param,
varargs: false,
},
Word {
name: "eqhi",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set high shelf gain (dB)",
example: "1 eqhi",
compile: Param,
varargs: false,
},
Word {
name: "tilt",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set tilt EQ (-1 dark, 1 bright)",
example: "-0.5 tilt",
compile: Param,
varargs: false,
},
Word {
name: "comb",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set comb filter mix",
example: "0.5 comb",
compile: Param,
varargs: false,
},
Word {
name: "combfreq",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set comb frequency",
example: "200 combfreq",
compile: Param,
varargs: false,
},
Word {
name: "combfeedback",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set comb feedback",
example: "0.5 combfeedback",
compile: Param,
varargs: false,
},
Word {
name: "combdamp",
aliases: &[],
category: "Filter",
stack: "(f --)",
desc: "Set comb damping",
example: "0.5 combdamp",
compile: Param,
varargs: false,
},
// Reverb
Word {
name: "verb",
aliases: &[],
category: "Reverb",
stack: "(f --)",
desc: "Set reverb mix",
example: "0.3 verb",
compile: Param,
varargs: false,
},
Word {
name: "verbdecay",
aliases: &[],
category: "Reverb",
stack: "(f --)",
desc: "Set reverb decay",
example: "2 verbdecay",
compile: Param,
varargs: false,
},
Word {
name: "verbdamp",
aliases: &[],
category: "Reverb",
stack: "(f --)",
desc: "Set reverb damping",
example: "0.5 verbdamp",
compile: Param,
varargs: false,
},
Word {
name: "verbpredelay",
aliases: &[],
category: "Reverb",
stack: "(f --)",
desc: "Set reverb predelay",
example: "0.02 verbpredelay",
compile: Param,
varargs: false,
},
Word {
name: "verbdiff",
aliases: &[],
category: "Reverb",
stack: "(f --)",
desc: "Set reverb diffusion",
example: "0.7 verbdiff",
compile: Param,
varargs: false,
},
Word {
name: "size",
aliases: &[],
category: "Reverb",
stack: "(f --)",
desc: "Set size",
example: "1 size",
compile: Param,
varargs: false,
},
// Delay
Word {
name: "delay",
aliases: &[],
category: "Delay",
stack: "(f --)",
desc: "Set delay mix",
example: "0.3 delay",
compile: Param,
varargs: false,
},
Word {
name: "delaytime",
aliases: &[],
category: "Delay",
stack: "(f --)",
desc: "Set delay time",
example: "0.25 delaytime",
compile: Param,
varargs: false,
},
Word {
name: "delayfeedback",
aliases: &[],
category: "Delay",
stack: "(f --)",
desc: "Set delay feedback",
example: "0.5 delayfeedback",
compile: Param,
varargs: false,
},
Word {
name: "delaytype",
aliases: &[],
category: "Delay",
stack: "(n --)",
desc: "Set delay type",
example: "1 delaytype",
compile: Param,
varargs: false,
},
// Lo-fi
Word {
name: "crush",
aliases: &[],
category: "Lo-fi",
stack: "(f --)",
desc: "Set bit crush",
example: "8 crush",
compile: Param,
varargs: false,
},
Word {
name: "fold",
aliases: &[],
category: "Lo-fi",
stack: "(f --)",
desc: "Set wave fold",
example: "2 fold",
compile: Param,
varargs: false,
},
Word {
name: "wrap",
aliases: &[],
category: "Lo-fi",
stack: "(f --)",
desc: "Set wave wrap",
example: "0.5 wrap",
compile: Param,
varargs: false,
},
Word {
name: "distort",
aliases: &[],
category: "Lo-fi",
stack: "(f --)",
desc: "Set distortion",
example: "0.5 distort",
compile: Param,
varargs: false,
},
Word {
name: "distortvol",
aliases: &[],
category: "Lo-fi",
stack: "(f --)",
desc: "Set distortion volume",
example: "0.8 distortvol",
compile: Param,
varargs: false,
},
// Stereo
Word {
name: "pan",
aliases: &[],
category: "Stereo",
stack: "(f --)",
desc: "Set pan (-1 to 1)",
example: "0.5 pan",
compile: Param,
varargs: false,
},
Word {
name: "width",
aliases: &[],
category: "Stereo",
stack: "(f --)",
desc: "Set stereo width (0 mono, 1 normal, 2 wide)",
example: "0 width",
compile: Param,
varargs: false,
},
Word {
name: "haas",
aliases: &[],
category: "Stereo",
stack: "(f --)",
desc: "Set Haas delay in ms (spatial placement)",
example: "8 haas",
compile: Param,
varargs: false,
},
// Mod FX
Word {
name: "phaser",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set phaser rate",
example: "1 phaser",
compile: Param,
varargs: false,
},
Word {
name: "phaserdepth",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set phaser depth",
example: "0.5 phaserdepth",
compile: Param,
varargs: false,
},
Word {
name: "phasersweep",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set phaser sweep",
example: "0.5 phasersweep",
compile: Param,
varargs: false,
},
Word {
name: "phasercenter",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set phaser center",
example: "1000 phasercenter",
compile: Param,
varargs: false,
},
Word {
name: "flanger",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set flanger rate",
example: "0.5 flanger",
compile: Param,
varargs: false,
},
Word {
name: "flangerdepth",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set flanger depth",
example: "0.5 flangerdepth",
compile: Param,
varargs: false,
},
Word {
name: "flangerfeedback",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set flanger feedback",
example: "0.5 flangerfeedback",
compile: Param,
varargs: false,
},
Word {
name: "chorus",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set chorus rate",
example: "1 chorus",
compile: Param,
varargs: false,
},
Word {
name: "chorusdepth",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set chorus depth",
example: "0.5 chorusdepth",
compile: Param,
varargs: false,
},
Word {
name: "chorusdelay",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set chorus delay",
example: "0.02 chorusdelay",
compile: Param,
varargs: false,
},
Word {
name: "feedback",
aliases: &["fb"],
category: "Mod FX",
stack: "(f --)",
desc: "Set feedback delay level",
example: "0.7 feedback",
compile: Param,
varargs: false,
},
Word {
name: "fbtime",
aliases: &["fbt"],
category: "Mod FX",
stack: "(f --)",
desc: "Set feedback delay time in ms",
example: "30 fbtime",
compile: Param,
varargs: false,
},
Word {
name: "fbdamp",
aliases: &["fbd"],
category: "Mod FX",
stack: "(f --)",
desc: "Set feedback delay damping",
example: "0.3 fbdamp",
compile: Param,
varargs: false,
},
Word {
name: "fblfo",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set feedback delay LFO rate in Hz",
example: "2 fblfo",
compile: Param,
varargs: false,
},
Word {
name: "fblfodepth",
aliases: &[],
category: "Mod FX",
stack: "(f --)",
desc: "Set feedback delay LFO depth",
example: "0.5 fblfodepth",
compile: Param,
varargs: false,
},
Word {
name: "fblfoshape",
aliases: &[],
category: "Mod FX",
stack: "(s --)",
desc: "Set feedback delay LFO shape",
example: "tri fblfoshape",
compile: Param,
varargs: false,
},
];

View File

@@ -0,0 +1,135 @@
use super::{Word, WordCompile::*};
// MIDI
pub(super) const WORDS: &[Word] = &[
Word {
name: "chan",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI channel 1-16",
example: "1 chan",
compile: Param,
varargs: false,
},
Word {
name: "ccnum",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI CC number 0-127",
example: "1 ccnum",
compile: Param,
varargs: false,
},
Word {
name: "ccout",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI CC output value 0-127",
example: "64 ccout",
compile: Param,
varargs: false,
},
Word {
name: "bend",
aliases: &[],
category: "MIDI",
stack: "(f --)",
desc: "Set pitch bend -1.0 to 1.0 (0 = center)",
example: "0.5 bend",
compile: Param,
varargs: false,
},
Word {
name: "pressure",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set channel pressure (aftertouch) 0-127",
example: "64 pressure",
compile: Param,
varargs: false,
},
Word {
name: "program",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set program change number 0-127",
example: "0 program",
compile: Param,
varargs: false,
},
Word {
name: "m.",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Emit MIDI message from params (note/cc/bend/pressure/program)",
example: "60 note 100 velocity 1 chan m.",
compile: Simple,
varargs: false,
},
Word {
name: "mclock",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI clock pulse (24 per quarter note)",
example: "mclock",
compile: Simple,
varargs: false,
},
Word {
name: "mstart",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI start message",
example: "mstart",
compile: Simple,
varargs: false,
},
Word {
name: "mstop",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI stop message",
example: "mstop",
compile: Simple,
varargs: false,
},
Word {
name: "mcont",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI continue message",
example: "mcont",
compile: Simple,
varargs: false,
},
Word {
name: "ccval",
aliases: &[],
category: "MIDI",
stack: "(cc chan -- val)",
desc: "Read CC value 0-127 from MIDI input (uses dev param for device)",
example: "1 1 ccval",
compile: Simple,
varargs: false,
},
Word {
name: "dev",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI device slot 0-3 for output/input",
example: "1 dev 60 note m.",
compile: Param,
varargs: false,
},
];

View File

@@ -0,0 +1,58 @@
mod compile;
mod core;
mod effects;
mod midi;
mod music;
mod sequencing;
mod sound;
use std::collections::HashMap;
use std::sync::LazyLock;
pub(crate) use compile::compile_word;
#[derive(Clone, Copy)]
pub enum WordCompile {
Simple,
Context(&'static str),
Param,
Probability(f64),
}
#[derive(Clone, Copy)]
pub struct Word {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub category: &'static str,
pub stack: &'static str,
pub desc: &'static str,
pub example: &'static str,
pub compile: WordCompile,
pub varargs: bool,
}
pub static WORDS: LazyLock<Vec<Word>> = LazyLock::new(|| {
let mut words = Vec::new();
words.extend_from_slice(self::core::WORDS);
words.extend_from_slice(sound::WORDS);
words.extend_from_slice(effects::WORDS);
words.extend_from_slice(sequencing::WORDS);
words.extend_from_slice(music::WORDS);
words.extend_from_slice(midi::WORDS);
words
});
static WORD_MAP: LazyLock<HashMap<&'static str, &'static Word>> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(WORDS.len() * 2);
for word in WORDS.iter() {
map.insert(word.name, word);
for alias in word.aliases {
map.insert(alias, word);
}
}
map
});
pub fn lookup_word(name: &str) -> Option<&'static Word> {
WORD_MAP.get(name).copied()
}

View File

@@ -0,0 +1,312 @@
use super::{Word, WordCompile::*};
// Music, Chord
pub(super) const WORDS: &[Word] = &[
// Music
Word {
name: "mtof",
aliases: &[],
category: "Music",
stack: "(midi -- hz)",
desc: "MIDI note to frequency",
example: "69 mtof => 440.0",
compile: Simple,
varargs: false,
},
Word {
name: "ftom",
aliases: &[],
category: "Music",
stack: "(hz -- midi)",
desc: "Frequency to MIDI note",
example: "440 ftom => 69.0",
compile: Simple,
varargs: false,
},
// Chords - Triads
Word {
name: "maj",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Major triad",
example: "c4 maj => 60 64 67",
compile: Simple,
varargs: true,
},
Word {
name: "m",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Minor triad",
example: "c4 m => 60 63 67",
compile: Simple,
varargs: true,
},
Word {
name: "dim",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Diminished triad",
example: "c4 dim => 60 63 66",
compile: Simple,
varargs: true,
},
Word {
name: "aug",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Augmented triad",
example: "c4 aug => 60 64 68",
compile: Simple,
varargs: true,
},
Word {
name: "sus2",
aliases: &[],
category: "Chord",
stack: "(root -- root second fifth)",
desc: "Suspended 2nd",
example: "c4 sus2 => 60 62 67",
compile: Simple,
varargs: true,
},
Word {
name: "sus4",
aliases: &[],
category: "Chord",
stack: "(root -- root fourth fifth)",
desc: "Suspended 4th",
example: "c4 sus4 => 60 65 67",
compile: Simple,
varargs: true,
},
// Chords - Seventh
Word {
name: "maj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Major 7th",
example: "c4 maj7 => 60 64 67 71",
compile: Simple,
varargs: true,
},
Word {
name: "min7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Minor 7th",
example: "c4 min7 => 60 63 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Dominant 7th",
example: "c4 dom7 => 60 64 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "dim7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Diminished 7th",
example: "c4 dim7 => 60 63 66 69",
compile: Simple,
varargs: true,
},
Word {
name: "m7b5",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Half-diminished (min7b5)",
example: "c4 m7b5 => 60 63 66 70",
compile: Simple,
varargs: true,
},
Word {
name: "minmaj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Minor-major 7th",
example: "c4 minmaj7 => 60 63 67 71",
compile: Simple,
varargs: true,
},
Word {
name: "aug7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Augmented 7th",
example: "c4 aug7 => 60 64 68 70",
compile: Simple,
varargs: true,
},
// Chords - Sixth
Word {
name: "maj6",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth)",
desc: "Major 6th",
example: "c4 maj6 => 60 64 67 69",
compile: Simple,
varargs: true,
},
Word {
name: "min6",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth)",
desc: "Minor 6th",
example: "c4 min6 => 60 63 67 69",
compile: Simple,
varargs: true,
},
// Chords - Extended
Word {
name: "dom9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Dominant 9th",
example: "c4 dom9 => 60 64 67 70 74",
compile: Simple,
varargs: true,
},
Word {
name: "maj9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Major 9th",
example: "c4 maj9 => 60 64 67 71 74",
compile: Simple,
varargs: true,
},
Word {
name: "min9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Minor 9th",
example: "c4 min9 => 60 63 67 70 74",
compile: Simple,
varargs: true,
},
Word {
name: "dom11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Dominant 11th",
example: "c4 dom11 => 60 64 67 70 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "min11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Minor 11th",
example: "c4 min11 => 60 63 67 70 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "dom13",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth thirteenth)",
desc: "Dominant 13th",
example: "c4 dom13 => 60 64 67 70 74 81",
compile: Simple,
varargs: true,
},
// Chords - Add
Word {
name: "add9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth ninth)",
desc: "Major add 9",
example: "c4 add9 => 60 64 67 74",
compile: Simple,
varargs: true,
},
Word {
name: "add11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth eleventh)",
desc: "Major add 11",
example: "c4 add11 => 60 64 67 77",
compile: Simple,
varargs: true,
},
Word {
name: "madd9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth ninth)",
desc: "Minor add 9",
example: "c4 madd9 => 60 63 67 74",
compile: Simple,
varargs: true,
},
// Chords - Altered dominants
Word {
name: "dom7b9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh flatninth)",
desc: "7th flat 9",
example: "c4 dom7b9 => 60 64 67 70 73",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh sharpninth)",
desc: "7th sharp 9 (Hendrix chord)",
example: "c4 dom7s9 => 60 64 67 70 75",
compile: Simple,
varargs: true,
},
Word {
name: "dom7b5",
aliases: &[],
category: "Chord",
stack: "(root -- root third flatfifth seventh)",
desc: "7th flat 5",
example: "c4 dom7b5 => 60 64 66 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s5",
aliases: &[],
category: "Chord",
stack: "(root -- root third sharpfifth seventh)",
desc: "7th sharp 5",
example: "c4 dom7s5 => 60 64 68 70",
compile: Simple,
varargs: true,
},
];

View File

@@ -0,0 +1,463 @@
use super::{Word, WordCompile::*};
// Time, Context, Probability, Generator, Desktop
pub(super) const WORDS: &[Word] = &[
// Probability
Word {
name: "rand",
aliases: &[],
category: "Probability",
stack: "(min max -- n|f)",
desc: "Random in range. Int if both args are int, float otherwise",
example: "1 6 rand => 4 | 0.0 1.0 rand => 0.42",
compile: Simple,
varargs: false,
},
Word {
name: "exprand",
aliases: &[],
category: "Probability",
stack: "(lo hi -- f)",
desc: "Exponential random biased toward lo. Both args must be positive",
example: "1.0 100.0 exprand => 3.7",
compile: Simple,
varargs: false,
},
Word {
name: "logrand",
aliases: &[],
category: "Probability",
stack: "(lo hi -- f)",
desc: "Exponential random biased toward hi. Both args must be positive",
example: "1.0 100.0 logrand => 87.2",
compile: Simple,
varargs: false,
},
Word {
name: "seed",
aliases: &[],
category: "Probability",
stack: "(n --)",
desc: "Set random seed",
example: "12345 seed",
compile: Simple,
varargs: false,
},
Word {
name: "coin",
aliases: &[],
category: "Probability",
stack: "(-- bool)",
desc: "50/50 random boolean",
example: "coin => 0 or 1",
compile: Simple,
varargs: false,
},
Word {
name: "chance",
aliases: &[],
category: "Probability",
stack: "(quot prob --)",
desc: "Execute quotation with probability (0.0-1.0)",
example: "{ 2 distort } 0.75 chance",
compile: Simple,
varargs: false,
},
Word {
name: "prob",
aliases: &[],
category: "Probability",
stack: "(quot pct --)",
desc: "Execute quotation with probability (0-100)",
example: "{ 2 distort } 75 prob",
compile: Simple,
varargs: false,
},
Word {
name: "choose",
aliases: &[],
category: "Probability",
stack: "(..n n -- val)",
desc: "Random pick from n items",
example: "1 2 3 3 choose",
compile: Simple,
varargs: true,
},
Word {
name: "cycle",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Cycle through n items by step runs",
example: "60 64 67 3 cycle",
compile: Simple,
varargs: true,
},
Word {
name: "pcycle",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Cycle through n items by pattern iteration",
example: "60 64 67 3 pcycle",
compile: Simple,
varargs: true,
},
Word {
name: "bounce",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- selected)",
desc: "Ping-pong cycle through n items by step runs",
example: "60 64 67 72 4 bounce",
compile: Simple,
varargs: true,
},
Word {
name: "wchoose",
aliases: &[],
category: "Probability",
stack: "(v1 w1 v2 w2 ... n -- selected)",
desc: "Weighted random pick from n value/weight pairs",
example: "60 0.6 64 0.3 67 0.1 3 wchoose",
compile: Simple,
varargs: true,
},
Word {
name: "always",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Always execute quotation",
example: "{ 2 distort } always",
compile: Probability(1.0),
varargs: false,
},
Word {
name: "never",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Never execute quotation",
example: "{ 2 distort } never",
compile: Probability(0.0),
varargs: false,
},
Word {
name: "often",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 75% of the time",
example: "{ 2 distort } often",
compile: Probability(0.75),
varargs: false,
},
Word {
name: "sometimes",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 50% of the time",
example: "{ 2 distort } sometimes",
compile: Probability(0.5),
varargs: false,
},
Word {
name: "rarely",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 25% of the time",
example: "{ 2 distort } rarely",
compile: Probability(0.25),
varargs: false,
},
Word {
name: "almostNever",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 10% of the time",
example: "{ 2 distort } almostNever",
compile: Probability(0.1),
varargs: false,
},
Word {
name: "almostAlways",
aliases: &[],
category: "Probability",
stack: "(quot --)",
desc: "Execute quotation 90% of the time",
example: "{ 2 distort } almostAlways",
compile: Probability(0.9),
varargs: false,
},
// Time
Word {
name: "every",
aliases: &[],
category: "Time",
stack: "(n -- bool)",
desc: "True every nth iteration",
example: "4 every",
compile: Simple,
varargs: false,
},
Word {
name: "loop",
aliases: &[],
category: "Time",
stack: "(n --)",
desc: "Fit sample to n beats",
example: "\"break\" s 4 loop @",
compile: Simple,
varargs: false,
},
Word {
name: "tempo!",
aliases: &[],
category: "Time",
stack: "(bpm --)",
desc: "Set global tempo",
example: "140 tempo!",
compile: Simple,
varargs: false,
},
Word {
name: "speed!",
aliases: &[],
category: "Time",
stack: "(multiplier --)",
desc: "Set pattern speed multiplier",
example: "2.0 speed!",
compile: Simple,
varargs: false,
},
Word {
name: "chain",
aliases: &[],
category: "Time",
stack: "(bank pattern --)",
desc: "Chain to bank/pattern (1-indexed) when current pattern ends",
example: "1 4 chain",
compile: Simple,
varargs: false,
},
Word {
name: "at",
aliases: &[],
category: "Time",
stack: "(v1..vn --)",
desc: "Set delta context for emit timing",
example: "0 0.5 at kick s . => emits at 0 and 0.5 of step",
compile: Simple,
varargs: true,
},
// Context
Word {
name: "step",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current step index",
example: "step => 0",
compile: Context("step"),
varargs: false,
},
Word {
name: "beat",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Current beat position",
example: "beat => 4.5",
compile: Context("beat"),
varargs: false,
},
Word {
name: "pattern",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current pattern index",
example: "pattern => 0",
compile: Context("pattern"),
varargs: false,
},
Word {
name: "pbank",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current pattern's bank index",
example: "pbank => 0",
compile: Context("bank"),
varargs: false,
},
Word {
name: "tempo",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Current BPM",
example: "tempo => 120.0",
compile: Context("tempo"),
varargs: false,
},
Word {
name: "phase",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Phase in bar (0-1)",
example: "phase => 0.25",
compile: Context("phase"),
varargs: false,
},
Word {
name: "slot",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Current slot number",
example: "slot => 0",
compile: Context("slot"),
varargs: false,
},
Word {
name: "runs",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Times this step ran",
example: "runs => 3",
compile: Context("runs"),
varargs: false,
},
Word {
name: "iter",
aliases: &[],
category: "Context",
stack: "(-- n)",
desc: "Pattern iteration count",
example: "iter => 2",
compile: Context("iter"),
varargs: false,
},
Word {
name: "stepdur",
aliases: &[],
category: "Context",
stack: "(-- f)",
desc: "Step duration in seconds",
example: "stepdur => 0.125",
compile: Context("stepdur"),
varargs: false,
},
Word {
name: "fill",
aliases: &[],
category: "Context",
stack: "(-- bool)",
desc: "True when fill is on (f key)",
example: "\"snare\" s . fill ?",
compile: Context("fill"),
varargs: false,
},
// Desktop
#[cfg(feature = "desktop")]
Word {
name: "mx",
aliases: &[],
category: "Desktop",
stack: "(-- x)",
desc: "Normalized mouse X position (0-1)",
example: "mx 440 880 range freq",
compile: Context("mx"),
varargs: false,
},
#[cfg(feature = "desktop")]
Word {
name: "my",
aliases: &[],
category: "Desktop",
stack: "(-- y)",
desc: "Normalized mouse Y position (0-1)",
example: "my 0.1 0.9 range gain",
compile: Context("my"),
varargs: false,
},
#[cfg(feature = "desktop")]
Word {
name: "mdown",
aliases: &[],
category: "Desktop",
stack: "(-- bool)",
desc: "1 when mouse button held, 0 otherwise",
example: "mdown { \"crash\" s . } ?",
compile: Context("mdown"),
varargs: false,
},
// Generator
Word {
name: "..",
aliases: &[],
category: "Generator",
stack: "(start end -- start start+1 ... end)",
desc: "Push arithmetic sequence from start to end",
example: "1 4 .. => 1 2 3 4",
compile: Simple,
varargs: false,
},
Word {
name: ".,",
aliases: &[],
category: "Generator",
stack: "(start end step -- start start+step ...)",
desc: "Push arithmetic sequence with custom step",
example: "0 1 0.25 ., => 0 0.25 0.5 0.75 1",
compile: Simple,
varargs: false,
},
Word {
name: "gen",
aliases: &[],
category: "Generator",
stack: "(quot n -- results...)",
desc: "Execute quotation n times, push all results",
example: "{ 1 6 rand } 4 gen => 4 random values",
compile: Simple,
varargs: true,
},
Word {
name: "geom..",
aliases: &[],
category: "Generator",
stack: "(start ratio count -- start start*r start*r^2 ...)",
desc: "Push geometric sequence",
example: "1 2 4 geom.. => 1 2 4 8",
compile: Simple,
varargs: false,
},
Word {
name: "euclid",
aliases: &[],
category: "Generator",
stack: "(k n -- i1 i2 ... ik)",
desc: "Push indices for k hits evenly distributed over n steps",
example: "4 8 euclid => 0 2 4 6",
compile: Simple,
varargs: false,
},
Word {
name: "euclidrot",
aliases: &[],
category: "Generator",
stack: "(k n r -- i1 i2 ... ik)",
desc: "Push Euclidean indices with rotation r",
example: "3 8 2 euclidrot => 1 4 6",
compile: Simple,
varargs: false,
},
];

View File

@@ -0,0 +1,773 @@
use super::{Word, WordCompile::*};
// Sound, Oscillator, Sample, Wavetable, FM, Modulation, LFO
pub(super) const WORDS: &[Word] = &[
// Sound
Word {
name: "sound",
aliases: &["s"],
category: "Sound",
stack: "(name --)",
desc: "Begin sound command",
example: "\"kick\" sound",
compile: Simple,
varargs: false,
},
Word {
name: ".",
aliases: &[],
category: "Sound",
stack: "(--)",
desc: "Emit current sound",
example: "\"kick\" s . . . .",
compile: Simple,
varargs: false,
},
Word {
name: "clear",
aliases: &[],
category: "Sound",
stack: "(--)",
desc: "Clear sound register (sound and all params)",
example: "\"kick\" s 0.5 gain . clear \"hat\" s .",
compile: Simple,
varargs: false,
},
// Sample
Word {
name: "bank",
aliases: &[],
category: "Sample",
stack: "(str --)",
desc: "Set sample bank suffix",
example: "\"a\" bank",
compile: Param,
varargs: false,
},
Word {
name: "time",
aliases: &[],
category: "Sample",
stack: "(f --)",
desc: "Set time offset",
example: "0.1 time",
compile: Param,
varargs: false,
},
Word {
name: "repeat",
aliases: &[],
category: "Sample",
stack: "(n --)",
desc: "Set repeat count",
example: "4 repeat",
compile: Param,
varargs: false,
},
Word {
name: "dur",
aliases: &[],
category: "Sample",
stack: "(f --)",
desc: "Set duration",
example: "0.5 dur",
compile: Param,
varargs: false,
},
Word {
name: "gate",
aliases: &[],
category: "Sample",
stack: "(f --)",
desc: "Set gate time",
example: "0.8 gate",
compile: Param,
varargs: false,
},
Word {
name: "speed",
aliases: &[],
category: "Sample",
stack: "(f --)",
desc: "Set playback speed",
example: "1.5 speed",
compile: Param,
varargs: false,
},
Word {
name: "begin",
aliases: &[],
category: "Sample",
stack: "(f --)",
desc: "Set sample start (0-1)",
example: "0.25 begin",
compile: Param,
varargs: false,
},
Word {
name: "end",
aliases: &[],
category: "Sample",
stack: "(f --)",
desc: "Set sample end (0-1)",
example: "0.75 end",
compile: Param,
varargs: false,
},
Word {
name: "voice",
aliases: &[],
category: "Sample",
stack: "(n --)",
desc: "Set voice number",
example: "1 voice",
compile: Param,
varargs: false,
},
Word {
name: "orbit",
aliases: &[],
category: "Sample",
stack: "(n --)",
desc: "Set orbit/bus",
example: "0 orbit",
compile: Param,
varargs: false,
},
Word {
name: "n",
aliases: &[],
category: "Sample",
stack: "(n --)",
desc: "Set sample number",
example: "0 n",
compile: Param,
varargs: false,
},
Word {
name: "cut",
aliases: &[],
category: "Sample",
stack: "(n --)",
desc: "Set cut group",
example: "1 cut",
compile: Param,
varargs: false,
},
Word {
name: "reset",
aliases: &[],
category: "Sample",
stack: "(n --)",
desc: "Reset parameter",
example: "1 reset",
compile: Param,
varargs: false,
},
// Oscillator
Word {
name: "freq",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set frequency (Hz)",
example: "440 freq",
compile: Param,
varargs: false,
},
Word {
name: "detune",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set detune amount",
example: "0.01 detune",
compile: Param,
varargs: false,
},
Word {
name: "glide",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set glide/portamento",
example: "0.1 glide",
compile: Param,
varargs: false,
},
Word {
name: "pw",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set pulse width",
example: "0.5 pw",
compile: Param,
varargs: false,
},
Word {
name: "spread",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set stereo spread",
example: "0.5 spread",
compile: Param,
varargs: false,
},
Word {
name: "mult",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set multiplier",
example: "2 mult",
compile: Param,
varargs: false,
},
Word {
name: "warp",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set warp amount",
example: "0.5 warp",
compile: Param,
varargs: false,
},
Word {
name: "mirror",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set mirror",
example: "1 mirror",
compile: Param,
varargs: false,
},
Word {
name: "harmonics",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set harmonics (mutable only)",
example: "4 harmonics",
compile: Param,
varargs: false,
},
Word {
name: "timbre",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set timbre (mutable only)",
example: "0.5 timbre",
compile: Param,
varargs: false,
},
Word {
name: "morph",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set morph (mutable only)",
example: "0.5 morph",
compile: Param,
varargs: false,
},
Word {
name: "coarse",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set coarse tune",
example: "12 coarse",
compile: Param,
varargs: false,
},
Word {
name: "sub",
aliases: &[],
category: "Oscillator",
stack: "(f --)",
desc: "Set sub oscillator level",
example: "0.5 sub",
compile: Param,
varargs: false,
},
Word {
name: "suboct",
aliases: &[],
category: "Oscillator",
stack: "(n --)",
desc: "Set sub oscillator octave",
example: "2 suboct",
compile: Param,
varargs: false,
},
Word {
name: "subwave",
aliases: &[],
category: "Oscillator",
stack: "(n --)",
desc: "Set sub oscillator waveform",
example: "1 subwave",
compile: Param,
varargs: false,
},
Word {
name: "note",
aliases: &[],
category: "Oscillator",
stack: "(n --)",
desc: "Set MIDI note",
example: "60 note",
compile: Param,
varargs: false,
},
// Wavetable
Word {
name: "scan",
aliases: &[],
category: "Wavetable",
stack: "(f --)",
desc: "Set wavetable scan position (0-1)",
example: "0.5 scan",
compile: Param,
varargs: false,
},
Word {
name: "wtlen",
aliases: &[],
category: "Wavetable",
stack: "(n --)",
desc: "Set wavetable cycle length in samples",
example: "2048 wtlen",
compile: Param,
varargs: false,
},
Word {
name: "scanlfo",
aliases: &[],
category: "Wavetable",
stack: "(f --)",
desc: "Set scan LFO rate (Hz)",
example: "0.2 scanlfo",
compile: Param,
varargs: false,
},
Word {
name: "scandepth",
aliases: &[],
category: "Wavetable",
stack: "(f --)",
desc: "Set scan LFO depth (0-1)",
example: "0.4 scandepth",
compile: Param,
varargs: false,
},
Word {
name: "scanshape",
aliases: &[],
category: "Wavetable",
stack: "(s --)",
desc: "Set scan LFO shape (sine/tri/saw/square/sh)",
example: "\"tri\" scanshape",
compile: Param,
varargs: false,
},
// FM
Word {
name: "fm",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM frequency",
example: "200 fm",
compile: Param,
varargs: false,
},
Word {
name: "fmh",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM harmonic ratio",
example: "2 fmh",
compile: Param,
varargs: false,
},
Word {
name: "fmshape",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM shape",
example: "0 fmshape",
compile: Param,
varargs: false,
},
Word {
name: "fme",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM envelope",
example: "0.5 fme",
compile: Param,
varargs: false,
},
Word {
name: "fma",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM attack",
example: "0.01 fma",
compile: Param,
varargs: false,
},
Word {
name: "fmd",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM decay",
example: "0.1 fmd",
compile: Param,
varargs: false,
},
Word {
name: "fms",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM sustain",
example: "0.5 fms",
compile: Param,
varargs: false,
},
Word {
name: "fmr",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM release",
example: "0.1 fmr",
compile: Param,
varargs: false,
},
Word {
name: "fm2",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM operator 2 depth",
example: "1.5 fm2",
compile: Param,
varargs: false,
},
Word {
name: "fm2h",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM operator 2 harmonic ratio",
example: "3 fm2h",
compile: Param,
varargs: false,
},
Word {
name: "fmalgo",
aliases: &[],
category: "FM",
stack: "(n --)",
desc: "Set FM algorithm (0=cascade 1=parallel 2=branch)",
example: "0 fmalgo",
compile: Param,
varargs: false,
},
Word {
name: "fmfb",
aliases: &[],
category: "FM",
stack: "(f --)",
desc: "Set FM feedback amount",
example: "0.5 fmfb",
compile: Param,
varargs: false,
},
// Modulation
Word {
name: "vib",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set vibrato rate",
example: "5 vib",
compile: Param,
varargs: false,
},
Word {
name: "vibmod",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set vibrato depth",
example: "0.5 vibmod",
compile: Param,
varargs: false,
},
Word {
name: "vibshape",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set vibrato shape",
example: "0 vibshape",
compile: Param,
varargs: false,
},
Word {
name: "am",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set AM frequency",
example: "10 am",
compile: Param,
varargs: false,
},
Word {
name: "amdepth",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set AM depth",
example: "0.5 amdepth",
compile: Param,
varargs: false,
},
Word {
name: "amshape",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set AM shape",
example: "0 amshape",
compile: Param,
varargs: false,
},
Word {
name: "rm",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set RM frequency",
example: "100 rm",
compile: Param,
varargs: false,
},
Word {
name: "rmdepth",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set RM depth",
example: "0.5 rmdepth",
compile: Param,
varargs: false,
},
Word {
name: "rmshape",
aliases: &[],
category: "Modulation",
stack: "(f --)",
desc: "Set RM shape",
example: "0 rmshape",
compile: Param,
varargs: false,
},
// LFO
Word {
name: "ramp",
aliases: &[],
category: "LFO",
stack: "(freq curve -- val)",
desc: "Ramp [0,1]: fract(freq*beat)^curve",
example: "0.25 2.0 ramp",
compile: Simple,
varargs: false,
},
Word {
name: "range",
aliases: &[],
category: "LFO",
stack: "(val min max -- scaled)",
desc: "Scale [0,1] to [min,max]",
example: "0.5 200 800 range => 500",
compile: Simple,
varargs: false,
},
Word {
name: "linramp",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Linear ramp (curve=1)",
example: "1.0 linramp",
compile: Simple,
varargs: false,
},
Word {
name: "expramp",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Exponential ramp (curve=3)",
example: "0.25 expramp",
compile: Simple,
varargs: false,
},
Word {
name: "logramp",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Logarithmic ramp (curve=0.3)",
example: "2.0 logramp",
compile: Simple,
varargs: false,
},
Word {
name: "triangle",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Triangle wave [0,1]: 0→1→0",
example: "0.5 triangle",
compile: Simple,
varargs: false,
},
Word {
name: "perlin",
aliases: &[],
category: "LFO",
stack: "(freq -- val)",
desc: "Perlin noise [0,1] sampled at freq*beat",
example: "0.25 perlin",
compile: Simple,
varargs: false,
},
// Audio-rate Modulation DSL
Word {
name: "lfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Sine oscillation: min~max:period",
example: "200 4000 2 lfo lpf",
compile: Simple,
varargs: false,
},
Word {
name: "tlfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Triangle oscillation: min~max:periodt",
example: "0.3 0.7 0.5 tlfo pan",
compile: Simple,
varargs: false,
},
Word {
name: "wlfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Sawtooth oscillation: min~max:periodw",
example: "200 4000 1 wlfo lpf",
compile: Simple,
varargs: false,
},
Word {
name: "qlfo",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Square oscillation: min~max:periodq",
example: "0.0 1.0 0.25 qlfo gain",
compile: Simple,
varargs: false,
},
Word {
name: "slide",
aliases: &[],
category: "Audio Modulation",
stack: "(start end dur -- str)",
desc: "Linear transition: start>end:dur",
example: "0 1 0.01 slide gain",
compile: Simple,
varargs: false,
},
Word {
name: "expslide",
aliases: &[],
category: "Audio Modulation",
stack: "(start end dur -- str)",
desc: "Exponential transition: start>end:dure",
example: "0 1 0.5 expslide gain",
compile: Simple,
varargs: false,
},
Word {
name: "sslide",
aliases: &[],
category: "Audio Modulation",
stack: "(start end dur -- str)",
desc: "Smooth transition: start>end:durs",
example: "200 800 1 sslide lpf",
compile: Simple,
varargs: false,
},
Word {
name: "jit",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Random hold: min?max:period",
example: "200 4000 0.5 jit lpf",
compile: Simple,
varargs: false,
},
Word {
name: "sjit",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Smooth random: min?max:periods",
example: "200 4000 0.5 sjit lpf",
compile: Simple,
varargs: false,
},
Word {
name: "drunk",
aliases: &[],
category: "Audio Modulation",
stack: "(min max period -- str)",
desc: "Drunk walk: min?max:periodd",
example: "200 4000 0.5 drunk lpf",
compile: Simple,
varargs: false,
},
Word {
name: "env",
aliases: &[],
category: "Audio Modulation",
stack: "(start t1 d1 ... -- str)",
desc: "Multi-segment envelope: start>t1:d1>...",
example: "0 1 0.01 0.7 0.1 0 2 env gain",
compile: Simple,
varargs: false,
},
];

View File

@@ -25,6 +25,10 @@ struct ProjectFile {
sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
tempo: f64,
#[serde(default)]
playing_patterns: Vec<(usize, usize)>,
#[serde(default, skip_serializing_if = "String::is_empty")]
prelude: String,
}
fn default_tempo() -> f64 {
@@ -38,6 +42,8 @@ impl From<&Project> for ProjectFile {
banks: project.banks.clone(),
sample_paths: project.sample_paths.clone(),
tempo: project.tempo,
playing_patterns: project.playing_patterns.clone(),
prelude: project.prelude.clone(),
}
}
}
@@ -48,6 +54,8 @@ impl From<ProjectFile> for Project {
banks: file.banks,
sample_paths: file.sample_paths,
tempo: file.tempo,
playing_patterns: file.playing_patterns,
prelude: file.prelude,
};
project.normalize();
project

View File

@@ -210,10 +210,8 @@ impl SyncMode {
pub struct Step {
pub active: bool,
pub script: String,
#[serde(skip)]
pub command: Option<String>,
#[serde(default)]
pub source: Option<usize>,
pub source: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
@@ -229,7 +227,6 @@ impl Default for Step {
Self {
active: true,
script: String::new(),
command: None,
source: None,
name: None,
}
@@ -254,7 +251,7 @@ struct SparseStep {
#[serde(default, skip_serializing_if = "String::is_empty")]
script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<usize>,
source: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
@@ -348,7 +345,6 @@ impl<'de> Deserialize<'de> for Pattern {
steps[ss.i] = Step {
active: ss.active,
script: ss.script,
command: None,
source: ss.source,
name: ss.name,
};
@@ -410,7 +406,7 @@ impl Pattern {
for _ in 0..self.steps.len() {
if let Some(step) = self.steps.get(current) {
if let Some(source) = step.source {
current = source;
current = source as usize;
} else {
return current;
}
@@ -450,6 +446,10 @@ pub struct Project {
pub sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
pub tempo: f64,
#[serde(default)]
pub playing_patterns: Vec<(usize, usize)>,
#[serde(default)]
pub prelude: String,
}
fn default_tempo() -> f64 {
@@ -462,6 +462,8 @@ impl Default for Project {
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
sample_paths: Vec::new(),
tempo: default_tempo(),
playing_patterns: Vec::new(),
prelude: String::new(),
}
}
}

View File

@@ -0,0 +1,104 @@
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MuteStatus {
Normal,
Muted,
Soloed,
EffectivelyMuted, // Solo active on another pattern
}
pub struct ActivePatterns<'a> {
patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter)
mute_status: Option<&'a [MuteStatus]>,
current_step: Option<(usize, usize)>, // (current_step, total_steps)
}
impl<'a> ActivePatterns<'a> {
pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self {
Self {
patterns,
mute_status: None,
current_step: None,
}
}
pub fn with_step(mut self, current: usize, total: usize) -> Self {
self.current_step = Some((current, total));
self
}
pub fn with_mute_status(mut self, status: &'a [MuteStatus]) -> Self {
self.mute_status = Some(status);
self
}
}
impl Widget for ActivePatterns<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 10 || area.height == 0 {
return;
}
let theme = theme::get();
let max_pattern_rows = if self.current_step.is_some() {
area.height.saturating_sub(1) as usize
} else {
area.height as usize
};
for (row, &(bank, pattern, iter)) in self.patterns.iter().enumerate() {
if row >= max_pattern_rows {
break;
}
let mute_status = self
.mute_status
.and_then(|s| s.get(row))
.copied()
.unwrap_or(MuteStatus::Normal);
let (prefix, fg, bg) = match mute_status {
MuteStatus::Soloed => ("S", theme.list.soloed_fg, theme.list.soloed_bg),
MuteStatus::Muted => ("M", theme.list.muted_fg, theme.list.muted_bg),
MuteStatus::EffectivelyMuted => (" ", theme.list.muted_fg, theme.list.muted_bg),
MuteStatus::Normal => {
let bg = if row % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
(" ", theme.ui.text_primary, bg)
}
};
let text = format!("{}B{:02}:{:02}({:02})", prefix, bank + 1, pattern + 1, iter.min(99));
let y = area.y + row as u16;
let mut chars = text.chars();
for col in 0..area.width as usize {
let ch = chars.next().unwrap_or(' ');
buf[(area.x + col as u16, y)]
.set_char(ch)
.set_fg(fg)
.set_bg(bg);
}
}
if let Some((current, total)) = self.current_step {
let text = format!("{:02}/{:02}", current + 1, total);
let y = area.y + area.height.saturating_sub(1);
let mut chars = text.chars();
for col in 0..area.width as usize {
let ch = chars.next().unwrap_or(' ');
buf[(area.x + col as u16, y)]
.set_char(ch)
.set_fg(theme.ui.text_primary)
.set_bg(theme.table.row_even);
}
}
}
}

View File

@@ -22,7 +22,7 @@ impl<'a> ConfirmModal<'a> {
}
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let t = theme::get();
let inner = ModalFrame::new(self.title)
.width(30)
@@ -58,5 +58,7 @@ impl<'a> ConfirmModal<'a> {
Paragraph::new(buttons).alignment(Alignment::Center),
rows[1],
);
inner
}
}

View File

@@ -1,3 +1,5 @@
use std::cell::Cell;
use crate::theme;
use ratatui::{
layout::Rect,
@@ -8,8 +10,9 @@ use ratatui::{
};
use tui_textarea::TextArea;
pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String)>;
pub type Highlighter<'a> = &'a dyn Fn(usize, &str) -> Vec<(Style, String, bool)>;
#[derive(Clone)]
pub struct CompletionCandidate {
pub name: String,
pub signature: String,
@@ -41,6 +44,26 @@ impl CompletionState {
}
}
struct SampleFinderState {
query: String,
folders: Vec<String>,
matches: Vec<usize>,
cursor: usize,
active: bool,
}
impl SampleFinderState {
fn new() -> Self {
Self {
query: String::new(),
folders: Vec::new(),
matches: Vec::new(),
cursor: 0,
active: false,
}
}
}
struct SearchState {
query: String,
active: bool,
@@ -58,7 +81,9 @@ impl SearchState {
pub struct Editor {
text: TextArea<'static>,
completion: CompletionState,
sample_finder: SampleFinderState,
search: SearchState,
scroll_offset: Cell<u16>,
}
impl Editor {
@@ -106,15 +131,19 @@ impl Editor {
Self {
text: TextArea::default(),
completion: CompletionState::new(),
sample_finder: SampleFinderState::new(),
search: SearchState::new(),
scroll_offset: Cell::new(0),
}
}
pub fn set_content(&mut self, lines: Vec<String>) {
self.text = TextArea::new(lines);
self.completion.active = false;
self.sample_finder.active = false;
self.search.query.clear();
self.search.active = false;
self.scroll_offset.set(0);
}
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
@@ -145,6 +174,18 @@ impl Editor {
self.completion.active = false;
}
pub fn completion_next(&mut self) {
if self.completion.cursor + 1 < self.completion.matches.len() {
self.completion.cursor += 1;
}
}
pub fn completion_prev(&mut self) {
if self.completion.cursor > 0 {
self.completion.cursor -= 1;
}
}
pub fn set_completion_enabled(&mut self, enabled: bool) {
self.completion.enabled = enabled;
if !enabled {
@@ -208,24 +249,85 @@ impl Editor {
}
}
pub fn set_sample_folders(&mut self, folders: Vec<String>) {
self.sample_finder.folders = folders;
}
pub fn activate_sample_finder(&mut self) {
self.completion.active = false;
self.sample_finder.query.clear();
self.sample_finder.cursor = 0;
self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect();
self.sample_finder.active = true;
}
pub fn dismiss_sample_finder(&mut self) {
self.sample_finder.active = false;
}
pub fn sample_finder_active(&self) -> bool {
self.sample_finder.active
}
pub fn sample_finder_input(&mut self, c: char) {
self.sample_finder.query.push(c);
self.update_sample_finder_matches();
}
pub fn sample_finder_backspace(&mut self) {
self.sample_finder.query.pop();
self.update_sample_finder_matches();
}
pub fn sample_finder_next(&mut self) {
if self.sample_finder.cursor + 1 < self.sample_finder.matches.len() {
self.sample_finder.cursor += 1;
}
}
pub fn sample_finder_prev(&mut self) {
if self.sample_finder.cursor > 0 {
self.sample_finder.cursor -= 1;
}
}
pub fn accept_sample_finder(&mut self) {
if self.sample_finder.matches.is_empty() {
self.sample_finder.active = false;
return;
}
let idx = self.sample_finder.matches[self.sample_finder.cursor];
let name = self.sample_finder.folders[idx].clone();
self.text.insert_str(&name);
self.sample_finder.active = false;
}
fn update_sample_finder_matches(&mut self) {
if self.sample_finder.query.is_empty() {
self.sample_finder.matches = (0..self.sample_finder.folders.len()).collect();
} else {
let mut scored: Vec<(usize, usize)> = self
.sample_finder
.folders
.iter()
.enumerate()
.filter_map(|(i, name)| fuzzy_match(&self.sample_finder.query, name).map(|s| (s, i)))
.collect();
scored.sort_by_key(|(score, _)| *score);
self.sample_finder.matches = scored.into_iter().map(|(_, i)| i).collect();
}
self.sample_finder.cursor = self
.sample_finder
.cursor
.min(self.sample_finder.matches.len().saturating_sub(1));
}
pub fn input(&mut self, input: impl Into<tui_textarea::Input>) {
let input: tui_textarea::Input = input.into();
let has_modifier = input.ctrl || input.alt;
if self.completion.active && !has_modifier {
match &input {
tui_textarea::Input { key: tui_textarea::Key::Up, .. } => {
if self.completion.cursor > 0 {
self.completion.cursor -= 1;
}
return;
}
tui_textarea::Input { key: tui_textarea::Key::Down, .. } => {
if self.completion.cursor + 1 < self.completion.matches.len() {
self.completion.cursor += 1;
}
return;
}
tui_textarea::Input { key: tui_textarea::Key::Tab, .. } => {
self.accept_completion();
return;
@@ -255,7 +357,7 @@ impl Editor {
}
fn update_completion(&mut self) {
if !self.completion.enabled || self.completion.candidates.is_empty() {
if !self.completion.enabled || self.completion.candidates.is_empty() || self.sample_finder.active {
return;
}
@@ -350,21 +452,25 @@ impl Editor {
let mut spans: Vec<Span> = Vec::new();
let mut col = 0;
for (base_style, text) in tokens {
for (base_style, text, is_annotation) in tokens {
for ch in text.chars() {
let is_cursor = row == cursor_row && col == cursor_col;
let is_selected = is_in_selection(row, col, selection);
let style = if is_cursor {
cursor_style
} else if is_selected {
base_style.bg(selection_style.bg.unwrap())
} else {
let style = if is_annotation {
base_style
} else {
let is_cursor = row == cursor_row && col == cursor_col;
let is_selected = is_in_selection(row, col, selection);
if is_cursor {
cursor_style
} else if is_selected {
base_style.bg(selection_style.bg.unwrap())
} else {
base_style
}
};
spans.push(Span::styled(ch.to_string(), style));
col += 1;
if !is_annotation {
col += 1;
}
}
}
@@ -376,10 +482,23 @@ impl Editor {
})
.collect();
frame.render_widget(Paragraph::new(lines), area);
let viewport_height = area.height as usize;
let offset = self.scroll_offset.get() as usize;
let offset = if cursor_row < offset {
cursor_row
} else if cursor_row >= offset + viewport_height {
cursor_row - viewport_height + 1
} else {
offset
};
self.scroll_offset.set(offset as u16);
if self.completion.active && !self.completion.matches.is_empty() {
self.render_completion(frame, area, cursor_row);
frame.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), area);
if self.sample_finder.active && !self.sample_finder.matches.is_empty() {
self.render_sample_finder(frame, area, cursor_row - offset);
} else if self.completion.active && !self.completion.matches.is_empty() {
self.render_completion(frame, area, cursor_row - offset);
}
}
@@ -494,6 +613,98 @@ impl Editor {
frame.render_widget(Paragraph::new(doc_lines), doc_area);
}
fn render_sample_finder(&self, frame: &mut Frame, editor_area: Rect, cursor_row: usize) {
let t = theme::get();
let max_visible: usize = 8;
let width: u16 = 24;
let visible_count = self.sample_finder.matches.len().min(max_visible);
let total_height = visible_count as u16 + 1; // +1 for query line
let (_, cursor_col) = self.text.cursor();
let popup_x = (editor_area.x + cursor_col as u16)
.min(editor_area.x + editor_area.width.saturating_sub(width));
let below_y = editor_area.y + cursor_row as u16 + 1;
let popup_y = if below_y + total_height > editor_area.y + editor_area.height {
(editor_area.y + cursor_row as u16).saturating_sub(total_height)
} else {
below_y
};
let area = Rect::new(popup_x, popup_y, width, total_height);
frame.render_widget(Clear, area);
let bg_style = Style::default().bg(t.editor_widget.completion_bg);
let highlight_style = Style::default()
.fg(t.editor_widget.completion_selected)
.add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(t.editor_widget.completion_fg);
let w = width as usize;
let mut lines: Vec<Line> = Vec::new();
let query_display = format!("/{}", self.sample_finder.query);
lines.push(Line::from(Span::styled(
format!("{query_display:<w$}"),
highlight_style.bg(t.editor_widget.completion_bg),
)));
let scroll_offset = if self.sample_finder.cursor >= max_visible {
self.sample_finder.cursor - max_visible + 1
} else {
0
};
for i in scroll_offset..scroll_offset + visible_count {
let idx = self.sample_finder.matches[i];
let name = &self.sample_finder.folders[idx];
let style = if i == self.sample_finder.cursor {
highlight_style
} else {
normal_style
};
let prefix = if i == self.sample_finder.cursor { "> " } else { " " };
let display = format!("{prefix}{name:<width$}", width = w - 2);
lines.push(Line::from(Span::styled(
format!("{display:<w$}"),
style.bg(t.editor_widget.completion_bg),
)));
}
// Fill rest with bg
for _ in lines.len() as u16..total_height {
lines.push(Line::from(Span::styled(" ".repeat(w), bg_style)));
}
frame.render_widget(Paragraph::new(lines), area);
}
}
pub fn fuzzy_match(query: &str, target: &str) -> Option<usize> {
let target_lower: Vec<char> = target.to_lowercase().chars().collect();
let query_lower: Vec<char> = query.to_lowercase().chars().collect();
let mut ti = 0;
let mut score = 0;
let mut prev_pos = 0;
for (qi, &qc) in query_lower.iter().enumerate() {
loop {
if ti >= target_lower.len() {
return None;
}
if target_lower[ti] == qc {
if qi > 0 {
score += ti - prev_pos;
}
prev_pos = ti;
ti += 1;
break;
}
ti += 1;
}
}
Some(score)
}
fn is_word_char(c: char) -> bool {

View File

@@ -57,7 +57,7 @@ impl<'a> FileBrowserModal<'a> {
self
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
@@ -112,5 +112,7 @@ impl<'a> FileBrowserModal<'a> {
.collect();
frame.render_widget(Paragraph::new(lines), rows[1]);
inner
}
}

View File

@@ -1,3 +1,4 @@
mod active_patterns;
mod confirm;
mod editor;
mod file_browser;
@@ -11,9 +12,11 @@ mod spectrum;
mod text_input;
pub mod theme;
mod vu_meter;
mod waveform;
pub use active_patterns::{ActivePatterns, MuteStatus};
pub use confirm::ConfirmModal;
pub use editor::{CompletionCandidate, Editor};
pub use editor::{fuzzy_match, CompletionCandidate, Editor};
pub use file_browser::FileBrowserModal;
pub use list_select::ListSelect;
pub use modal::ModalFrame;
@@ -24,3 +27,4 @@ pub use sparkles::Sparkles;
pub use spectrum::Spectrum;
pub use text_input::TextInputModal;
pub use vu_meter::VuMeter;
pub use waveform::Waveform;

View File

@@ -158,12 +158,17 @@ impl<'a> SampleBrowser<'a> {
Style::new().fg(icon_color)
};
let spans = vec![
let mut spans = vec![
Span::raw(indent),
Span::styled(icon, icon_style),
Span::styled(&entry.label, label_style),
];
if matches!(entry.kind, TreeLineKind::File) {
let idx_style = Style::new().fg(colors.browser.empty_text);
spans.push(Span::styled(format!(" {}", entry.index), idx_style));
}
lines.push(Line::from(spans));
}

View File

@@ -41,7 +41,7 @@ impl<'a> TextInputModal<'a> {
self
}
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
let colors = theme::get();
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
let height = if self.hint.is_some() { 6 } else { 5 };
@@ -81,5 +81,7 @@ impl<'a> TextInputModal<'a> {
inner,
);
}
inner
}
}

View File

@@ -110,7 +110,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: surface0,
info_fg: text,
event_rgb: (225, 215, 240),
},
list: ListColors {
playing_bg: Color::Rgb(210, 235, 220),
@@ -123,6 +122,10 @@ pub fn theme() -> ThemeColors {
edit_fg: teal,
hover_bg: surface1,
hover_fg: text,
muted_bg: Color::Rgb(215, 215, 225),
muted_fg: overlay0,
soloed_bg: Color::Rgb(250, 235, 200),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
@@ -148,6 +151,7 @@ pub fn theme() -> ThemeColors {
variable: (pink, Color::Rgb(245, 220, 240)),
vary: (yellow, Color::Rgb(245, 235, 210)),
generator: (teal, Color::Rgb(210, 240, 235)),
user_defined: (maroon, Color::Rgb(245, 225, 230)),
default: (subtext0, mantle),
},
table: TableColors {

View File

@@ -110,7 +110,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: surface0,
info_fg: text,
event_rgb: (55, 45, 70),
},
list: ListColors {
playing_bg: Color::Rgb(35, 55, 45),
@@ -123,6 +122,10 @@ pub fn theme() -> ThemeColors {
edit_fg: teal,
hover_bg: surface1,
hover_fg: text,
muted_bg: Color::Rgb(40, 40, 50),
muted_fg: overlay0,
soloed_bg: Color::Rgb(60, 55, 35),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
@@ -148,6 +151,7 @@ pub fn theme() -> ThemeColors {
variable: (pink, Color::Rgb(55, 40, 55)),
vary: (yellow, Color::Rgb(55, 50, 35)),
generator: (teal, Color::Rgb(35, 55, 50)),
user_defined: (maroon, Color::Rgb(55, 35, 40)),
default: (subtext0, mantle),
},
table: TableColors {

View File

@@ -104,7 +104,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: current_line,
info_fg: foreground,
event_rgb: (70, 55, 85),
},
list: ListColors {
playing_bg: Color::Rgb(40, 65, 50),
@@ -117,6 +116,10 @@ pub fn theme() -> ThemeColors {
edit_fg: cyan,
hover_bg: lighter_bg,
hover_fg: foreground,
muted_bg: Color::Rgb(50, 52, 65),
muted_fg: comment,
soloed_bg: Color::Rgb(70, 70, 50),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
@@ -142,6 +145,7 @@ pub fn theme() -> ThemeColors {
variable: (pink, Color::Rgb(80, 50, 65)),
vary: (yellow, Color::Rgb(70, 70, 45)),
generator: (cyan, Color::Rgb(45, 70, 65)),
user_defined: (orange, Color::Rgb(65, 55, 40)),
default: (comment, darker_bg),
},
table: TableColors {

View File

@@ -0,0 +1,283 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let bg = Color::Rgb(8, 12, 8);
let surface = Color::Rgb(16, 24, 16);
let surface2 = Color::Rgb(24, 32, 24);
let border = Color::Rgb(32, 48, 32);
let fg = Color::Rgb(200, 216, 192);
let fg_dim = Color::Rgb(122, 144, 112);
let fg_muted = Color::Rgb(64, 88, 56);
let green = Color::Rgb(64, 192, 64);
let bright_green = Color::Rgb(96, 224, 96);
let dark_green = Color::Rgb(48, 128, 48);
let cyan = Color::Rgb(80, 168, 144);
let yellow = Color::Rgb(160, 160, 64);
let red = Color::Rgb(192, 80, 64);
let orange = Color::Rgb(176, 128, 48);
let blue = Color::Rgb(80, 128, 160);
let purple = Color::Rgb(128, 104, 144);
ThemeColors {
ui: UiColors {
bg,
bg_rgb: (8, 12, 8),
text_primary: fg,
text_muted: fg_dim,
text_dim: fg_muted,
border,
header: green,
unfocused: fg_muted,
accent: green,
surface,
},
status: StatusColors {
playing_bg: Color::Rgb(14, 30, 14),
playing_fg: bright_green,
stopped_bg: Color::Rgb(36, 16, 14),
stopped_fg: red,
fill_on: green,
fill_off: dark_green,
fill_bg: surface,
},
selection: SelectionColors {
cursor_bg: green,
cursor_fg: bg,
selected_bg: Color::Rgb(20, 40, 20),
selected_fg: bright_green,
in_range_bg: Color::Rgb(16, 30, 16),
in_range_fg: fg,
cursor: green,
selected: Color::Rgb(20, 40, 20),
in_range: Color::Rgb(16, 30, 16),
},
tile: TileColors {
playing_active_bg: Color::Rgb(16, 38, 16),
playing_active_fg: bright_green,
playing_inactive_bg: Color::Rgb(28, 36, 14),
playing_inactive_fg: yellow,
active_bg: Color::Rgb(14, 28, 26),
active_fg: cyan,
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(22, 36, 28),
active_in_range_bg: Color::Rgb(16, 28, 20),
link_bright: [
(64, 192, 64),
(80, 168, 144),
(160, 160, 64),
(80, 128, 160),
(192, 80, 64),
],
link_dim: [
(14, 38, 14),
(16, 34, 28),
(32, 32, 14),
(16, 26, 32),
(38, 16, 14),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(26, 22, 30),
tempo_fg: purple,
bank_bg: Color::Rgb(16, 26, 32),
bank_fg: blue,
pattern_bg: Color::Rgb(14, 28, 26),
pattern_fg: cyan,
stats_bg: surface,
stats_fg: fg_dim,
},
modal: ModalColors {
border: green,
border_accent: bright_green,
border_warn: yellow,
border_dim: fg_muted,
confirm: red,
rename: green,
input: cyan,
editor: green,
preview: fg_muted,
},
flash: FlashColors {
error_bg: Color::Rgb(40, 16, 14),
error_fg: red,
success_bg: Color::Rgb(14, 32, 14),
success_fg: bright_green,
info_bg: surface,
info_fg: fg,
},
list: ListColors {
playing_bg: Color::Rgb(14, 32, 14),
playing_fg: bright_green,
staged_play_bg: Color::Rgb(26, 22, 30),
staged_play_fg: purple,
staged_stop_bg: Color::Rgb(40, 16, 14),
staged_stop_fg: red,
edit_bg: Color::Rgb(14, 28, 26),
edit_fg: cyan,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(10, 14, 10),
muted_fg: fg_muted,
soloed_bg: Color::Rgb(30, 30, 14),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
connected: bright_green,
listening: yellow,
},
syntax: SyntaxColors {
gap_bg: bg,
executed_bg: Color::Rgb(14, 24, 14),
selected_bg: Color::Rgb(20, 40, 16),
emit: (fg, Color::Rgb(20, 34, 20)),
number: (yellow, Color::Rgb(30, 30, 12)),
string: (bright_green, Color::Rgb(16, 32, 16)),
comment: (fg_muted, bg),
keyword: (red, Color::Rgb(38, 18, 14)),
stack_op: (blue, Color::Rgb(16, 26, 32)),
operator: (green, Color::Rgb(14, 36, 14)),
sound: (cyan, Color::Rgb(16, 30, 28)),
param: (purple, Color::Rgb(26, 22, 28)),
context: (orange, Color::Rgb(34, 26, 12)),
note: (bright_green, Color::Rgb(16, 34, 16)),
interval: (Color::Rgb(100, 180, 100), Color::Rgb(18, 34, 18)),
variable: (purple, Color::Rgb(26, 22, 28)),
vary: (yellow, Color::Rgb(30, 30, 12)),
generator: (cyan, Color::Rgb(16, 30, 26)),
user_defined: (orange, Color::Rgb(30, 22, 10)),
default: (fg_dim, bg),
},
table: TableColors {
row_even: bg,
row_odd: surface,
},
values: ValuesColors {
tempo: green,
value: fg_dim,
},
hint: HintColors {
key: green,
text: fg_muted,
},
view_badge: ViewBadgeColors { bg: fg, fg: bg },
nav: NavColors {
selected_bg: Color::Rgb(18, 38, 18),
selected_fg: fg,
unselected_bg: surface,
unselected_fg: fg_muted,
},
editor_widget: EditorWidgetColors {
cursor_bg: fg,
cursor_fg: bg,
selection_bg: Color::Rgb(24, 48, 24),
completion_bg: surface,
completion_fg: fg,
completion_selected: green,
completion_example: cyan,
},
browser: BrowserColors {
directory: blue,
project_file: green,
selected: bright_green,
file: fg,
focused_border: green,
unfocused_border: fg_muted,
root: fg,
file_icon: fg_muted,
folder_icon: blue,
empty_text: fg_muted,
},
input: InputColors {
text: green,
cursor: fg,
hint: fg_muted,
},
search: SearchColors {
active: green,
inactive: fg_muted,
match_bg: yellow,
match_fg: bg,
},
markdown: MarkdownColors {
h1: green,
h2: cyan,
h3: purple,
code: bright_green,
code_border: Color::Rgb(28, 42, 28),
link: cyan,
link_url: Color::Rgb(56, 72, 52),
quote: fg_muted,
text: fg,
list: fg,
},
engine: EngineColors {
header: green,
header_focused: bright_green,
divider: Color::Rgb(24, 36, 24),
scroll_indicator: Color::Rgb(36, 52, 36),
label: Color::Rgb(100, 120, 92),
label_focused: Color::Rgb(140, 160, 130),
label_dim: Color::Rgb(68, 84, 60),
value: Color::Rgb(180, 196, 172),
focused: bright_green,
normal: fg,
dim: Color::Rgb(36, 52, 36),
path: Color::Rgb(100, 120, 92),
border_magenta: purple,
border_green: green,
border_cyan: cyan,
separator: Color::Rgb(24, 36, 24),
hint_active: green,
hint_inactive: Color::Rgb(24, 36, 24),
},
dict: DictColors {
word_name: bright_green,
word_bg: Color::Rgb(14, 24, 14),
alias: fg_muted,
stack_sig: purple,
description: fg,
example: Color::Rgb(100, 120, 92),
category_focused: bright_green,
category_selected: green,
category_normal: fg,
category_dimmed: Color::Rgb(36, 52, 36),
border_focused: bright_green,
border_normal: Color::Rgb(24, 36, 24),
header_desc: Color::Rgb(120, 140, 112),
},
title: TitleColors {
big_title: green,
author: cyan,
link: bright_green,
license: yellow,
prompt: Color::Rgb(100, 120, 92),
subtitle: fg,
},
meter: MeterColors {
low: green,
mid: yellow,
high: red,
low_rgb: (64, 192, 64),
mid_rgb: (160, 160, 64),
high_rgb: (192, 80, 64),
},
sparkle: SparkleColors {
colors: [
(64, 192, 64),
(96, 224, 96),
(80, 168, 144),
(160, 160, 64),
(48, 128, 48),
],
},
confirm: ConfirmColors {
border: red,
button_selected_bg: green,
button_selected_fg: bg,
},
}
}

View File

@@ -0,0 +1,281 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let bg = Color::Rgb(10, 8, 8);
let surface = Color::Rgb(20, 16, 16);
let surface2 = Color::Rgb(26, 20, 20);
let border = Color::Rgb(42, 32, 32);
let fg = Color::Rgb(232, 221, 208);
let fg_dim = Color::Rgb(160, 144, 128);
let fg_muted = Color::Rgb(96, 80, 64);
let red = Color::Rgb(224, 80, 64);
let orange = Color::Rgb(224, 128, 48);
let yellow = Color::Rgb(208, 160, 48);
let green = Color::Rgb(128, 160, 80);
let cyan = Color::Rgb(112, 160, 160);
let purple = Color::Rgb(160, 112, 144);
let blue = Color::Rgb(96, 128, 176);
ThemeColors {
ui: UiColors {
bg,
bg_rgb: (10, 8, 8),
text_primary: fg,
text_muted: fg_dim,
text_dim: fg_muted,
border,
header: orange,
unfocused: fg_muted,
accent: orange,
surface,
},
status: StatusColors {
playing_bg: Color::Rgb(20, 28, 16),
playing_fg: green,
stopped_bg: Color::Rgb(40, 16, 14),
stopped_fg: red,
fill_on: orange,
fill_off: fg_muted,
fill_bg: surface,
},
selection: SelectionColors {
cursor_bg: orange,
cursor_fg: bg,
selected_bg: Color::Rgb(50, 35, 20),
selected_fg: orange,
in_range_bg: Color::Rgb(35, 25, 18),
in_range_fg: fg,
cursor: orange,
selected: Color::Rgb(50, 35, 20),
in_range: Color::Rgb(35, 25, 18),
},
tile: TileColors {
playing_active_bg: Color::Rgb(45, 25, 15),
playing_active_fg: orange,
playing_inactive_bg: Color::Rgb(40, 32, 12),
playing_inactive_fg: yellow,
active_bg: Color::Rgb(20, 30, 30),
active_fg: cyan,
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(40, 30, 35),
active_in_range_bg: Color::Rgb(30, 25, 25),
link_bright: [
(224, 128, 48),
(224, 80, 64),
(208, 160, 48),
(112, 160, 160),
(128, 160, 80),
],
link_dim: [
(45, 28, 14),
(45, 18, 14),
(42, 32, 12),
(22, 32, 32),
(26, 32, 18),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(40, 28, 30),
tempo_fg: purple,
bank_bg: Color::Rgb(22, 30, 40),
bank_fg: blue,
pattern_bg: Color::Rgb(22, 34, 34),
pattern_fg: cyan,
stats_bg: surface,
stats_fg: fg_dim,
},
modal: ModalColors {
border: orange,
border_accent: red,
border_warn: yellow,
border_dim: fg_muted,
confirm: red,
rename: orange,
input: cyan,
editor: orange,
preview: fg_muted,
},
flash: FlashColors {
error_bg: Color::Rgb(50, 16, 14),
error_fg: red,
success_bg: Color::Rgb(20, 35, 18),
success_fg: green,
info_bg: surface,
info_fg: fg,
},
list: ListColors {
playing_bg: Color::Rgb(20, 35, 18),
playing_fg: green,
staged_play_bg: Color::Rgb(35, 25, 30),
staged_play_fg: purple,
staged_stop_bg: Color::Rgb(45, 18, 16),
staged_stop_fg: red,
edit_bg: Color::Rgb(20, 32, 32),
edit_fg: cyan,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(18, 14, 14),
muted_fg: fg_muted,
soloed_bg: Color::Rgb(40, 32, 12),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
connected: green,
listening: yellow,
},
syntax: SyntaxColors {
gap_bg: bg,
executed_bg: Color::Rgb(25, 18, 14),
selected_bg: Color::Rgb(45, 30, 15),
emit: (fg, Color::Rgb(45, 20, 18)),
number: (yellow, Color::Rgb(40, 30, 12)),
string: (green, Color::Rgb(25, 30, 18)),
comment: (fg_muted, bg),
keyword: (red, Color::Rgb(42, 18, 15)),
stack_op: (blue, Color::Rgb(22, 28, 38)),
operator: (orange, Color::Rgb(42, 26, 12)),
sound: (cyan, Color::Rgb(22, 32, 32)),
param: (purple, Color::Rgb(32, 24, 28)),
context: (orange, Color::Rgb(42, 26, 12)),
note: (green, Color::Rgb(25, 30, 18)),
interval: (Color::Rgb(150, 180, 100), Color::Rgb(28, 34, 20)),
variable: (purple, Color::Rgb(32, 24, 28)),
vary: (yellow, Color::Rgb(40, 30, 12)),
generator: (cyan, Color::Rgb(22, 32, 30)),
user_defined: (purple, Color::Rgb(28, 20, 26)),
default: (fg_dim, bg),
},
table: TableColors {
row_even: bg,
row_odd: surface,
},
values: ValuesColors {
tempo: orange,
value: fg_dim,
},
hint: HintColors {
key: orange,
text: fg_muted,
},
view_badge: ViewBadgeColors { bg: fg, fg: bg },
nav: NavColors {
selected_bg: Color::Rgb(45, 30, 20),
selected_fg: fg,
unselected_bg: surface,
unselected_fg: fg_muted,
},
editor_widget: EditorWidgetColors {
cursor_bg: fg,
cursor_fg: bg,
selection_bg: Color::Rgb(50, 35, 25),
completion_bg: surface,
completion_fg: fg,
completion_selected: orange,
completion_example: cyan,
},
browser: BrowserColors {
directory: blue,
project_file: orange,
selected: red,
file: fg,
focused_border: orange,
unfocused_border: fg_muted,
root: fg,
file_icon: fg_muted,
folder_icon: blue,
empty_text: fg_muted,
},
input: InputColors {
text: orange,
cursor: fg,
hint: fg_muted,
},
search: SearchColors {
active: orange,
inactive: fg_muted,
match_bg: yellow,
match_fg: bg,
},
markdown: MarkdownColors {
h1: orange,
h2: red,
h3: purple,
code: green,
code_border: Color::Rgb(50, 38, 38),
link: cyan,
link_url: Color::Rgb(80, 68, 56),
quote: fg_muted,
text: fg,
list: fg,
},
engine: EngineColors {
header: orange,
header_focused: yellow,
divider: Color::Rgb(38, 30, 30),
scroll_indicator: Color::Rgb(55, 42, 42),
label: Color::Rgb(130, 115, 100),
label_focused: Color::Rgb(170, 155, 140),
label_dim: Color::Rgb(90, 76, 64),
value: Color::Rgb(200, 188, 175),
focused: yellow,
normal: fg,
dim: Color::Rgb(55, 42, 42),
path: Color::Rgb(130, 115, 100),
border_magenta: purple,
border_green: green,
border_cyan: cyan,
separator: Color::Rgb(38, 30, 30),
hint_active: orange,
hint_inactive: Color::Rgb(38, 30, 30),
},
dict: DictColors {
word_name: green,
word_bg: Color::Rgb(22, 28, 22),
alias: fg_muted,
stack_sig: purple,
description: fg,
example: Color::Rgb(130, 115, 100),
category_focused: yellow,
category_selected: orange,
category_normal: fg,
category_dimmed: Color::Rgb(55, 42, 42),
border_focused: yellow,
border_normal: Color::Rgb(38, 30, 30),
header_desc: Color::Rgb(145, 130, 115),
},
title: TitleColors {
big_title: orange,
author: red,
link: cyan,
license: yellow,
prompt: Color::Rgb(130, 115, 100),
subtitle: fg,
},
meter: MeterColors {
low: green,
mid: yellow,
high: red,
low_rgb: (128, 160, 80),
mid_rgb: (208, 160, 48),
high_rgb: (224, 80, 64),
},
sparkle: SparkleColors {
colors: [
(224, 128, 48),
(224, 80, 64),
(208, 160, 48),
(112, 160, 160),
(128, 160, 80),
],
},
confirm: ConfirmColors {
border: red,
button_selected_bg: orange,
button_selected_fg: bg,
},
}
}

View File

@@ -0,0 +1,281 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let bg = Color::Rgb(90, 84, 117);
let bg_light = Color::Rgb(113, 103, 153);
let bg_lighter = Color::Rgb(130, 120, 165);
let fg = Color::Rgb(248, 248, 240);
let fg_dim = Color::Rgb(197, 163, 255);
let muted = Color::Rgb(168, 164, 177);
let dark = Color::Rgb(55, 51, 72);
let purple = Color::Rgb(174, 129, 255);
let pink = Color::Rgb(255, 184, 209);
let coral = Color::Rgb(255, 133, 127);
let yellow = Color::Rgb(255, 243, 82);
let gold = Color::Rgb(230, 192, 0);
let mint = Color::Rgb(194, 255, 223);
let lavender = Color::Rgb(197, 163, 255);
ThemeColors {
ui: UiColors {
bg,
bg_rgb: (90, 84, 117),
text_primary: fg,
text_muted: fg_dim,
text_dim: muted,
border: bg_lighter,
header: mint,
unfocused: muted,
accent: pink,
surface: bg_light,
},
status: StatusColors {
playing_bg: Color::Rgb(70, 95, 85),
playing_fg: mint,
stopped_bg: Color::Rgb(100, 70, 85),
stopped_fg: coral,
fill_on: mint,
fill_off: muted,
fill_bg: bg_light,
},
selection: SelectionColors {
cursor_bg: pink,
cursor_fg: dark,
selected_bg: Color::Rgb(120, 90, 130),
selected_fg: pink,
in_range_bg: Color::Rgb(100, 95, 125),
in_range_fg: fg,
cursor: pink,
selected: Color::Rgb(120, 90, 130),
in_range: Color::Rgb(100, 95, 125),
},
tile: TileColors {
playing_active_bg: Color::Rgb(100, 85, 60),
playing_active_fg: gold,
playing_inactive_bg: Color::Rgb(95, 90, 70),
playing_inactive_fg: yellow,
active_bg: Color::Rgb(70, 100, 100),
active_fg: mint,
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(120, 90, 130),
active_in_range_bg: Color::Rgb(100, 95, 125),
link_bright: [
(255, 184, 209),
(174, 129, 255),
(255, 133, 127),
(194, 255, 223),
(255, 243, 82),
],
link_dim: [
(100, 75, 90),
(85, 70, 105),
(100, 65, 65),
(75, 100, 95),
(100, 95, 55),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(100, 75, 95),
tempo_fg: pink,
bank_bg: Color::Rgb(70, 95, 95),
bank_fg: mint,
pattern_bg: Color::Rgb(85, 75, 110),
pattern_fg: purple,
stats_bg: bg_light,
stats_fg: fg_dim,
},
modal: ModalColors {
border: mint,
border_accent: pink,
border_warn: coral,
border_dim: muted,
confirm: coral,
rename: purple,
input: mint,
editor: mint,
preview: muted,
},
flash: FlashColors {
error_bg: Color::Rgb(100, 65, 70),
error_fg: coral,
success_bg: Color::Rgb(65, 95, 85),
success_fg: mint,
info_bg: bg_light,
info_fg: fg,
},
list: ListColors {
playing_bg: Color::Rgb(65, 95, 85),
playing_fg: mint,
staged_play_bg: Color::Rgb(95, 80, 120),
staged_play_fg: purple,
staged_stop_bg: Color::Rgb(105, 70, 85),
staged_stop_fg: pink,
edit_bg: Color::Rgb(70, 95, 100),
edit_fg: mint,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(75, 70, 95),
muted_fg: muted,
soloed_bg: Color::Rgb(100, 95, 65),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: coral,
connected: mint,
listening: yellow,
},
syntax: SyntaxColors {
gap_bg: dark,
executed_bg: Color::Rgb(80, 75, 100),
selected_bg: Color::Rgb(110, 100, 70),
emit: (fg, Color::Rgb(110, 80, 100)),
number: (purple, Color::Rgb(85, 75, 110)),
string: (yellow, Color::Rgb(100, 95, 60)),
comment: (muted, dark),
keyword: (pink, Color::Rgb(105, 75, 90)),
stack_op: (mint, Color::Rgb(70, 100, 95)),
operator: (pink, Color::Rgb(105, 75, 90)),
sound: (mint, Color::Rgb(70, 100, 95)),
param: (coral, Color::Rgb(105, 70, 70)),
context: (coral, Color::Rgb(105, 70, 70)),
note: (lavender, Color::Rgb(85, 75, 110)),
interval: (Color::Rgb(220, 190, 255), Color::Rgb(85, 75, 100)),
variable: (lavender, Color::Rgb(85, 75, 110)),
vary: (yellow, Color::Rgb(100, 95, 60)),
generator: (mint, Color::Rgb(70, 95, 95)),
user_defined: (coral, Color::Rgb(100, 75, 75)),
default: (fg_dim, dark),
},
table: TableColors {
row_even: dark,
row_odd: bg,
},
values: ValuesColors {
tempo: coral,
value: fg_dim,
},
hint: HintColors {
key: coral,
text: muted,
},
view_badge: ViewBadgeColors { bg: fg, fg: bg },
nav: NavColors {
selected_bg: Color::Rgb(110, 85, 120),
selected_fg: fg,
unselected_bg: bg_light,
unselected_fg: muted,
},
editor_widget: EditorWidgetColors {
cursor_bg: fg,
cursor_fg: bg,
selection_bg: Color::Rgb(105, 95, 125),
completion_bg: bg_light,
completion_fg: fg,
completion_selected: coral,
completion_example: mint,
},
browser: BrowserColors {
directory: mint,
project_file: purple,
selected: coral,
file: fg,
focused_border: coral,
unfocused_border: muted,
root: fg,
file_icon: muted,
folder_icon: mint,
empty_text: muted,
},
input: InputColors {
text: mint,
cursor: fg,
hint: muted,
},
search: SearchColors {
active: coral,
inactive: muted,
match_bg: yellow,
match_fg: dark,
},
markdown: MarkdownColors {
h1: mint,
h2: coral,
h3: purple,
code: lavender,
code_border: Color::Rgb(120, 115, 140),
link: pink,
link_url: Color::Rgb(150, 145, 165),
quote: muted,
text: fg,
list: fg,
},
engine: EngineColors {
header: mint,
header_focused: yellow,
divider: Color::Rgb(110, 105, 130),
scroll_indicator: Color::Rgb(125, 120, 145),
label: Color::Rgb(175, 170, 190),
label_focused: Color::Rgb(210, 205, 225),
label_dim: Color::Rgb(145, 140, 160),
value: Color::Rgb(230, 225, 240),
focused: yellow,
normal: fg,
dim: Color::Rgb(125, 120, 145),
path: Color::Rgb(175, 170, 190),
border_magenta: pink,
border_green: mint,
border_cyan: lavender,
separator: Color::Rgb(110, 105, 130),
hint_active: Color::Rgb(240, 230, 120),
hint_inactive: Color::Rgb(110, 105, 130),
},
dict: DictColors {
word_name: lavender,
word_bg: Color::Rgb(75, 85, 105),
alias: muted,
stack_sig: purple,
description: fg,
example: Color::Rgb(175, 170, 190),
category_focused: yellow,
category_selected: mint,
category_normal: fg,
category_dimmed: Color::Rgb(125, 120, 145),
border_focused: yellow,
border_normal: Color::Rgb(110, 105, 130),
header_desc: Color::Rgb(195, 190, 210),
},
title: TitleColors {
big_title: pink,
author: mint,
link: lavender,
license: coral,
prompt: Color::Rgb(195, 190, 210),
subtitle: fg,
},
meter: MeterColors {
low: mint,
mid: yellow,
high: coral,
low_rgb: (194, 255, 223),
mid_rgb: (255, 243, 82),
high_rgb: (255, 133, 127),
},
sparkle: SparkleColors {
colors: [
(194, 255, 223),
(255, 133, 127),
(255, 243, 82),
(255, 184, 209),
(174, 129, 255),
],
},
confirm: ConfirmColors {
border: coral,
button_selected_bg: coral,
button_selected_fg: dark,
},
}
}

View File

@@ -0,0 +1,285 @@
use super::*;
use ratatui::style::Color;
// C64 palette on pure black
pub fn theme() -> ThemeColors {
let bg = Color::Rgb(0, 0, 0);
let surface = Color::Rgb(16, 16, 16);
let surface2 = Color::Rgb(24, 24, 24);
let border = Color::Rgb(51, 51, 51);
let fg = Color::Rgb(187, 187, 187);
let fg_dim = Color::Rgb(119, 119, 119);
let fg_muted = Color::Rgb(51, 51, 51);
let white = Color::Rgb(255, 255, 255);
let lightred = Color::Rgb(255, 119, 119);
let cyan = Color::Rgb(170, 255, 238);
let violet = Color::Rgb(204, 68, 204);
let green = Color::Rgb(0, 204, 85);
let lightgreen = Color::Rgb(170, 255, 102);
let lightblue = Color::Rgb(0, 136, 255);
let yellow = Color::Rgb(238, 238, 119);
let orange = Color::Rgb(221, 136, 85);
let brown = Color::Rgb(102, 68, 0);
ThemeColors {
ui: UiColors {
bg,
bg_rgb: (0, 0, 0),
text_primary: fg,
text_muted: fg_dim,
text_dim: fg_muted,
border,
header: lightblue,
unfocused: fg_muted,
accent: lightblue,
surface,
},
status: StatusColors {
playing_bg: Color::Rgb(0, 24, 10),
playing_fg: green,
stopped_bg: Color::Rgb(28, 0, 0),
stopped_fg: lightred,
fill_on: lightblue,
fill_off: brown,
fill_bg: surface,
},
selection: SelectionColors {
cursor_bg: lightblue,
cursor_fg: bg,
selected_bg: Color::Rgb(0, 20, 40),
selected_fg: cyan,
in_range_bg: Color::Rgb(0, 14, 28),
in_range_fg: fg,
cursor: lightblue,
selected: Color::Rgb(0, 20, 40),
in_range: Color::Rgb(0, 14, 28),
},
tile: TileColors {
playing_active_bg: Color::Rgb(0, 30, 12),
playing_active_fg: lightgreen,
playing_inactive_bg: Color::Rgb(30, 30, 14),
playing_inactive_fg: yellow,
active_bg: Color::Rgb(0, 16, 32),
active_fg: lightblue,
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(10, 20, 36),
active_in_range_bg: Color::Rgb(6, 14, 28),
link_bright: [
(0, 136, 255),
(0, 204, 85),
(238, 238, 119),
(204, 68, 204),
(170, 255, 238),
],
link_dim: [
(0, 20, 40),
(0, 30, 12),
(34, 34, 16),
(30, 10, 30),
(24, 36, 34),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(28, 10, 28),
tempo_fg: violet,
bank_bg: Color::Rgb(0, 0, 24),
bank_fg: lightblue,
pattern_bg: Color::Rgb(0, 24, 10),
pattern_fg: green,
stats_bg: surface,
stats_fg: fg_dim,
},
modal: ModalColors {
border: lightblue,
border_accent: cyan,
border_warn: yellow,
border_dim: fg_muted,
confirm: lightred,
rename: lightblue,
input: cyan,
editor: lightblue,
preview: fg_muted,
},
flash: FlashColors {
error_bg: Color::Rgb(28, 0, 0),
error_fg: lightred,
success_bg: Color::Rgb(0, 24, 10),
success_fg: green,
info_bg: surface,
info_fg: fg,
},
list: ListColors {
playing_bg: Color::Rgb(0, 24, 10),
playing_fg: green,
staged_play_bg: Color::Rgb(28, 10, 28),
staged_play_fg: violet,
staged_stop_bg: Color::Rgb(28, 0, 0),
staged_stop_fg: lightred,
edit_bg: Color::Rgb(0, 16, 32),
edit_fg: lightblue,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(8, 8, 8),
muted_fg: fg_muted,
soloed_bg: Color::Rgb(30, 30, 14),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: lightred,
connected: green,
listening: yellow,
},
syntax: SyntaxColors {
gap_bg: bg,
executed_bg: Color::Rgb(0, 12, 24),
selected_bg: Color::Rgb(0, 20, 40),
emit: (white, Color::Rgb(20, 20, 20)),
number: (yellow, Color::Rgb(30, 30, 14)),
string: (lightgreen, Color::Rgb(18, 30, 12)),
comment: (fg_muted, bg),
keyword: (lightred, Color::Rgb(30, 14, 14)),
stack_op: (lightblue, Color::Rgb(0, 16, 32)),
operator: (cyan, Color::Rgb(20, 30, 28)),
sound: (green, Color::Rgb(0, 24, 10)),
param: (violet, Color::Rgb(24, 8, 24)),
context: (orange, Color::Rgb(28, 16, 10)),
note: (lightgreen, Color::Rgb(18, 30, 12)),
interval: (Color::Rgb(130, 210, 80), Color::Rgb(14, 26, 8)),
variable: (violet, Color::Rgb(24, 8, 24)),
vary: (yellow, Color::Rgb(30, 30, 14)),
generator: (cyan, Color::Rgb(20, 30, 28)),
user_defined: (orange, Color::Rgb(30, 20, 10)),
default: (fg_dim, bg),
},
table: TableColors {
row_even: bg,
row_odd: surface,
},
values: ValuesColors {
tempo: lightblue,
value: fg_dim,
},
hint: HintColors {
key: lightblue,
text: fg_muted,
},
view_badge: ViewBadgeColors { bg: fg, fg: bg },
nav: NavColors {
selected_bg: Color::Rgb(0, 20, 40),
selected_fg: fg,
unselected_bg: surface,
unselected_fg: fg_muted,
},
editor_widget: EditorWidgetColors {
cursor_bg: fg,
cursor_fg: bg,
selection_bg: Color::Rgb(0, 24, 48),
completion_bg: surface,
completion_fg: fg,
completion_selected: lightblue,
completion_example: cyan,
},
browser: BrowserColors {
directory: lightblue,
project_file: green,
selected: cyan,
file: fg,
focused_border: lightblue,
unfocused_border: fg_muted,
root: fg,
file_icon: fg_muted,
folder_icon: lightblue,
empty_text: fg_muted,
},
input: InputColors {
text: lightblue,
cursor: fg,
hint: fg_muted,
},
search: SearchColors {
active: lightblue,
inactive: fg_muted,
match_bg: yellow,
match_fg: bg,
},
markdown: MarkdownColors {
h1: lightblue,
h2: green,
h3: violet,
code: lightgreen,
code_border: Color::Rgb(36, 36, 36),
link: cyan,
link_url: brown,
quote: fg_muted,
text: fg,
list: fg,
},
engine: EngineColors {
header: lightblue,
header_focused: cyan,
divider: Color::Rgb(28, 28, 28),
scroll_indicator: Color::Rgb(51, 51, 51),
label: fg_dim,
label_focused: fg,
label_dim: fg_muted,
value: fg,
focused: cyan,
normal: fg,
dim: Color::Rgb(36, 36, 36),
path: fg_dim,
border_magenta: violet,
border_green: green,
border_cyan: cyan,
separator: Color::Rgb(28, 28, 28),
hint_active: lightblue,
hint_inactive: Color::Rgb(28, 28, 28),
},
dict: DictColors {
word_name: lightgreen,
word_bg: Color::Rgb(10, 18, 6),
alias: fg_muted,
stack_sig: violet,
description: fg,
example: fg_dim,
category_focused: cyan,
category_selected: lightblue,
category_normal: fg,
category_dimmed: Color::Rgb(36, 36, 36),
border_focused: cyan,
border_normal: Color::Rgb(28, 28, 28),
header_desc: fg_dim,
},
title: TitleColors {
big_title: lightblue,
author: green,
link: cyan,
license: yellow,
prompt: fg_dim,
subtitle: fg,
},
meter: MeterColors {
low: green,
mid: yellow,
high: lightred,
low_rgb: (0, 204, 85),
mid_rgb: (238, 238, 119),
high_rgb: (255, 119, 119),
},
sparkle: SparkleColors {
colors: [
(0, 136, 255),
(170, 255, 238),
(0, 204, 85),
(238, 238, 119),
(204, 68, 204),
],
},
confirm: ConfirmColors {
border: lightred,
button_selected_bg: lightblue,
button_selected_fg: bg,
},
}
}

View File

@@ -106,7 +106,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: bg1,
info_fg: fg,
event_rgb: (70, 55, 45),
},
list: ListColors {
playing_bg: Color::Rgb(50, 65, 45),
@@ -119,6 +118,10 @@ pub fn theme() -> ThemeColors {
edit_fg: aqua,
hover_bg: bg2,
hover_fg: fg,
muted_bg: Color::Rgb(50, 50, 55),
muted_fg: fg4,
soloed_bg: Color::Rgb(70, 65, 40),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
@@ -144,6 +147,7 @@ pub fn theme() -> ThemeColors {
variable: (purple, Color::Rgb(65, 50, 55)),
vary: (yellow, Color::Rgb(70, 65, 40)),
generator: (aqua, Color::Rgb(45, 60, 50)),
user_defined: (orange, Color::Rgb(60, 45, 30)),
default: (fg3, darker_bg),
},
table: TableColors {

View File

@@ -0,0 +1,277 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let red = Color::Rgb(255, 0, 0);
let dark_red = Color::Rgb(215, 0, 0);
let darker_red = Color::Rgb(175, 0, 0);
let yellow = Color::Rgb(255, 255, 0);
let light_yellow = Color::Rgb(255, 255, 95);
let gold = Color::Rgb(255, 215, 0);
let black = Color::Rgb(0, 0, 0);
let white = Color::Rgb(255, 255, 255);
let dim_yellow = Color::Rgb(180, 180, 0);
let muted_red = Color::Rgb(140, 40, 40);
ThemeColors {
ui: UiColors {
bg: red,
bg_rgb: (255, 0, 0),
text_primary: yellow,
text_muted: light_yellow,
text_dim: gold,
border: yellow,
header: yellow,
unfocused: gold,
accent: yellow,
surface: dark_red,
},
status: StatusColors {
playing_bg: Color::Rgb(180, 180, 0),
playing_fg: black,
stopped_bg: darker_red,
stopped_fg: yellow,
fill_on: yellow,
fill_off: gold,
fill_bg: dark_red,
},
selection: SelectionColors {
cursor_bg: yellow,
cursor_fg: red,
selected_bg: Color::Rgb(200, 200, 0),
selected_fg: black,
in_range_bg: Color::Rgb(170, 100, 0),
in_range_fg: yellow,
cursor: yellow,
selected: Color::Rgb(200, 200, 0),
in_range: Color::Rgb(170, 100, 0),
},
tile: TileColors {
playing_active_bg: Color::Rgb(200, 200, 0),
playing_active_fg: black,
playing_inactive_bg: Color::Rgb(180, 180, 0),
playing_inactive_fg: black,
active_bg: Color::Rgb(200, 50, 50),
active_fg: yellow,
inactive_bg: dark_red,
inactive_fg: gold,
active_selected_bg: Color::Rgb(200, 200, 0),
active_in_range_bg: Color::Rgb(170, 100, 0),
link_bright: [
(255, 255, 0),
(255, 255, 255),
(255, 215, 0),
(255, 255, 95),
(255, 255, 0),
],
link_dim: [
(140, 140, 0),
(140, 140, 140),
(140, 120, 0),
(140, 140, 60),
(140, 140, 0),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(180, 180, 0),
tempo_fg: black,
bank_bg: darker_red,
bank_fg: yellow,
pattern_bg: Color::Rgb(200, 200, 0),
pattern_fg: black,
stats_bg: dark_red,
stats_fg: yellow,
},
modal: ModalColors {
border: yellow,
border_accent: white,
border_warn: gold,
border_dim: dim_yellow,
confirm: gold,
rename: light_yellow,
input: yellow,
editor: yellow,
preview: gold,
},
flash: FlashColors {
error_bg: black,
error_fg: yellow,
success_bg: Color::Rgb(180, 180, 0),
success_fg: black,
info_bg: dark_red,
info_fg: yellow,
},
list: ListColors {
playing_bg: Color::Rgb(180, 180, 0),
playing_fg: black,
staged_play_bg: Color::Rgb(200, 200, 0),
staged_play_fg: black,
staged_stop_bg: darker_red,
staged_stop_fg: yellow,
edit_bg: Color::Rgb(200, 50, 50),
edit_fg: yellow,
hover_bg: Color::Rgb(230, 50, 50),
hover_fg: yellow,
muted_bg: darker_red,
muted_fg: dim_yellow,
soloed_bg: Color::Rgb(200, 200, 0),
soloed_fg: black,
},
link_status: LinkStatusColors {
disabled: white,
connected: yellow,
listening: gold,
},
syntax: SyntaxColors {
gap_bg: darker_red,
executed_bg: Color::Rgb(200, 50, 50),
selected_bg: Color::Rgb(180, 180, 0),
emit: (yellow, muted_red),
number: (white, muted_red),
string: (gold, muted_red),
comment: (dim_yellow, darker_red),
keyword: (light_yellow, muted_red),
stack_op: (yellow, muted_red),
operator: (light_yellow, muted_red),
sound: (yellow, muted_red),
param: (gold, muted_red),
context: (gold, muted_red),
note: (white, muted_red),
interval: (Color::Rgb(255, 240, 150), muted_red),
variable: (white, muted_red),
vary: (gold, muted_red),
generator: (yellow, muted_red),
user_defined: (gold, Color::Rgb(100, 70, 0)),
default: (light_yellow, darker_red),
},
table: TableColors {
row_even: darker_red,
row_odd: red,
},
values: ValuesColors {
tempo: gold,
value: light_yellow,
},
hint: HintColors {
key: white,
text: gold,
},
view_badge: ViewBadgeColors { bg: yellow, fg: red },
nav: NavColors {
selected_bg: Color::Rgb(200, 200, 0),
selected_fg: black,
unselected_bg: dark_red,
unselected_fg: gold,
},
editor_widget: EditorWidgetColors {
cursor_bg: yellow,
cursor_fg: red,
selection_bg: Color::Rgb(180, 180, 0),
completion_bg: dark_red,
completion_fg: yellow,
completion_selected: white,
completion_example: gold,
},
browser: BrowserColors {
directory: yellow,
project_file: white,
selected: gold,
file: light_yellow,
focused_border: white,
unfocused_border: gold,
root: yellow,
file_icon: gold,
folder_icon: yellow,
empty_text: gold,
},
input: InputColors {
text: yellow,
cursor: white,
hint: gold,
},
search: SearchColors {
active: white,
inactive: gold,
match_bg: yellow,
match_fg: red,
},
markdown: MarkdownColors {
h1: yellow,
h2: white,
h3: gold,
code: light_yellow,
code_border: dim_yellow,
link: white,
link_url: gold,
quote: dim_yellow,
text: yellow,
list: yellow,
},
engine: EngineColors {
header: yellow,
header_focused: white,
divider: dim_yellow,
scroll_indicator: gold,
label: light_yellow,
label_focused: white,
label_dim: dim_yellow,
value: yellow,
focused: white,
normal: yellow,
dim: dim_yellow,
path: gold,
border_magenta: gold,
border_green: yellow,
border_cyan: white,
separator: dim_yellow,
hint_active: white,
hint_inactive: dim_yellow,
},
dict: DictColors {
word_name: yellow,
word_bg: darker_red,
alias: gold,
stack_sig: white,
description: yellow,
example: gold,
category_focused: white,
category_selected: yellow,
category_normal: light_yellow,
category_dimmed: dim_yellow,
border_focused: white,
border_normal: dim_yellow,
header_desc: gold,
},
title: TitleColors {
big_title: yellow,
author: white,
link: gold,
license: light_yellow,
prompt: gold,
subtitle: yellow,
},
meter: MeterColors {
low: yellow,
mid: gold,
high: white,
low_rgb: (255, 255, 0),
mid_rgb: (255, 215, 0),
high_rgb: (255, 255, 255),
},
sparkle: SparkleColors {
colors: [
(255, 255, 0),
(255, 255, 255),
(255, 215, 0),
(255, 255, 95),
(255, 255, 0),
],
},
confirm: ConfirmColors {
border: white,
button_selected_bg: yellow,
button_selected_fg: red,
},
}
}

View File

@@ -14,8 +14,8 @@ pub fn theme() -> ThemeColors {
let autumn_red = Color::Rgb(195, 64, 67);
let carp_yellow = Color::Rgb(230, 195, 132);
let spring_blue = Color::Rgb(127, 180, 202);
let wave_red = Color::Rgb(226, 109, 115);
let sakura_pink = Color::Rgb(212, 140, 149);
let wave_red = Color::Rgb(228, 104, 118);
let sakura_pink = Color::Rgb(210, 126, 153);
let darker_bg = Color::Rgb(26, 26, 34);
@@ -64,7 +64,7 @@ pub fn theme() -> ThemeColors {
active_selected_bg: Color::Rgb(65, 55, 70),
active_in_range_bg: Color::Rgb(50, 50, 60),
link_bright: [
(226, 109, 115),
(228, 104, 118),
(149, 127, 184),
(230, 195, 132),
(127, 180, 202),
@@ -106,7 +106,6 @@ pub fn theme() -> ThemeColors {
success_fg: autumn_green,
info_bg: bg_light,
info_fg: fg,
event_rgb: (50, 50, 60),
},
list: ListColors {
playing_bg: Color::Rgb(40, 55, 45),
@@ -119,6 +118,10 @@ pub fn theme() -> ThemeColors {
edit_fg: crystal_blue,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(38, 38, 48),
muted_fg: comment,
soloed_bg: Color::Rgb(60, 55, 45),
soloed_fg: carp_yellow,
},
link_status: LinkStatusColors {
disabled: autumn_red,
@@ -144,6 +147,7 @@ pub fn theme() -> ThemeColors {
variable: (autumn_green, Color::Rgb(45, 55, 45)),
vary: (carp_yellow, Color::Rgb(65, 60, 50)),
generator: (spring_blue, Color::Rgb(45, 55, 65)),
user_defined: (sakura_pink, Color::Rgb(55, 40, 50)),
default: (fg_dim, darker_bg),
},
table: TableColors {
@@ -258,14 +262,14 @@ pub fn theme() -> ThemeColors {
high: wave_red,
low_rgb: (118, 148, 106),
mid_rgb: (230, 195, 132),
high_rgb: (226, 109, 115),
high_rgb: (228, 104, 118),
},
sparkle: SparkleColors {
colors: [
(127, 180, 202),
(230, 195, 132),
(118, 148, 106),
(226, 109, 115),
(228, 104, 118),
(149, 127, 184),
],
},

View File

@@ -0,0 +1,282 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let bg = Color::Rgb(255, 255, 255);
let off_white = Color::Rgb(245, 245, 247);
let surface = Color::Rgb(235, 235, 240);
let border = Color::Rgb(210, 210, 215);
let text = Color::Rgb(29, 29, 31);
let text_dim = Color::Rgb(110, 110, 115);
let text_muted = Color::Rgb(160, 160, 165);
let keyword = Color::Rgb(173, 61, 164);
let string = Color::Rgb(209, 47, 27);
let comment = Color::Rgb(112, 127, 52);
let number = Color::Rgb(39, 42, 216);
let types = Color::Rgb(112, 61, 170);
let function = Color::Rgb(62, 128, 135);
let preproc = Color::Rgb(120, 73, 42);
let accent = Color::Rgb(0, 112, 243);
ThemeColors {
ui: UiColors {
bg,
bg_rgb: (255, 255, 255),
text_primary: text,
text_muted: text_dim,
text_dim: text_muted,
border,
header: accent,
unfocused: text_muted,
accent,
surface,
},
status: StatusColors {
playing_bg: Color::Rgb(220, 240, 220),
playing_fg: comment,
stopped_bg: Color::Rgb(245, 220, 220),
stopped_fg: string,
fill_on: comment,
fill_off: text_muted,
fill_bg: surface,
},
selection: SelectionColors {
cursor_bg: accent,
cursor_fg: bg,
selected_bg: Color::Rgb(200, 220, 250),
selected_fg: accent,
in_range_bg: Color::Rgb(220, 233, 250),
in_range_fg: text,
cursor: accent,
selected: Color::Rgb(200, 220, 250),
in_range: Color::Rgb(220, 233, 250),
},
tile: TileColors {
playing_active_bg: Color::Rgb(250, 225, 210),
playing_active_fg: preproc,
playing_inactive_bg: Color::Rgb(250, 240, 200),
playing_inactive_fg: Color::Rgb(180, 140, 20),
active_bg: Color::Rgb(210, 235, 240),
active_fg: function,
inactive_bg: surface,
inactive_fg: text_dim,
active_selected_bg: Color::Rgb(210, 215, 245),
active_in_range_bg: Color::Rgb(220, 230, 245),
link_bright: [
(173, 61, 164),
(0, 112, 243),
(120, 73, 42),
(62, 128, 135),
(112, 127, 52),
],
link_dim: [
(235, 215, 235),
(210, 225, 250),
(240, 225, 210),
(215, 235, 240),
(225, 235, 215),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(225, 215, 240),
tempo_fg: types,
bank_bg: Color::Rgb(210, 230, 250),
bank_fg: accent,
pattern_bg: Color::Rgb(210, 235, 235),
pattern_fg: function,
stats_bg: surface,
stats_fg: text_dim,
},
modal: ModalColors {
border: accent,
border_accent: keyword,
border_warn: preproc,
border_dim: text_muted,
confirm: preproc,
rename: keyword,
input: accent,
editor: accent,
preview: text_muted,
},
flash: FlashColors {
error_bg: Color::Rgb(250, 215, 215),
error_fg: string,
success_bg: Color::Rgb(215, 240, 215),
success_fg: comment,
info_bg: surface,
info_fg: text,
},
list: ListColors {
playing_bg: Color::Rgb(215, 240, 220),
playing_fg: comment,
staged_play_bg: Color::Rgb(230, 215, 240),
staged_play_fg: keyword,
staged_stop_bg: Color::Rgb(245, 215, 220),
staged_stop_fg: string,
edit_bg: Color::Rgb(210, 235, 240),
edit_fg: function,
hover_bg: Color::Rgb(240, 240, 242),
hover_fg: text,
muted_bg: Color::Rgb(230, 230, 235),
muted_fg: text_muted,
soloed_bg: Color::Rgb(250, 240, 200),
soloed_fg: Color::Rgb(170, 130, 10),
},
link_status: LinkStatusColors {
disabled: string,
connected: comment,
listening: Color::Rgb(180, 140, 20),
},
syntax: SyntaxColors {
gap_bg: off_white,
executed_bg: Color::Rgb(230, 225, 245),
selected_bg: Color::Rgb(250, 240, 210),
emit: (text, Color::Rgb(250, 220, 215)),
number: (number, Color::Rgb(220, 220, 250)),
string: (string, Color::Rgb(250, 225, 220)),
comment: (Color::Rgb(130, 145, 75), off_white),
keyword: (keyword, Color::Rgb(240, 225, 240)),
stack_op: (accent, Color::Rgb(220, 235, 250)),
operator: (preproc, Color::Rgb(240, 230, 215)),
sound: (function, Color::Rgb(215, 240, 240)),
param: (types, Color::Rgb(230, 220, 240)),
context: (preproc, Color::Rgb(240, 230, 215)),
note: (comment, Color::Rgb(225, 240, 220)),
interval: (Color::Rgb(90, 110, 40), Color::Rgb(225, 240, 215)),
variable: (keyword, Color::Rgb(240, 225, 240)),
vary: (Color::Rgb(180, 140, 20), Color::Rgb(250, 240, 210)),
generator: (function, Color::Rgb(215, 240, 235)),
user_defined: (preproc, Color::Rgb(240, 230, 215)),
default: (text_dim, off_white),
},
table: TableColors {
row_even: off_white,
row_odd: bg,
},
values: ValuesColors {
tempo: preproc,
value: text_dim,
},
hint: HintColors {
key: accent,
text: text_muted,
},
view_badge: ViewBadgeColors { bg: text, fg: bg },
nav: NavColors {
selected_bg: Color::Rgb(210, 225, 250),
selected_fg: text,
unselected_bg: surface,
unselected_fg: text_muted,
},
editor_widget: EditorWidgetColors {
cursor_bg: text,
cursor_fg: bg,
selection_bg: Color::Rgb(200, 220, 250),
completion_bg: surface,
completion_fg: text,
completion_selected: accent,
completion_example: function,
},
browser: BrowserColors {
directory: accent,
project_file: keyword,
selected: preproc,
file: text,
focused_border: accent,
unfocused_border: text_muted,
root: text,
file_icon: text_muted,
folder_icon: accent,
empty_text: text_muted,
},
input: InputColors {
text: accent,
cursor: text,
hint: text_muted,
},
search: SearchColors {
active: accent,
inactive: text_muted,
match_bg: Color::Rgb(255, 230, 80),
match_fg: text,
},
markdown: MarkdownColors {
h1: accent,
h2: preproc,
h3: keyword,
code: comment,
code_border: Color::Rgb(200, 200, 205),
link: function,
link_url: Color::Rgb(150, 150, 155),
quote: text_muted,
text,
list: text,
},
engine: EngineColors {
header: accent,
header_focused: preproc,
divider: Color::Rgb(200, 200, 205),
scroll_indicator: Color::Rgb(180, 180, 185),
label: Color::Rgb(120, 120, 125),
label_focused: Color::Rgb(80, 80, 85),
label_dim: Color::Rgb(150, 150, 155),
value: Color::Rgb(60, 60, 65),
focused: preproc,
normal: text,
dim: Color::Rgb(180, 180, 185),
path: Color::Rgb(120, 120, 125),
border_magenta: keyword,
border_green: comment,
border_cyan: function,
separator: Color::Rgb(200, 200, 210),
hint_active: preproc,
hint_inactive: Color::Rgb(200, 200, 210),
},
dict: DictColors {
word_name: function,
word_bg: Color::Rgb(215, 235, 240),
alias: text_muted,
stack_sig: keyword,
description: text,
example: Color::Rgb(110, 110, 120),
category_focused: preproc,
category_selected: accent,
category_normal: text,
category_dimmed: Color::Rgb(180, 180, 185),
border_focused: preproc,
border_normal: Color::Rgb(200, 200, 205),
header_desc: Color::Rgb(100, 100, 110),
},
title: TitleColors {
big_title: accent,
author: types,
link: function,
license: preproc,
prompt: Color::Rgb(100, 100, 110),
subtitle: text,
},
meter: MeterColors {
low: comment,
mid: Color::Rgb(200, 150, 20),
high: string,
low_rgb: (112, 127, 52),
mid_rgb: (200, 150, 20),
high_rgb: (209, 47, 27),
},
sparkle: SparkleColors {
colors: [
(0, 112, 243),
(173, 61, 164),
(112, 127, 52),
(62, 128, 135),
(120, 73, 42),
],
},
confirm: ConfirmColors {
border: accent,
button_selected_bg: accent,
button_selected_fg: bg,
},
}
}

View File

@@ -4,8 +4,14 @@
mod catppuccin_latte;
mod catppuccin_mocha;
mod dracula;
mod eden;
mod ember;
mod georges;
mod fairyfloss;
mod gruvbox_dark;
mod hot_dog_stand;
mod kanagawa;
mod letz_light;
mod monochrome_black;
mod monochrome_white;
mod monokai;
@@ -13,6 +19,7 @@ mod nord;
mod pitch_black;
mod rose_pine;
mod tokyo_night;
pub mod transform;
use ratatui::style::Color;
use std::cell::RefCell;
@@ -36,6 +43,12 @@ pub const THEMES: &[ThemeEntry] = &[
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
ThemeEntry { id: "Fairyfloss", label: "Fairyfloss", colors: fairyfloss::theme },
ThemeEntry { id: "HotDogStand", label: "Hot Dog Stand", colors: hot_dog_stand::theme },
ThemeEntry { id: "LetzLight", label: "Letz Light", colors: letz_light::theme },
ThemeEntry { id: "Ember", label: "Ember", colors: ember::theme },
ThemeEntry { id: "Eden", label: "Eden", colors: eden::theme },
ThemeEntry { id: "Georges", label: "Georges", colors: georges::theme },
];
thread_local! {
@@ -167,7 +180,6 @@ pub struct FlashColors {
pub success_fg: Color,
pub info_bg: Color,
pub info_fg: Color,
pub event_rgb: (u8, u8, u8),
}
#[derive(Clone)]
@@ -182,6 +194,10 @@ pub struct ListColors {
pub edit_fg: Color,
pub hover_bg: Color,
pub hover_fg: Color,
pub muted_bg: Color,
pub muted_fg: Color,
pub soloed_bg: Color,
pub soloed_fg: Color,
}
#[derive(Clone)]
@@ -211,6 +227,7 @@ pub struct SyntaxColors {
pub variable: (Color, Color),
pub vary: (Color, Color),
pub generator: (Color, Color),
pub user_defined: (Color, Color),
pub default: (Color, Color),
}

View File

@@ -103,7 +103,6 @@ pub fn theme() -> ThemeColors {
success_fg: bright,
info_bg: surface,
info_fg: fg,
event_rgb: (40, 40, 40),
},
list: ListColors {
playing_bg: Color::Rgb(50, 50, 50),
@@ -116,6 +115,10 @@ pub fn theme() -> ThemeColors {
edit_fg: bright,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(22, 22, 22),
muted_fg: dark,
soloed_bg: Color::Rgb(60, 60, 60),
soloed_fg: bright,
},
link_status: LinkStatusColors {
disabled: dim,
@@ -141,6 +144,7 @@ pub fn theme() -> ThemeColors {
variable: (medium, Color::Rgb(30, 30, 30)),
vary: (dim, Color::Rgb(25, 25, 25)),
generator: (bright, Color::Rgb(45, 45, 45)),
user_defined: (medium, Color::Rgb(35, 35, 35)),
default: (fg_dim, bg),
},
table: TableColors {

View File

@@ -103,7 +103,6 @@ pub fn theme() -> ThemeColors {
success_fg: dark,
info_bg: surface,
info_fg: fg,
event_rgb: (220, 220, 220),
},
list: ListColors {
playing_bg: Color::Rgb(200, 200, 200),
@@ -116,6 +115,10 @@ pub fn theme() -> ThemeColors {
edit_fg: dark,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(235, 235, 235),
muted_fg: light,
soloed_bg: Color::Rgb(190, 190, 190),
soloed_fg: dark,
},
link_status: LinkStatusColors {
disabled: dim,
@@ -141,6 +144,7 @@ pub fn theme() -> ThemeColors {
variable: (medium, Color::Rgb(230, 230, 230)),
vary: (dim, Color::Rgb(235, 235, 235)),
generator: (dark, Color::Rgb(215, 215, 215)),
user_defined: (medium, Color::Rgb(225, 225, 225)),
default: (fg_dim, bg),
},
table: TableColors {

View File

@@ -104,7 +104,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: bg_light,
info_fg: fg,
event_rgb: (70, 55, 70),
},
list: ListColors {
playing_bg: Color::Rgb(50, 70, 45),
@@ -117,6 +116,10 @@ pub fn theme() -> ThemeColors {
edit_fg: blue,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(48, 50, 45),
muted_fg: comment,
soloed_bg: Color::Rgb(70, 65, 45),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: pink,
@@ -142,6 +145,7 @@ pub fn theme() -> ThemeColors {
variable: (green, Color::Rgb(55, 75, 45)),
vary: (yellow, Color::Rgb(70, 65, 45)),
generator: (blue, Color::Rgb(50, 70, 70)),
user_defined: (orange, Color::Rgb(60, 50, 30)),
default: (fg_dim, darker_bg),
},
table: TableColors {

View File

@@ -104,7 +104,6 @@ pub fn theme() -> ThemeColors {
success_fg: aurora_green,
info_bg: polar_night1,
info_fg: snow_storm2,
event_rgb: (60, 55, 75),
},
list: ListColors {
playing_bg: Color::Rgb(50, 65, 55),
@@ -117,6 +116,10 @@ pub fn theme() -> ThemeColors {
edit_fg: frost0,
hover_bg: polar_night2,
hover_fg: snow_storm2,
muted_bg: Color::Rgb(55, 60, 70),
muted_fg: polar_night3,
soloed_bg: Color::Rgb(70, 65, 50),
soloed_fg: aurora_yellow,
},
link_status: LinkStatusColors {
disabled: aurora_red,
@@ -142,6 +145,7 @@ pub fn theme() -> ThemeColors {
variable: (aurora_purple, Color::Rgb(60, 50, 60)),
vary: (aurora_yellow, Color::Rgb(65, 60, 45)),
generator: (frost0, Color::Rgb(45, 60, 55)),
user_defined: (aurora_orange, Color::Rgb(60, 50, 45)),
default: (snow_storm0, polar_night1),
},
table: TableColors {

View File

@@ -105,7 +105,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: surface,
info_fg: fg,
event_rgb: (40, 30, 50),
},
list: ListColors {
playing_bg: Color::Rgb(15, 45, 25),
@@ -118,6 +117,10 @@ pub fn theme() -> ThemeColors {
edit_fg: cyan,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(15, 15, 15),
muted_fg: fg_muted,
soloed_bg: Color::Rgb(45, 40, 15),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
@@ -143,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (purple, Color::Rgb(40, 25, 50)),
vary: (yellow, Color::Rgb(50, 45, 20)),
generator: (cyan, Color::Rgb(20, 45, 40)),
user_defined: (orange, Color::Rgb(40, 25, 10)),
default: (fg_dim, bg),
},
table: TableColors {

View File

@@ -8,7 +8,7 @@ pub fn theme() -> ThemeColors {
let fg = Color::Rgb(224, 222, 244);
let fg_dim = Color::Rgb(144, 140, 170);
let muted = Color::Rgb(110, 106, 134);
let rose = Color::Rgb(235, 111, 146);
let rose = Color::Rgb(235, 188, 186);
let gold = Color::Rgb(246, 193, 119);
let foam = Color::Rgb(156, 207, 216);
let iris = Color::Rgb(196, 167, 231);
@@ -105,7 +105,6 @@ pub fn theme() -> ThemeColors {
success_fg: foam,
info_bg: bg_light,
info_fg: fg,
event_rgb: (50, 45, 60),
},
list: ListColors {
playing_bg: Color::Rgb(35, 55, 55),
@@ -118,6 +117,10 @@ pub fn theme() -> ThemeColors {
edit_fg: foam,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(32, 30, 42),
muted_fg: muted,
soloed_bg: Color::Rgb(60, 50, 40),
soloed_fg: gold,
},
link_status: LinkStatusColors {
disabled: love,
@@ -143,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (pine, Color::Rgb(35, 50, 55)),
vary: (subtle, Color::Rgb(60, 55, 55)),
generator: (foam, Color::Rgb(40, 55, 60)),
user_defined: (love, Color::Rgb(55, 35, 45)),
default: (fg_dim, darker_bg),
},
table: TableColors {

View File

@@ -105,7 +105,6 @@ pub fn theme() -> ThemeColors {
success_fg: green,
info_bg: bg_light,
info_fg: fg,
event_rgb: (55, 50, 70),
},
list: ListColors {
playing_bg: Color::Rgb(45, 60, 45),
@@ -118,6 +117,10 @@ pub fn theme() -> ThemeColors {
edit_fg: blue,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(35, 38, 50),
muted_fg: comment,
soloed_bg: Color::Rgb(60, 55, 40),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,
@@ -143,6 +146,7 @@ pub fn theme() -> ThemeColors {
variable: (green, Color::Rgb(50, 60, 45)),
vary: (yellow, Color::Rgb(70, 60, 45)),
generator: (cyan, Color::Rgb(45, 60, 75)),
user_defined: (orange, Color::Rgb(60, 50, 35)),
default: (fg_dim, darker_bg),
},
table: TableColors {

View File

@@ -0,0 +1,349 @@
use ratatui::style::Color;
use super::*;
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
let b = b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let h = if h < 0.0 { h + 360.0 } else { h };
let s = if max == 0.0 { 0.0 } else { delta / max };
let v = max;
(h, s, v)
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
fn rotate_hue_rgb(r: u8, g: u8, b: u8, degrees: f32) -> (u8, u8, u8) {
let (h, s, v) = rgb_to_hsv(r, g, b);
let new_h = (h + degrees) % 360.0;
let new_h = if new_h < 0.0 { new_h + 360.0 } else { new_h };
hsv_to_rgb(new_h, s, v)
}
fn rotate_color(color: Color, degrees: f32) -> Color {
match color {
Color::Rgb(r, g, b) => {
let (nr, ng, nb) = rotate_hue_rgb(r, g, b, degrees);
Color::Rgb(nr, ng, nb)
}
_ => color,
}
}
fn rotate_tuple(tuple: (u8, u8, u8), degrees: f32) -> (u8, u8, u8) {
rotate_hue_rgb(tuple.0, tuple.1, tuple.2, degrees)
}
fn rotate_color_pair(pair: (Color, Color), degrees: f32) -> (Color, Color) {
(rotate_color(pair.0, degrees), rotate_color(pair.1, degrees))
}
pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
if degrees == 0.0 {
return theme;
}
ThemeColors {
ui: UiColors {
bg: rotate_color(theme.ui.bg, degrees),
bg_rgb: rotate_tuple(theme.ui.bg_rgb, degrees),
text_primary: rotate_color(theme.ui.text_primary, degrees),
text_muted: rotate_color(theme.ui.text_muted, degrees),
text_dim: rotate_color(theme.ui.text_dim, degrees),
border: rotate_color(theme.ui.border, degrees),
header: rotate_color(theme.ui.header, degrees),
unfocused: rotate_color(theme.ui.unfocused, degrees),
accent: rotate_color(theme.ui.accent, degrees),
surface: rotate_color(theme.ui.surface, degrees),
},
status: StatusColors {
playing_bg: rotate_color(theme.status.playing_bg, degrees),
playing_fg: rotate_color(theme.status.playing_fg, degrees),
stopped_bg: rotate_color(theme.status.stopped_bg, degrees),
stopped_fg: rotate_color(theme.status.stopped_fg, degrees),
fill_on: rotate_color(theme.status.fill_on, degrees),
fill_off: rotate_color(theme.status.fill_off, degrees),
fill_bg: rotate_color(theme.status.fill_bg, degrees),
},
selection: SelectionColors {
cursor_bg: rotate_color(theme.selection.cursor_bg, degrees),
cursor_fg: rotate_color(theme.selection.cursor_fg, degrees),
selected_bg: rotate_color(theme.selection.selected_bg, degrees),
selected_fg: rotate_color(theme.selection.selected_fg, degrees),
in_range_bg: rotate_color(theme.selection.in_range_bg, degrees),
in_range_fg: rotate_color(theme.selection.in_range_fg, degrees),
cursor: rotate_color(theme.selection.cursor, degrees),
selected: rotate_color(theme.selection.selected, degrees),
in_range: rotate_color(theme.selection.in_range, degrees),
},
tile: TileColors {
playing_active_bg: rotate_color(theme.tile.playing_active_bg, degrees),
playing_active_fg: rotate_color(theme.tile.playing_active_fg, degrees),
playing_inactive_bg: rotate_color(theme.tile.playing_inactive_bg, degrees),
playing_inactive_fg: rotate_color(theme.tile.playing_inactive_fg, degrees),
active_bg: rotate_color(theme.tile.active_bg, degrees),
active_fg: rotate_color(theme.tile.active_fg, degrees),
inactive_bg: rotate_color(theme.tile.inactive_bg, degrees),
inactive_fg: rotate_color(theme.tile.inactive_fg, degrees),
active_selected_bg: rotate_color(theme.tile.active_selected_bg, degrees),
active_in_range_bg: rotate_color(theme.tile.active_in_range_bg, degrees),
link_bright: [
rotate_tuple(theme.tile.link_bright[0], degrees),
rotate_tuple(theme.tile.link_bright[1], degrees),
rotate_tuple(theme.tile.link_bright[2], degrees),
rotate_tuple(theme.tile.link_bright[3], degrees),
rotate_tuple(theme.tile.link_bright[4], degrees),
],
link_dim: [
rotate_tuple(theme.tile.link_dim[0], degrees),
rotate_tuple(theme.tile.link_dim[1], degrees),
rotate_tuple(theme.tile.link_dim[2], degrees),
rotate_tuple(theme.tile.link_dim[3], degrees),
rotate_tuple(theme.tile.link_dim[4], degrees),
],
},
header: HeaderColors {
tempo_bg: rotate_color(theme.header.tempo_bg, degrees),
tempo_fg: rotate_color(theme.header.tempo_fg, degrees),
bank_bg: rotate_color(theme.header.bank_bg, degrees),
bank_fg: rotate_color(theme.header.bank_fg, degrees),
pattern_bg: rotate_color(theme.header.pattern_bg, degrees),
pattern_fg: rotate_color(theme.header.pattern_fg, degrees),
stats_bg: rotate_color(theme.header.stats_bg, degrees),
stats_fg: rotate_color(theme.header.stats_fg, degrees),
},
modal: ModalColors {
border: rotate_color(theme.modal.border, degrees),
border_accent: rotate_color(theme.modal.border_accent, degrees),
border_warn: rotate_color(theme.modal.border_warn, degrees),
border_dim: rotate_color(theme.modal.border_dim, degrees),
confirm: rotate_color(theme.modal.confirm, degrees),
rename: rotate_color(theme.modal.rename, degrees),
input: rotate_color(theme.modal.input, degrees),
editor: rotate_color(theme.modal.editor, degrees),
preview: rotate_color(theme.modal.preview, degrees),
},
flash: FlashColors {
error_bg: rotate_color(theme.flash.error_bg, degrees),
error_fg: rotate_color(theme.flash.error_fg, degrees),
success_bg: rotate_color(theme.flash.success_bg, degrees),
success_fg: rotate_color(theme.flash.success_fg, degrees),
info_bg: rotate_color(theme.flash.info_bg, degrees),
info_fg: rotate_color(theme.flash.info_fg, degrees),
},
list: ListColors {
playing_bg: rotate_color(theme.list.playing_bg, degrees),
playing_fg: rotate_color(theme.list.playing_fg, degrees),
staged_play_bg: rotate_color(theme.list.staged_play_bg, degrees),
staged_play_fg: rotate_color(theme.list.staged_play_fg, degrees),
staged_stop_bg: rotate_color(theme.list.staged_stop_bg, degrees),
staged_stop_fg: rotate_color(theme.list.staged_stop_fg, degrees),
edit_bg: rotate_color(theme.list.edit_bg, degrees),
edit_fg: rotate_color(theme.list.edit_fg, degrees),
hover_bg: rotate_color(theme.list.hover_bg, degrees),
hover_fg: rotate_color(theme.list.hover_fg, degrees),
muted_bg: rotate_color(theme.list.muted_bg, degrees),
muted_fg: rotate_color(theme.list.muted_fg, degrees),
soloed_bg: rotate_color(theme.list.soloed_bg, degrees),
soloed_fg: rotate_color(theme.list.soloed_fg, degrees),
},
link_status: LinkStatusColors {
disabled: rotate_color(theme.link_status.disabled, degrees),
connected: rotate_color(theme.link_status.connected, degrees),
listening: rotate_color(theme.link_status.listening, degrees),
},
syntax: SyntaxColors {
gap_bg: rotate_color(theme.syntax.gap_bg, degrees),
executed_bg: rotate_color(theme.syntax.executed_bg, degrees),
selected_bg: rotate_color(theme.syntax.selected_bg, degrees),
emit: rotate_color_pair(theme.syntax.emit, degrees),
number: rotate_color_pair(theme.syntax.number, degrees),
string: rotate_color_pair(theme.syntax.string, degrees),
comment: rotate_color_pair(theme.syntax.comment, degrees),
keyword: rotate_color_pair(theme.syntax.keyword, degrees),
stack_op: rotate_color_pair(theme.syntax.stack_op, degrees),
operator: rotate_color_pair(theme.syntax.operator, degrees),
sound: rotate_color_pair(theme.syntax.sound, degrees),
param: rotate_color_pair(theme.syntax.param, degrees),
context: rotate_color_pair(theme.syntax.context, degrees),
note: rotate_color_pair(theme.syntax.note, degrees),
interval: rotate_color_pair(theme.syntax.interval, degrees),
variable: rotate_color_pair(theme.syntax.variable, degrees),
vary: rotate_color_pair(theme.syntax.vary, degrees),
generator: rotate_color_pair(theme.syntax.generator, degrees),
user_defined: rotate_color_pair(theme.syntax.user_defined, degrees),
default: rotate_color_pair(theme.syntax.default, degrees),
},
table: TableColors {
row_even: rotate_color(theme.table.row_even, degrees),
row_odd: rotate_color(theme.table.row_odd, degrees),
},
values: ValuesColors {
tempo: rotate_color(theme.values.tempo, degrees),
value: rotate_color(theme.values.value, degrees),
},
hint: HintColors {
key: rotate_color(theme.hint.key, degrees),
text: rotate_color(theme.hint.text, degrees),
},
view_badge: ViewBadgeColors {
bg: rotate_color(theme.view_badge.bg, degrees),
fg: rotate_color(theme.view_badge.fg, degrees),
},
nav: NavColors {
selected_bg: rotate_color(theme.nav.selected_bg, degrees),
selected_fg: rotate_color(theme.nav.selected_fg, degrees),
unselected_bg: rotate_color(theme.nav.unselected_bg, degrees),
unselected_fg: rotate_color(theme.nav.unselected_fg, degrees),
},
editor_widget: EditorWidgetColors {
cursor_bg: rotate_color(theme.editor_widget.cursor_bg, degrees),
cursor_fg: rotate_color(theme.editor_widget.cursor_fg, degrees),
selection_bg: rotate_color(theme.editor_widget.selection_bg, degrees),
completion_bg: rotate_color(theme.editor_widget.completion_bg, degrees),
completion_fg: rotate_color(theme.editor_widget.completion_fg, degrees),
completion_selected: rotate_color(theme.editor_widget.completion_selected, degrees),
completion_example: rotate_color(theme.editor_widget.completion_example, degrees),
},
browser: BrowserColors {
directory: rotate_color(theme.browser.directory, degrees),
project_file: rotate_color(theme.browser.project_file, degrees),
selected: rotate_color(theme.browser.selected, degrees),
file: rotate_color(theme.browser.file, degrees),
focused_border: rotate_color(theme.browser.focused_border, degrees),
unfocused_border: rotate_color(theme.browser.unfocused_border, degrees),
root: rotate_color(theme.browser.root, degrees),
file_icon: rotate_color(theme.browser.file_icon, degrees),
folder_icon: rotate_color(theme.browser.folder_icon, degrees),
empty_text: rotate_color(theme.browser.empty_text, degrees),
},
input: InputColors {
text: rotate_color(theme.input.text, degrees),
cursor: rotate_color(theme.input.cursor, degrees),
hint: rotate_color(theme.input.hint, degrees),
},
search: SearchColors {
active: rotate_color(theme.search.active, degrees),
inactive: rotate_color(theme.search.inactive, degrees),
match_bg: rotate_color(theme.search.match_bg, degrees),
match_fg: rotate_color(theme.search.match_fg, degrees),
},
markdown: MarkdownColors {
h1: rotate_color(theme.markdown.h1, degrees),
h2: rotate_color(theme.markdown.h2, degrees),
h3: rotate_color(theme.markdown.h3, degrees),
code: rotate_color(theme.markdown.code, degrees),
code_border: rotate_color(theme.markdown.code_border, degrees),
link: rotate_color(theme.markdown.link, degrees),
link_url: rotate_color(theme.markdown.link_url, degrees),
quote: rotate_color(theme.markdown.quote, degrees),
text: rotate_color(theme.markdown.text, degrees),
list: rotate_color(theme.markdown.list, degrees),
},
engine: EngineColors {
header: rotate_color(theme.engine.header, degrees),
header_focused: rotate_color(theme.engine.header_focused, degrees),
divider: rotate_color(theme.engine.divider, degrees),
scroll_indicator: rotate_color(theme.engine.scroll_indicator, degrees),
label: rotate_color(theme.engine.label, degrees),
label_focused: rotate_color(theme.engine.label_focused, degrees),
label_dim: rotate_color(theme.engine.label_dim, degrees),
value: rotate_color(theme.engine.value, degrees),
focused: rotate_color(theme.engine.focused, degrees),
normal: rotate_color(theme.engine.normal, degrees),
dim: rotate_color(theme.engine.dim, degrees),
path: rotate_color(theme.engine.path, degrees),
border_magenta: rotate_color(theme.engine.border_magenta, degrees),
border_green: rotate_color(theme.engine.border_green, degrees),
border_cyan: rotate_color(theme.engine.border_cyan, degrees),
separator: rotate_color(theme.engine.separator, degrees),
hint_active: rotate_color(theme.engine.hint_active, degrees),
hint_inactive: rotate_color(theme.engine.hint_inactive, degrees),
},
dict: DictColors {
word_name: rotate_color(theme.dict.word_name, degrees),
word_bg: rotate_color(theme.dict.word_bg, degrees),
alias: rotate_color(theme.dict.alias, degrees),
stack_sig: rotate_color(theme.dict.stack_sig, degrees),
description: rotate_color(theme.dict.description, degrees),
example: rotate_color(theme.dict.example, degrees),
category_focused: rotate_color(theme.dict.category_focused, degrees),
category_selected: rotate_color(theme.dict.category_selected, degrees),
category_normal: rotate_color(theme.dict.category_normal, degrees),
category_dimmed: rotate_color(theme.dict.category_dimmed, degrees),
border_focused: rotate_color(theme.dict.border_focused, degrees),
border_normal: rotate_color(theme.dict.border_normal, degrees),
header_desc: rotate_color(theme.dict.header_desc, degrees),
},
title: TitleColors {
big_title: rotate_color(theme.title.big_title, degrees),
author: rotate_color(theme.title.author, degrees),
link: rotate_color(theme.title.link, degrees),
license: rotate_color(theme.title.license, degrees),
prompt: rotate_color(theme.title.prompt, degrees),
subtitle: rotate_color(theme.title.subtitle, degrees),
},
meter: MeterColors {
low: rotate_color(theme.meter.low, degrees),
mid: rotate_color(theme.meter.mid, degrees),
high: rotate_color(theme.meter.high, degrees),
low_rgb: rotate_tuple(theme.meter.low_rgb, degrees),
mid_rgb: rotate_tuple(theme.meter.mid_rgb, degrees),
high_rgb: rotate_tuple(theme.meter.high_rgb, degrees),
},
sparkle: SparkleColors {
colors: [
rotate_tuple(theme.sparkle.colors[0], degrees),
rotate_tuple(theme.sparkle.colors[1], degrees),
rotate_tuple(theme.sparkle.colors[2], degrees),
rotate_tuple(theme.sparkle.colors[3], degrees),
rotate_tuple(theme.sparkle.colors[4], degrees),
],
},
confirm: ConfirmColors {
border: rotate_color(theme.confirm.border, degrees),
button_selected_bg: rotate_color(theme.confirm.button_selected_bg, degrees),
button_selected_fg: rotate_color(theme.confirm.button_selected_fg, degrees),
},
}
}

View File

@@ -0,0 +1,197 @@
use crate::scope::Orientation;
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
use std::cell::RefCell;
thread_local! {
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub struct Waveform<'a> {
data: &'a [f32],
orientation: Orientation,
color: Option<Color>,
gain: f32,
}
impl<'a> Waveform<'a> {
pub fn new(data: &'a [f32]) -> Self {
Self {
data,
orientation: Orientation::Horizontal,
color: None,
gain: 1.0,
}
}
pub fn orientation(mut self, o: Orientation) -> Self {
self.orientation = o;
self
}
pub fn color(mut self, c: Color) -> Self {
self.color = Some(c);
self
}
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
}
impl Widget for Waveform<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 || self.data.is_empty() {
return;
}
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
match self.orientation {
Orientation::Horizontal => {
render_horizontal(self.data, area, buf, color, self.gain)
}
Orientation::Vertical => render_vertical(self.data, area, buf, color, self.gain),
}
}
}
fn braille_bit(dot_x: usize, dot_y: usize) -> u8 {
match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
}
}
fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = data.len();
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();
patterns.clear();
patterns.resize(width * height, 0);
for fine_x in 0..fine_width {
let start = fine_x * len / fine_width;
let end = ((fine_x + 1) * len / fine_width).max(start + 1).min(len);
let slice = &data[start..end];
let mut min_s = f32::MAX;
let mut max_s = f32::MIN;
for &s in slice {
let s = (s * auto_gain).clamp(-1.0, 1.0);
if s < min_s {
min_s = s;
}
if s > max_s {
max_s = s;
}
}
let fy_top = ((1.0 - max_s) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fy_bot = ((1.0 - min_s) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fy_top = fy_top.min(fine_height - 1);
let fy_bot = fy_bot.min(fine_height - 1);
let char_x = fine_x / 2;
let dot_x = fine_x % 2;
for fy in fy_top..=fy_bot {
let char_y = fy / 4;
let dot_y = fy % 4;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}
fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
let len = data.len();
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();
patterns.clear();
patterns.resize(width * height, 0);
for fine_y in 0..fine_height {
let start = fine_y * len / fine_height;
let end = ((fine_y + 1) * len / fine_height).max(start + 1).min(len);
let slice = &data[start..end];
let mut min_s = f32::MAX;
let mut max_s = f32::MIN;
for &s in slice {
let s = (s * auto_gain).clamp(-1.0, 1.0);
if s < min_s {
min_s = s;
}
if s > max_s {
max_s = s;
}
}
let fx_left = ((min_s + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fx_right = ((max_s + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fx_left = fx_left.min(fine_width - 1);
let fx_right = fx_right.min(fine_width - 1);
let char_y = fine_y / 4;
let dot_y = fine_y % 4;
for fx in fx_left..=fx_right {
let char_x = fx / 2;
let dot_x = fx % 2;
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
}
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
}
});
}

View File

@@ -0,0 +1,82 @@
# Audio-Rate Modulation
Any parameter can be modulated continuously using modulation words. Instead of a fixed value, these words produce a modulation string that the engine interprets as a moving signal.
All time values are in **steps**, just like `attack`, `decay`, and `release`. At 120 BPM with speed 1, one step is 0.125 seconds. Writing `4 lfo` means a 4-step period.
## LFOs
Oscillate a parameter between two values.
```forth
saw s 200 4000 4 lfo lpf . ( sweep filter over 4 steps )
saw s 0.3 0.7 2 tlfo pan . ( triangle pan over 2 steps )
```
| Word | Shape | Output |
|------|-------|--------|
| `lfo` | Sine | `min~max:period` |
| `tlfo` | Triangle | `min~max:periodt` |
| `wlfo` | Sawtooth | `min~max:periodw` |
| `qlfo` | Square | `min~max:periodq` |
Stack effect: `( min max period -- str )`
## Slides
Transition from one value to another over a duration.
```forth
saw s 0 1 0.5 slide gain . ( fade in over half a step )
saw s 200 4000 8 sslide lpf . ( smooth sweep over 8 steps )
```
| Word | Curve | Output |
|------|-------|--------|
| `slide` | Linear | `start>end:dur` |
| `expslide` | Exponential | `start>end:dure` |
| `sslide` | Smooth (S-curve) | `start>end:durs` |
Stack effect: `( start end dur -- str )`
## Random
Randomize a parameter within a range, retriggering at a given period.
```forth
saw s 200 4000 2 jit lpf . ( new random value every 2 steps )
saw s 200 4000 2 sjit lpf . ( same but smoothly interpolated )
saw s 200 4000 1 drunk lpf . ( random walk, each step )
```
| Word | Behavior | Output |
|------|----------|--------|
| `jit` | Sample & hold | `min?max:period` |
| `sjit` | Smooth interpolation | `min?max:periods` |
| `drunk` | Random walk | `min?max:periodd` |
Stack effect: `( min max period -- str )`
## Envelopes
Define a multi-segment envelope for a parameter. Provide a start value, then pairs of target and duration.
```forth
saw s 0 1 0.1 0.7 0.5 0 8 env gain .
```
This creates: start at `0`, rise to `1` in `0.1` steps, drop to `0.7` in `0.5` steps, fall to `0` in `8` steps.
Stack effect: `( start target1 dur1 [target2 dur2 ...] -- str )`
## Combining
Modulation words return strings, so they compose naturally with the rest of the language. Use them anywhere a parameter value is expected.
```forth
saw s
200 4000 4 lfo lpf
0.3 0.7 8 tlfo pan
0 1 0.1 0.7 0.5 0 8 env gain
.
```

View File

@@ -22,7 +22,6 @@ Navigate with arrow keys, adjust values with left/right:
- **Channels**: number of output channels (2 for stereo).
- **Buffer Size**: audio buffer in samples (64-4096).
- **Max Voices**: polyphony limit (1-128, default 32).
- **Lookahead**: scheduling lookahead in milliseconds (0-50, default 15).
### Buffer Size

View File

@@ -193,21 +193,42 @@ You can also use quotations if you need to execute code:
When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack.
Three cycling words exist:
Two cycling words exist:
- `cycle` - selects based on `runs` (how many times this step has played)
- `pcycle` - selects based on `iter` (how many times the pattern has looped)
- `tcycle` - creates a cycle list that resolves at emit time
The difference between `cycle` and `pcycle` matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern.
`tcycle` is special. It does not select immediately. Instead it creates a value that cycles when emitted:
## Polyphonic Parameters
Parameter words like `note`, `freq`, and `gain` consume the entire stack. If you push multiple values before a param word, you get polyphony:
```forth
0.3 0.5 0.7 3 tcycle gain
60 64 67 note sine s . ;; emits 3 voices with notes 60, 64, 67
```
If you emit multiple times in one step (using `at`), each emit gets the next value from the cycle.
This works for any param and for the sound word itself:
```forth
440 880 freq sine tri s . ;; 2 voices: sine at 440, tri at 880
```
When params have different lengths, shorter lists cycle:
```forth
60 64 67 note ;; 3 notes
0.5 1.0 gain ;; 2 gains (cycles: 0.5, 1.0, 0.5)
sine s . ;; emits 3 voices
```
Polyphony multiplies with `at` deltas:
```forth
0 0.5 at ;; 2 time points
60 64 note ;; 2 notes
sine s . ;; emits 4 voices (2 notes × 2 times)
```
## Summary

51
docs/prelude.md Normal file
View File

@@ -0,0 +1,51 @@
# The Prelude
When you define a word in a step, it becomes available to all steps. But when you close and reopen the project, the dictionary is empty again. Words defined in steps only exist after those steps run.
The **prelude** solves this. It's a project-wide script that runs automatically when playback starts and when you load a project.
## Accessing the Prelude
Press `d` to open the prelude editor. Press `Esc` to save and evaluate. Press `D` (Shift+d) to re-evaluate the prelude without opening the editor.
## What It's For
Define words that should be available everywhere, always:
```forth
: kick "kick" s 0.9 gain . ;
: hat "hat" s 0.4 gain . ;
: bass "saw" s 0.7 gain 200 lpf . ;
```
Now every step in your project can use `kick`, `hat`, and `bass` from the first beat.
## When It Runs
The prelude evaluates:
1. When you press Space to start playback (if stopped)
2. When you load a project
3. When you press `D` manually
It does not run on every step, only once at these moments. This makes it ideal for setup code: word definitions, initial variable values, seed resets.
## Practical Example
A prelude for a techno project:
```forth
: k "kick" s 1.2 attack . ;
: sn "snare" s 0.6 gain 0.02 attack . ;
: hh "hat" s 0.3 gain 8000 hpf . ;
: sub "sine" s 0.8 gain 150 lpf . ;
0 seed
```
Step scripts become trivial:
```forth
c1 note k sub
```
The sound design lives in the prelude. Steps focus on rhythm and melody.

View File

@@ -2,5 +2,6 @@ allow-branch = ["main"]
sign-commit = false
sign-tag = false
push = true
push-remote = "github"
publish = false
tag-name = "v{{version}}"

File diff suppressed because it is too large Load Diff

View File

@@ -15,15 +15,14 @@ use soft_ratatui::embedded_graphics_unicodefonts::{
};
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::app::App;
use cagire::engine::{
build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand,
ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer,
build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer,
SequencerHandle, SpectrumBuffer,
};
use cagire::init::{init, InitArgs};
use cagire::input::{handle_key, InputContext, InputResult};
use cagire::input_egui::convert_egui_events;
use cagire::settings::Settings;
use cagire::state::audio::RefreshRate;
use cagire::views;
use crossbeam_channel::Receiver;
@@ -129,13 +128,12 @@ fn create_terminal(font: FontChoice) -> TerminalType {
}
struct CagireDesktop {
app: App,
app: cagire::app::App,
terminal: TerminalType,
link: Arc<LinkState>,
sequencer: Option<SequencerHandle>,
playing: Arc<AtomicBool>,
nudge_us: Arc<AtomicI64>,
lookahead_ms: Arc<AtomicU32>,
metrics: Arc<EngineMetrics>,
scope_buffer: Arc<ScopeBuffer>,
spectrum_buffer: Arc<SpectrumBuffer>,
@@ -148,144 +146,44 @@ struct CagireDesktop {
mouse_x: Arc<AtomicU32>,
mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>,
last_frame: std::time::Instant,
}
impl CagireDesktop {
fn new(cc: &eframe::CreationContext<'_>, args: Args) -> Self {
let settings = Settings::load();
let b = init(InitArgs {
samples: args.samples,
output: args.output,
input: args.input,
channels: args.channels,
buffer: args.buffer,
});
let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum));
if settings.link.enabled {
link.enable();
}
let playing = Arc::new(AtomicBool::new(true));
let nudge_us = Arc::new(AtomicI64::new(0));
let mut app = App::new();
app.playback
.queued_changes
.push(cagire::state::StagedChange {
change: cagire::engine::PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: cagire::model::LaunchQuantization::Immediate,
sync_mode: cagire::model::SyncMode::Reset,
});
app.audio.config.output_device = args.output.or(settings.audio.output_device);
app.audio.config.input_device = args.input.or(settings.audio.input_device);
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
app.audio.config.max_voices = settings.audio.max_voices;
app.audio.config.lookahead_ms = settings.audio.lookahead_ms;
app.audio.config.sample_paths = args.samples;
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum;
app.ui.show_completion = settings.display.show_completion;
app.ui.flash_brightness = settings.display.flash_brightness;
let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let audio_sample_pos = Arc::new(AtomicU64::new(0));
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let lookahead_ms = Arc::new(AtomicU32::new(settings.audio.lookahead_ms));
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_count += index.len();
initial_samples.extend(index);
}
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let seq_config = SequencerConfig {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
lookahead_ms: Arc::clone(&lookahead_ms),
cc_access: Some(
Arc::new(app.midi.cc_memory.clone()) as Arc<dyn cagire::model::CcAccess>
),
mouse_x: Arc::clone(&mouse_x),
mouse_y: Arc::clone(&mouse_y),
mouse_down: Arc::clone(&mouse_down),
};
let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&app.variables),
Arc::clone(&app.dict),
Arc::clone(&app.rng),
settings.link.quantum,
Arc::clone(&app.live_keys),
Arc::clone(&nudge_us),
seq_config,
);
let stream_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(),
channels: app.audio.config.channels,
buffer_size: app.audio.config.buffer_size,
max_voices: app.audio.config.max_voices,
};
let (stream, analysis_handle) = match build_stream(
&stream_config,
initial_audio_rx,
Arc::clone(&scope_buffer),
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
initial_samples,
Arc::clone(&audio_sample_pos),
) {
Ok((s, sample_rate, analysis)) => {
app.audio.config.sample_rate = sample_rate;
sample_rate_shared.store(sample_rate as u32, Ordering::Relaxed);
(Some(s), Some(analysis))
}
Err(e) => {
app.ui.set_status(format!("Audio failed: {e}"));
app.audio.error = Some(e);
(None, None)
}
};
app.mark_all_patterns_dirty();
let current_font = FontChoice::from_setting(&settings.display.font);
let current_font = FontChoice::from_setting(&b.settings.display.font);
let terminal = create_terminal(current_font);
cc.egui_ctx.set_visuals(egui::Visuals::dark());
Self {
app,
app: b.app,
terminal,
link,
sequencer: Some(sequencer),
playing,
nudge_us,
lookahead_ms,
metrics,
scope_buffer,
spectrum_buffer,
audio_sample_pos,
sample_rate_shared,
_stream: stream,
_analysis_handle: analysis_handle,
midi_rx,
link: b.link,
sequencer: Some(b.sequencer),
playing: b.playing,
nudge_us: b.nudge_us,
metrics: b.metrics,
scope_buffer: b.scope_buffer,
spectrum_buffer: b.spectrum_buffer,
audio_sample_pos: b.audio_sample_pos,
sample_rate_shared: b.sample_rate_shared,
_stream: b.stream,
_analysis_handle: b.analysis_handle,
midi_rx: b.midi_rx,
current_font,
mouse_x,
mouse_y,
mouse_down,
mouse_x: b.mouse_x,
mouse_y: b.mouse_y,
mouse_down: b.mouse_down,
last_frame: std::time::Instant::now(),
}
}
@@ -320,6 +218,11 @@ impl CagireDesktop {
self.audio_sample_pos.store(0, Ordering::Release);
let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples
.iter()
.map(|e| (e.name.clone(), e.path.clone()))
.collect();
match build_stream(
&new_config,
new_audio_rx,
@@ -329,13 +232,27 @@ impl CagireDesktop {
restart_samples,
Arc::clone(&self.audio_sample_pos),
) {
Ok((new_stream, sr, new_analysis)) => {
Ok((new_stream, info, new_analysis, registry)) => {
self._stream = Some(new_stream);
self._analysis_handle = Some(new_analysis);
self.app.audio.config.sample_rate = sr;
self.sample_rate_shared.store(sr as u32, Ordering::Relaxed);
self.app.audio.config.sample_rate = info.sample_rate;
self.app.audio.config.host_name = info.host_name;
self.app.audio.config.channels = info.channels;
self.sample_rate_shared
.store(info.sample_rate as u32, Ordering::Relaxed);
self.app.audio.error = None;
self.app.audio.sample_registry = Some(std::sync::Arc::clone(&registry));
self.app.ui.set_status("Audio restarted".to_string());
if !preload_entries.is_empty() {
let sr = info.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
cagire::init::preload_sample_heads(preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
}
Err(e) => {
self.app.audio.error = Some(e.clone());
@@ -378,7 +295,6 @@ impl CagireDesktop {
audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &self.nudge_us,
lookahead_ms: &self.lookahead_ms,
};
if let InputResult::Quit = handle_key(&mut input_ctx, key) {
@@ -417,18 +333,6 @@ impl eframe::App for CagireDesktop {
let seq_snapshot = sequencer.snapshot();
self.app.metrics.event_count = seq_snapshot.event_count;
self.app.metrics.dropped_events = seq_snapshot.dropped_events;
self.app.ui.event_flash = (self.app.ui.event_flash - 0.1).max(0.0);
let new_events = self
.app
.metrics
.event_count
.saturating_sub(self.app.ui.last_event_count);
if new_events > 0 {
self.app.ui.event_flash = (new_events as f32 * 0.4).min(1.0);
}
self.app.ui.last_event_count = self.app.metrics.event_count;
self.app.flush_queued_changes(&sequencer.cmd_tx);
self.app.flush_dirty_patterns(&sequencer.cmd_tx);
@@ -502,10 +406,15 @@ impl eframe::App for CagireDesktop {
self.app.ui.sparkles.tick(self.terminal.get_frame().area());
}
cagire::state::effects::tick_effects(&mut self.app.ui, self.app.page);
let elapsed = self.last_frame.elapsed();
self.last_frame = std::time::Instant::now();
let link = &self.link;
let app = &self.app;
self.terminal
.draw(|frame| views::render(frame, app, link, &seq_snapshot))
.draw(|frame| views::render(frame, app, link, &seq_snapshot, elapsed))
.expect("Failed to draw");
ui.add(self.terminal.backend_mut());
@@ -549,7 +458,7 @@ impl eframe::App for CagireDesktop {
}
fn load_icon() -> egui::IconData {
const ICON_BYTES: &[u8] = include_bytes!("../../cagire_pixel.png");
const ICON_BYTES: &[u8] = include_bytes!("../../assets/Cagire.png");
let img = image::load_from_memory(ICON_BYTES)
.expect("Failed to load embedded icon")
@@ -566,6 +475,9 @@ fn load_icon() -> egui::IconData {
}
fn main() -> eframe::Result<()> {
#[cfg(unix)]
cagire::engine::realtime::lock_memory();
let args = Args::parse();
let options = NativeOptions {

View File

@@ -60,12 +60,33 @@ pub enum AppCommand {
bank: usize,
pattern: usize,
},
CopyPatterns {
bank: usize,
patterns: Vec<usize>,
},
PastePatterns {
bank: usize,
start: usize,
},
CopyBank {
bank: usize,
},
PasteBank {
bank: usize,
},
CopyBanks {
banks: Vec<usize>,
},
PasteBanks {
start: usize,
},
ResetPatterns {
bank: usize,
patterns: Vec<usize>,
},
ResetBanks {
banks: Vec<usize>,
},
// Clipboard
HardenSteps,
@@ -75,7 +96,6 @@ pub enum AppCommand {
DuplicateSteps,
// Pattern playback (staging)
CommitStagedChanges,
ClearStagedChanges,
// Project
@@ -107,7 +127,7 @@ pub enum AppCommand {
bank: usize,
pattern: usize,
},
SetPatternProps {
StagePatternProps {
bank: usize,
pattern: usize,
name: Option<String>,
@@ -154,16 +174,21 @@ pub enum AppCommand {
PatternsCursorDown,
PatternsEnter,
PatternsBack,
PatternsTogglePlay,
// Mute/Solo (staged)
StageMute { bank: usize, pattern: usize },
StageSolo { bank: usize, pattern: usize },
ClearMutes, // Clears both staged and applied mutes
ClearSolos, // Clears both staged and applied solos
// UI state
ClearMinimap,
HideTitle,
ToggleEditorStack,
SetColorScheme(ColorScheme),
SetHueRotation(f32),
ToggleRuntimeHighlight,
ToggleCompletion,
AdjustFlashBrightness(f32),
// Live keys
ToggleLiveKeysFill,
@@ -207,4 +232,19 @@ pub enum AppCommand {
// Metrics
ResetPeakVoices,
// Euclidean distribution
ApplyEuclideanDistribution {
bank: usize,
pattern: usize,
source_step: usize,
pulses: usize,
steps: usize,
rotation: usize,
},
// Prelude
OpenPreludeEditor,
SavePrelude,
EvaluatePrelude,
ClosePreludeEditor,
}

View File

@@ -1,4 +1,4 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::traits::{DeviceTrait, StreamTrait};
use cpal::Stream;
use crossbeam_channel::Receiver;
use doux::{Engine, EngineMetrics};
@@ -237,6 +237,12 @@ pub struct AudioStreamConfig {
pub max_voices: usize,
}
pub struct AudioStreamInfo {
pub sample_rate: f32,
pub host_name: String,
pub channels: u16,
}
pub fn build_stream(
config: &AudioStreamConfig,
audio_rx: Receiver<AudioCommand>,
@@ -245,57 +251,84 @@ pub fn build_stream(
metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sampling::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>,
) -> Result<(Stream, f32, AnalysisHandle), String> {
let host = cpal::default_host();
) -> Result<
(
Stream,
AudioStreamInfo,
AnalysisHandle,
Arc<doux::SampleRegistry>,
),
String,
> {
let device = match &config.output_device {
Some(name) => doux::audio::find_output_device(name)
.ok_or_else(|| format!("Device not found: {name}"))?,
None => host
.default_output_device()
.ok_or("No default output device")?,
None => doux::audio::default_output_device().ok_or("No default output device")?,
};
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
let sample_rate = default_config.sample_rate().0 as f32;
let sample_rate = default_config.sample_rate() as f32;
let buffer_size = if config.buffer_size > 0 {
let max_channels = doux::audio::max_output_channels(&device);
let channels = config.channels.min(max_channels);
let host_name = doux::audio::preferred_host().id().name().to_string();
let is_jack = host_name.to_lowercase().contains("jack");
let buffer_size = if config.buffer_size > 0 && !is_jack {
cpal::BufferSize::Fixed(config.buffer_size)
} else {
cpal::BufferSize::Default
};
let stream_config = cpal::StreamConfig {
channels: config.channels,
channels,
sample_rate: default_config.sample_rate(),
buffer_size,
};
let sr = sample_rate;
let channels = config.channels as usize;
let effective_channels = channels;
let channels = channels as usize;
let max_voices = config.max_voices;
let mut engine =
Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics));
engine.sample_index = initial_samples;
let registry = Arc::clone(&engine.sample_registry);
let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
let mut cmd_buffer = String::with_capacity(256);
let mut rt_set = false;
let stream = device
.build_output_stream(
&stream_config,
move |data: &mut [f32], _| {
if !rt_set {
super::realtime::set_realtime_priority();
rt_set = true;
}
let buffer_samples = data.len() / channels;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Release);
while let Ok(cmd) = audio_rx.try_recv() {
match cmd {
AudioCommand::Evaluate { cmd, time } => {
let cmd_with_time = match time {
Some(t) => format!("{cmd}/time/{t:.6}"),
None => cmd,
let cmd_ref = match time {
Some(t) => {
cmd_buffer.clear();
use std::fmt::Write;
let _ = write!(&mut cmd_buffer, "{cmd}/time/{t:.6}");
cmd_buffer.as_str()
}
None => &cmd,
};
engine.evaluate(&cmd_with_time);
engine.evaluate(cmd_ref);
}
AudioCommand::Hush => {
engine.hush();
@@ -313,8 +346,6 @@ pub fn build_stream(
engine.process_block(data, &[], &[]);
scope_buffer.write(&engine.output);
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Relaxed);
// Feed mono mix to analysis thread via ring buffer (non-blocking)
for chunk in engine.output.chunks(channels) {
let mono = chunk.iter().sum::<f32>() / channels as f32;
@@ -329,5 +360,10 @@ pub fn build_stream(
stream
.play()
.map_err(|e| format!("Failed to play stream: {e}"))?;
Ok((stream, sample_rate, analysis_handle))
let info = AudioStreamInfo {
sample_rate,
host_name,
channels: effective_channels,
};
Ok((stream, info, analysis_handle, registry))
}

156
src/engine/dispatcher.rs Normal file
View File

@@ -0,0 +1,156 @@
use arc_swap::ArcSwap;
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::sync::Arc;
use std::time::Duration;
use super::link::LinkState;
use super::realtime::{precise_sleep_us, set_realtime_priority};
use super::sequencer::MidiCommand;
use super::timing::SyncTime;
/// A MIDI command scheduled for dispatch at a specific time.
#[derive(Clone)]
pub struct TimedMidiCommand {
pub command: MidiDispatch,
pub target_time_us: SyncTime,
}
/// MIDI commands the dispatcher can send.
#[derive(Clone)]
pub enum MidiDispatch {
Send(MidiCommand),
FlushAll,
}
impl Ord for TimedMidiCommand {
fn cmp(&self, other: &Self) -> Ordering {
other.target_time_us.cmp(&self.target_time_us)
}
}
impl PartialOrd for TimedMidiCommand {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for TimedMidiCommand {
fn eq(&self, other: &Self) -> bool {
self.target_time_us == other.target_time_us
}
}
impl Eq for TimedMidiCommand {}
const SPIN_THRESHOLD_US: SyncTime = 100;
/// Dispatcher loop — handles MIDI timing only.
/// Audio commands bypass the dispatcher entirely and go straight to doux's
/// sample-accurate scheduler via the audio thread channel.
pub fn dispatcher_loop(
cmd_rx: Receiver<TimedMidiCommand>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
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.");
}
let mut queue: BinaryHeap<TimedMidiCommand> = BinaryHeap::with_capacity(256);
loop {
let current_us = link.clock_micros() as SyncTime;
let timeout_us = queue
.peek()
.map(|cmd| cmd.target_time_us.saturating_sub(current_us))
.unwrap_or(100_000)
.max(100);
match cmd_rx.recv_timeout(Duration::from_micros(timeout_us)) {
Ok(cmd) => queue.push(cmd),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
while let Ok(cmd) = cmd_rx.try_recv() {
queue.push(cmd);
}
let current_us = link.clock_micros() as SyncTime;
while let Some(cmd) = queue.peek() {
if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US {
let cmd = queue.pop().unwrap();
wait_until_dispatch(cmd.target_time_us, &link, has_rt);
dispatch_midi(cmd.command, &midi_tx);
} else {
break;
}
}
}
}
fn wait_until_dispatch(target_us: SyncTime, link: &LinkState, has_rt: bool) {
let current = link.clock_micros() as SyncTime;
let remaining = target_us.saturating_sub(current);
if has_rt {
while (link.clock_micros() as SyncTime) < target_us {
std::hint::spin_loop();
}
} else if remaining > 0 {
precise_sleep_us(remaining);
}
}
fn dispatch_midi(cmd: MidiDispatch, midi_tx: &Arc<ArcSwap<Sender<MidiCommand>>>) {
match cmd {
MidiDispatch::Send(midi_cmd) => {
let _ = midi_tx.load().try_send(midi_cmd);
}
MidiDispatch::FlushAll => {
for dev in 0..4u8 {
for chan in 0..16u8 {
let _ = midi_tx.load().try_send(MidiCommand::CC {
device: dev,
channel: chan,
cc: 123,
value: 0,
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timed_command_ordering() {
let mut heap: BinaryHeap<TimedMidiCommand> = BinaryHeap::new();
heap.push(TimedMidiCommand {
command: MidiDispatch::FlushAll,
target_time_us: 300,
});
heap.push(TimedMidiCommand {
command: MidiDispatch::FlushAll,
target_time_us: 100,
});
heap.push(TimedMidiCommand {
command: MidiDispatch::FlushAll,
target_time_us: 200,
});
assert_eq!(heap.pop().unwrap().target_time_us, 100);
assert_eq!(heap.pop().unwrap().target_time_us, 200);
assert_eq!(heap.pop().unwrap().target_time_us, 300);
}
}

View File

@@ -37,12 +37,12 @@ impl LinkState {
}
pub fn quantum(&self) -> f64 {
f64::from_bits(self.quantum.load(Ordering::Relaxed))
f64::from_bits(self.quantum.load(Ordering::Acquire))
}
pub fn set_quantum(&self, quantum: f64) {
let clamped = quantum.clamp(1.0, 16.0);
self.quantum.store(clamped.to_bits(), Ordering::Relaxed);
self.quantum.store(clamped.to_bits(), Ordering::Release);
}
pub fn clock_micros(&self) -> i64 {

View File

@@ -1,6 +1,11 @@
mod audio;
mod dispatcher;
mod link;
pub mod realtime;
pub mod sequencer;
mod timing;
pub use timing::{substeps_in_window, StepTiming, SyncTime};
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
#[allow(unused_imports)]

169
src/engine/realtime.rs Normal file
View File

@@ -0,0 +1,169 @@
#[cfg(target_os = "linux")]
mod memory {
use std::sync::atomic::{AtomicBool, Ordering};
static MLOCKALL_CALLED: AtomicBool = AtomicBool::new(false);
static MLOCKALL_SUCCESS: AtomicBool = AtomicBool::new(false);
/// Locks all current and future memory pages to prevent page faults during RT execution.
/// Must be called BEFORE spawning any threads for maximum effectiveness.
pub fn lock_memory() -> bool {
if MLOCKALL_CALLED.swap(true, Ordering::SeqCst) {
return MLOCKALL_SUCCESS.load(Ordering::SeqCst);
}
let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
if result == 0 {
MLOCKALL_SUCCESS.store(true, Ordering::SeqCst);
true
} else {
let errno = std::io::Error::last_os_error();
eprintln!("[cagire] mlockall failed: {errno}");
eprintln!("[cagire] Memory locking disabled. For best RT performance on Linux:");
eprintln!("[cagire] 1. Add user to 'audio' group: sudo usermod -aG audio $USER");
eprintln!("[cagire] 2. Add to /etc/security/limits.conf:");
eprintln!("[cagire] @audio - memlock unlimited");
eprintln!("[cagire] 3. Log out and back in");
false
}
}
#[allow(dead_code)]
pub fn is_memory_locked() -> bool {
MLOCKALL_SUCCESS.load(Ordering::Relaxed)
}
}
#[cfg(target_os = "linux")]
pub use memory::{is_memory_locked, lock_memory};
#[cfg(not(target_os = "linux"))]
pub fn lock_memory() -> bool {
true
}
#[cfg(not(target_os = "linux"))]
#[allow(dead_code)]
pub fn is_memory_locked() -> bool {
false
}
/// Attempts to set realtime scheduling priority for the current thread.
/// Returns true if RT priority was successfully set, false otherwise.
#[cfg(target_os = "macos")]
pub fn set_realtime_priority() -> bool {
// macOS: use THREAD_TIME_CONSTRAINT_POLICY for true RT scheduling.
// This is the same mechanism CoreAudio uses for its audio threads.
// SCHED_FIFO/RR require root on macOS, but time constraint policy does not.
unsafe {
let thread = libc::pthread_self();
#[repr(C)]
struct ThreadTimeConstraintPolicy {
period: u32,
computation: u32,
constraint: u32,
preemptible: i32,
}
const THREAD_TIME_CONSTRAINT_POLICY_ID: u32 = 2;
const THREAD_TIME_CONSTRAINT_POLICY_COUNT: u32 = 4;
// ~1ms period at ~1GHz mach_absolute_time ticks (typical for audio)
let policy = ThreadTimeConstraintPolicy {
period: 1_000_000,
computation: 500_000,
constraint: 1_000_000,
preemptible: 1,
};
extern "C" {
fn thread_policy_set(
thread: libc::pthread_t,
flavor: u32,
policy_info: *const ThreadTimeConstraintPolicy,
count: u32,
) -> i32;
}
let result = thread_policy_set(
thread,
THREAD_TIME_CONSTRAINT_POLICY_ID,
&policy,
THREAD_TIME_CONSTRAINT_POLICY_COUNT,
);
result == 0
}
}
/// Attempts to set realtime scheduling priority for the current thread.
/// Returns true if RT priority was successfully set, false otherwise.
///
/// On Linux, this requires either:
/// - CAP_SYS_NICE capability, or
/// - Configured rtprio limits in /etc/security/limits.conf:
/// @audio - rtprio 95
/// @audio - memlock unlimited
#[cfg(target_os = "linux")]
pub fn set_realtime_priority() -> bool {
use thread_priority::unix::{
set_thread_priority_and_policy, thread_native_id, NormalThreadSchedulePolicy,
RealtimeThreadSchedulePolicy, ThreadSchedulePolicy,
};
use thread_priority::ThreadPriority;
let tid = thread_native_id();
let fifo = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo);
if set_thread_priority_and_policy(tid, ThreadPriority::Max, fifo).is_ok() {
return true;
}
let rr = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::RoundRobin);
if set_thread_priority_and_policy(tid, ThreadPriority::Max, rr).is_ok() {
return true;
}
let _ = set_thread_priority_and_policy(
tid,
ThreadPriority::Max,
ThreadSchedulePolicy::Normal(NormalThreadSchedulePolicy::Other),
);
unsafe {
libc::setpriority(libc::PRIO_PROCESS, 0, -20);
}
false
}
#[cfg(not(any(unix, target_os = "windows")))]
pub fn set_realtime_priority() -> bool {
false
}
#[cfg(target_os = "windows")]
pub fn set_realtime_priority() -> bool {
use thread_priority::{set_current_thread_priority, ThreadPriority};
set_current_thread_priority(ThreadPriority::Max).is_ok()
}
/// High-precision sleep using clock_nanosleep on Linux.
/// Uses monotonic clock for jitter-free sleeping.
#[cfg(target_os = "linux")]
pub fn precise_sleep_us(micros: u64) {
let duration_ns = micros * 1000;
let ts = libc::timespec {
tv_sec: (duration_ns / 1_000_000_000) as i64,
tv_nsec: (duration_ns % 1_000_000_000) as i64,
};
unsafe {
libc::clock_nanosleep(libc::CLOCK_MONOTONIC, 0, &ts, std::ptr::null_mut());
}
}
#[cfg(not(target_os = "linux"))]
pub fn precise_sleep_us(micros: u64) {
std::thread::sleep(std::time::Duration::from_micros(micros));
}

File diff suppressed because it is too large Load Diff

212
src/engine/timing.rs Normal file
View File

@@ -0,0 +1,212 @@
/// Microsecond-precision timestamp for audio synchronization.
pub type SyncTime = u64;
/// Timing boundary types for step and pattern scheduling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepTiming {
/// Fire when a beat boundary is crossed.
NextBeat,
/// Fire when a bar/quantum boundary is crossed.
NextBar,
}
impl StepTiming {
/// Returns true if the boundary was crossed between prev_beat and curr_beat.
pub fn crossed(&self, prev_beat: f64, curr_beat: f64, quantum: f64) -> bool {
if prev_beat < 0.0 {
return false;
}
match self {
Self::NextBeat => prev_beat.floor() as i64 != curr_beat.floor() as i64,
Self::NextBar => {
(prev_beat / quantum).floor() as i64 != (curr_beat / quantum).floor() as i64
}
}
}
}
/// Return the beat positions of all substeps in the window [frontier, end).
/// Each entry is the exact beat at which that substep fires.
/// Clamped to 64 results max to prevent runaway.
pub fn substeps_in_window(frontier: f64, end: f64, speed: f64) -> Vec<f64> {
if frontier < 0.0 || end <= frontier || speed <= 0.0 {
return Vec::new();
}
let substeps_per_beat = 4.0 * speed;
let first = (frontier * substeps_per_beat).floor() as i64 + 1;
let last = (end * substeps_per_beat).floor() as i64;
let count = (last - first + 1).clamp(0, 64) as usize;
let mut result = Vec::with_capacity(count);
for i in 0..count as i64 {
result.push((first + i) as f64 / substeps_per_beat);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn beats_to_micros(beats: f64, tempo: f64) -> SyncTime {
if tempo <= 0.0 {
return 0;
}
((beats / tempo) * 60_000_000.0).round() as SyncTime
}
fn substeps_crossed(prev_beat: f64, curr_beat: f64, speed: f64) -> usize {
if prev_beat < 0.0 {
return 0;
}
let prev_substep = (prev_beat * 4.0 * speed).floor() as i64;
let curr_substep = (curr_beat * 4.0 * speed).floor() as i64;
(curr_substep - prev_substep).clamp(0, 16) as usize
}
fn micros_until_next_substep(current_beat: f64, speed: f64, tempo: f64) -> SyncTime {
if tempo <= 0.0 || speed <= 0.0 {
return 0;
}
let substeps_per_beat = 4.0 * speed;
let current_substep = (current_beat * substeps_per_beat).floor();
let next_substep_beat = (current_substep + 1.0) / substeps_per_beat;
let beats_until = next_substep_beat - current_beat;
beats_to_micros(beats_until, tempo)
}
#[test]
fn test_beats_to_micros_at_120_bpm() {
// At 120 BPM, one beat = 0.5 seconds = 500,000 microseconds
assert_eq!(beats_to_micros(1.0, 120.0), 500_000);
assert_eq!(beats_to_micros(2.0, 120.0), 1_000_000);
assert_eq!(beats_to_micros(0.5, 120.0), 250_000);
}
#[test]
fn test_zero_tempo() {
assert_eq!(beats_to_micros(1.0, 0.0), 0);
}
#[test]
fn test_step_timing_beat_crossed() {
// Crossing from beat 0 to beat 1
assert!(StepTiming::NextBeat.crossed(0.9, 1.1, 4.0));
// Not crossing (both in same beat)
assert!(!StepTiming::NextBeat.crossed(0.5, 0.9, 4.0));
// Negative prev_beat returns false
assert!(!StepTiming::NextBeat.crossed(-1.0, 1.0, 4.0));
}
#[test]
fn test_step_timing_bar_crossed() {
// Crossing from bar 0 to bar 1 (quantum=4)
assert!(StepTiming::NextBar.crossed(3.9, 4.1, 4.0));
// Not crossing (both in same bar)
assert!(!StepTiming::NextBar.crossed(2.0, 3.0, 4.0));
// Crossing with different quantum
assert!(StepTiming::NextBar.crossed(7.9, 8.1, 8.0));
}
#[test]
fn test_substeps_crossed_normal() {
// One substep crossed at 1x speed
assert_eq!(substeps_crossed(0.0, 0.26, 1.0), 1);
// Two substeps crossed
assert_eq!(substeps_crossed(0.0, 0.51, 1.0), 2);
// No substep crossed
assert_eq!(substeps_crossed(0.1, 0.2, 1.0), 0);
}
#[test]
fn test_substeps_crossed_with_speed() {
// At 2x speed, 0.5 beats = 4 substeps
assert_eq!(substeps_crossed(0.0, 0.5, 2.0), 4);
// At 0.5x speed, 0.5 beats = 1 substep
assert_eq!(substeps_crossed(0.0, 0.5, 0.5), 1);
}
#[test]
fn test_substeps_crossed_negative_prev() {
// Negative prev_beat returns 0
assert_eq!(substeps_crossed(-1.0, 0.5, 1.0), 0);
}
#[test]
fn test_substeps_crossed_clamp() {
// Large jump clamped to 16
assert_eq!(substeps_crossed(0.0, 100.0, 1.0), 16);
}
#[test]
fn test_micros_until_next_substep_at_beat_zero() {
// At beat 0.0, speed 1.0, tempo 120 BPM
// Next substep is at beat 0.25 (1/4 beat)
// 1/4 beat at 120 BPM = 0.25 / 120 * 60_000_000 = 125_000 μs
let micros = micros_until_next_substep(0.0, 1.0, 120.0);
assert_eq!(micros, 125_000);
}
#[test]
fn test_micros_until_next_substep_near_boundary() {
// At beat 0.24, almost at the substep boundary (0.25)
// Next substep at 0.25, so 0.01 beats away
let micros = micros_until_next_substep(0.24, 1.0, 120.0);
// 0.01 beats at 120 BPM = 5000 μs
assert_eq!(micros, 5000);
}
#[test]
fn test_micros_until_next_substep_with_speed() {
// At 2x speed, substeps are at 0.125, 0.25, 0.375...
// At beat 0.0, next substep is at 0.125
let micros = micros_until_next_substep(0.0, 2.0, 120.0);
// 0.125 beats at 120 BPM = 62_500 μs
assert_eq!(micros, 62_500);
}
#[test]
fn test_micros_until_next_substep_zero_tempo() {
assert_eq!(micros_until_next_substep(0.0, 1.0, 0.0), 0);
}
#[test]
fn test_micros_until_next_substep_zero_speed() {
assert_eq!(micros_until_next_substep(0.0, 0.0, 120.0), 0);
}
#[test]
fn test_substeps_in_window_basic() {
// At 1x speed, substeps at 0.25, 0.5, 0.75, 1.0...
// Window [0.0, 0.5) should contain 0.25 and 0.5
let result = substeps_in_window(0.0, 0.5, 1.0);
assert_eq!(result, vec![0.25, 0.5]);
}
#[test]
fn test_substeps_in_window_2x_speed() {
// At 2x speed, substeps at 0.125, 0.25, 0.375, 0.5...
// Window [0.0, 0.5) should contain 4 substeps
let result = substeps_in_window(0.0, 0.5, 2.0);
assert_eq!(result, vec![0.125, 0.25, 0.375, 0.5]);
}
#[test]
fn test_substeps_in_window_mid_beat() {
// Window [0.3, 0.6): should contain 0.5
let result = substeps_in_window(0.3, 0.6, 1.0);
assert_eq!(result, vec![0.5]);
}
#[test]
fn test_substeps_in_window_empty() {
// Window too small to contain any substep
let result = substeps_in_window(0.1, 0.2, 1.0);
assert!(result.is_empty());
}
#[test]
fn test_substeps_in_window_negative_frontier() {
let result = substeps_in_window(-1.0, 0.5, 1.0);
assert!(result.is_empty());
}
}

243
src/init.rs Normal file
View File

@@ -0,0 +1,243 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use crossbeam_channel::Receiver;
use doux::EngineMetrics;
use crate::app::App;
use crate::engine::{
build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand,
PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer,
};
use crate::midi;
use crate::model;
use crate::settings::Settings;
use crate::state::audio::RefreshRate;
use crate::state::StagedChange;
use crate::theme;
pub struct InitArgs {
pub samples: Vec<PathBuf>,
pub output: Option<String>,
pub input: Option<String>,
pub channels: Option<u16>,
pub buffer: Option<u32>,
}
pub struct Init {
pub app: App,
pub link: Arc<LinkState>,
pub sequencer: SequencerHandle,
pub playing: Arc<AtomicBool>,
pub nudge_us: Arc<AtomicI64>,
pub metrics: Arc<EngineMetrics>,
pub scope_buffer: Arc<ScopeBuffer>,
pub spectrum_buffer: Arc<SpectrumBuffer>,
pub audio_sample_pos: Arc<AtomicU64>,
pub sample_rate_shared: Arc<AtomicU32>,
pub stream: Option<cpal::Stream>,
pub analysis_handle: Option<AnalysisHandle>,
pub midi_rx: Receiver<MidiCommand>,
#[cfg(feature = "desktop")]
pub settings: Settings,
#[cfg(feature = "desktop")]
pub mouse_x: Arc<AtomicU32>,
#[cfg(feature = "desktop")]
pub mouse_y: Arc<AtomicU32>,
#[cfg(feature = "desktop")]
pub mouse_down: Arc<AtomicU32>,
}
pub fn init(args: InitArgs) -> Init {
let settings = Settings::load();
let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum));
if settings.link.enabled {
link.enable();
}
let playing = Arc::new(AtomicBool::new(true));
let nudge_us = Arc::new(AtomicI64::new(0));
let mut app = App::new();
app.playback.queued_changes.push(StagedChange {
change: PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: model::LaunchQuantization::Immediate,
sync_mode: model::SyncMode::PhaseLock,
});
app.audio.config.output_device = args.output.or(settings.audio.output_device.clone());
app.audio.config.input_device = args.input.or(settings.audio.input_device.clone());
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
app.audio.config.max_voices = settings.audio.max_voices;
app.audio.config.sample_paths = args.samples;
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum;
app.ui.show_completion = settings.display.show_completion;
app.ui.color_scheme = settings.display.color_scheme;
app.ui.hue_rotation = settings.display.hue_rotation;
app.audio.config.layout = settings.display.layout;
let base_theme = settings.display.color_scheme.to_theme();
let rotated =
cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation);
theme::set(rotated);
// MIDI connections
let outputs = midi::list_midi_outputs();
let inputs = midi::list_midi_inputs();
for (slot, name) in settings.midi.output_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = outputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_output(slot, idx);
}
}
}
for (slot, name) in settings.midi.input_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = inputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_input(slot, idx);
}
}
}
let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let audio_sample_pos = Arc::new(AtomicU64::new(0));
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_count += index.len();
initial_samples.extend(index);
}
let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples
.iter()
.map(|e| (e.name.clone(), e.path.clone()))
.collect();
#[cfg(feature = "desktop")]
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
#[cfg(feature = "desktop")]
let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
#[cfg(feature = "desktop")]
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let seq_config = SequencerConfig {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc<dyn model::CcAccess>),
variables: Arc::clone(&app.variables),
dict: Arc::clone(&app.dict),
#[cfg(feature = "desktop")]
mouse_x: Arc::clone(&mouse_x),
#[cfg(feature = "desktop")]
mouse_y: Arc::clone(&mouse_y),
#[cfg(feature = "desktop")]
mouse_down: Arc::clone(&mouse_down),
};
let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
settings.link.quantum,
Arc::clone(&app.live_keys),
Arc::clone(&nudge_us),
seq_config,
);
let stream_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(),
channels: app.audio.config.channels,
buffer_size: app.audio.config.buffer_size,
max_voices: app.audio.config.max_voices,
};
let (stream, analysis_handle) = match build_stream(
&stream_config,
initial_audio_rx,
Arc::clone(&scope_buffer),
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
initial_samples,
Arc::clone(&audio_sample_pos),
) {
Ok((s, info, analysis, registry)) => {
app.audio.config.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels;
sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.sample_registry = Some(Arc::clone(&registry));
if !preload_entries.is_empty() {
let sr = info.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
preload_sample_heads(preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
(Some(s), Some(analysis))
}
Err(e) => {
app.ui.set_status(format!("Audio failed: {e}"));
app.audio.error = Some(e);
(None, None)
}
};
app.mark_all_patterns_dirty();
Init {
app,
link,
sequencer,
playing,
nudge_us,
metrics,
scope_buffer,
spectrum_buffer,
audio_sample_pos,
sample_rate_shared,
stream,
analysis_handle,
midi_rx,
#[cfg(feature = "desktop")]
settings,
#[cfg(feature = "desktop")]
mouse_x,
#[cfg(feature = "desktop")]
mouse_y,
#[cfg(feature = "desktop")]
mouse_down,
}
}
pub fn preload_sample_heads(
entries: Vec<(String, std::path::PathBuf)>,
target_sr: f32,
registry: &doux::SampleRegistry,
) {
let mut batch = Vec::with_capacity(entries.len());
for (name, path) in &entries {
match doux::sampling::decode_sample_head(path, target_sr) {
Ok(data) => batch.push((name.clone(), Arc::new(data))),
Err(e) => eprintln!("preload {name}: {e}"),
}
}
if !batch.is_empty() {
registry.insert_batch(batch);
}
}

View File

@@ -1,7 +1,7 @@
use arc_swap::ArcSwap;
use crossbeam_channel::Sender;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
use crate::model::PatternSpeed;
use crate::page::Page;
use crate::state::{
DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField,
SampleBrowserState, SettingKind, SidePanel,
CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal, OptionsFocus,
PanelFocus, PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
};
pub enum InputResult {
@@ -28,7 +28,6 @@ pub struct InputContext<'a> {
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
pub seq_cmd_tx: &'a Sender<SeqCommand>,
pub nudge_us: &'a Arc<AtomicI64>,
pub lookahead_ms: &'a Arc<AtomicU32>,
}
impl<'a> InputContext<'a> {
@@ -258,6 +257,8 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match mode {
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
FileBrowserMode::Load => {
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState);
ctx.dispatch(AppCommand::Load(path));
load_project_samples(ctx);
}
@@ -444,9 +445,22 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
if let Some(path) = sample_path {
let index = doux::sampling::scan_samples_dir(&path);
let count = index.len();
let preload_entries: Vec<(String, std::path::PathBuf)> = index
.iter()
.map(|e| (e.name.clone(), e.path.clone()))
.collect();
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
ctx.app.audio.config.sample_count += count;
ctx.app.audio.add_sample_path(path);
if let Some(registry) = ctx.app.audio.sample_registry.clone() {
let sr = ctx.app.audio.config.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
crate::init::preload_sample_heads(preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples")));
ctx.dispatch(AppCommand::CloseModal);
}
@@ -480,6 +494,19 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
return InputResult::Continue;
}
if editor.sample_finder_active() {
match key.code {
KeyCode::Esc => editor.dismiss_sample_finder(),
KeyCode::Tab | KeyCode::Enter => editor.accept_sample_finder(),
KeyCode::Backspace => editor.sample_finder_backspace(),
KeyCode::Char('n') if ctrl => editor.sample_finder_next(),
KeyCode::Char('p') if ctrl => editor.sample_finder_prev(),
KeyCode::Char(c) if !ctrl => editor.sample_finder_input(c),
_ => {}
}
return InputResult::Continue;
}
match key.code {
KeyCode::Esc => {
if editor.is_selecting() {
@@ -487,23 +514,53 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} else if editor.completion_active() {
editor.dismiss_completion();
} else {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
match ctx.app.editor_ctx.target {
EditorTarget::Step => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
ctx.dispatch(AppCommand::ClosePreludeEditor);
}
}
ctx.dispatch(AppCommand::CloseModal);
}
}
KeyCode::Char('e') if ctrl => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
KeyCode::Char('e') if ctrl => match ctx.app.editor_ctx.target {
EditorTarget::Step => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
}
},
KeyCode::Char('b') if ctrl => {
editor.activate_sample_finder();
}
KeyCode::Char('f') if ctrl => {
editor.activate_search();
}
KeyCode::Char('n') if ctrl => {
editor.search_next();
if editor.completion_active() {
editor.completion_next();
} else if editor.sample_finder_active() {
editor.sample_finder_next();
} else {
editor.search_next();
}
}
KeyCode::Char('p') if ctrl => {
editor.search_prev();
if editor.completion_active() {
editor.completion_prev();
} else if editor.sample_finder_active() {
editor.sample_finder_prev();
} else {
editor.search_prev();
}
}
KeyCode::Char('s') if ctrl => {
ctx.dispatch(AppCommand::ToggleEditorStack);
@@ -547,6 +604,10 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
editor.input(Event::Key(key));
}
}
if ctx.app.editor_ctx.show_stack {
crate::services::stack_preview::update_cache(&ctx.app.editor_ctx);
}
}
Modal::Preview => match key.code {
KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal),
@@ -606,7 +667,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let speed_val = *speed;
let quant_val = *quantization;
let sync_val = *sync_mode;
ctx.dispatch(AppCommand::SetPatternProps {
ctx.dispatch(AppCommand::StagePatternProps {
bank,
pattern,
name: name_val,
@@ -640,6 +701,143 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
_ => {}
}
}
Modal::EuclideanDistribution {
bank,
pattern,
source_step,
field,
pulses,
steps,
rotation,
} => {
let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step);
match key.code {
KeyCode::Up => *field = field.prev(),
KeyCode::Down | KeyCode::Tab => *field = field.next(),
KeyCode::Left => {
let target = match field {
EuclideanField::Pulses => pulses,
EuclideanField::Steps => steps,
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = val.saturating_sub(1).to_string();
}
}
KeyCode::Right => {
let target = match field {
EuclideanField::Pulses => pulses,
EuclideanField::Steps => steps,
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = (val + 1).min(128).to_string();
}
}
KeyCode::Char(c) if c.is_ascii_digit() => match field {
EuclideanField::Pulses => pulses.push(c),
EuclideanField::Steps => steps.push(c),
EuclideanField::Rotation => rotation.push(c),
},
KeyCode::Backspace => match field {
EuclideanField::Pulses => {
pulses.pop();
}
EuclideanField::Steps => {
steps.pop();
}
EuclideanField::Rotation => {
rotation.pop();
}
},
KeyCode::Enter => {
let pulses_val: usize = pulses.parse().unwrap_or(0);
let steps_val: usize = steps.parse().unwrap_or(0);
let rotation_val: usize = rotation.parse().unwrap_or(0);
if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val {
ctx.dispatch(AppCommand::ApplyEuclideanDistribution {
bank: bank_val,
pattern: pattern_val,
source_step: source_step_val,
pulses: pulses_val,
steps: steps_val,
rotation: rotation_val,
});
ctx.dispatch(AppCommand::CloseModal);
} else {
ctx.dispatch(AppCommand::SetStatus(
"Invalid: pulses must be > 0 and <= steps".to_string(),
));
}
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
_ => {}
}
}
Modal::ConfirmResetPatterns {
bank,
patterns,
selected: _,
} => {
let (bank, patterns) = (*bank, patterns.clone());
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::ResetPatterns { bank, patterns });
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmResetPatterns { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_reset =
if let Modal::ConfirmResetPatterns { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_reset {
ctx.dispatch(AppCommand::ResetPatterns { bank, patterns });
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::ConfirmResetBanks { banks, selected: _ } => {
let banks = banks.clone();
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::ResetBanks { banks });
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmResetBanks { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_reset =
if let Modal::ConfirmResetBanks { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_reset {
ctx.dispatch(AppCommand::ResetBanks { banks });
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::None => unreachable!(),
}
InputResult::Continue
@@ -736,13 +934,23 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
KeyCode::Down | KeyCode::Char('j') => state.move_down(30),
KeyCode::PageUp => {
for _ in 0..20 {
state.move_up();
}
}
KeyCode::PageDown => {
for _ in 0..20 {
state.move_down(30);
}
}
KeyCode::Enter | KeyCode::Right => {
if let Some(entry) = state.current_entry() {
match entry.kind {
TreeLineKind::File => {
let folder = &entry.folder;
let idx = entry.index;
let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/dur/1");
let _ = ctx
.audio_tx
.load()
@@ -754,7 +962,14 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
KeyCode::Left => state.collapse_at_cursor(),
KeyCode::Char('/') => state.activate_search(),
KeyCode::Esc | KeyCode::Tab => {
KeyCode::Esc => {
if state.has_filter() {
state.clear_filter();
} else {
ctx.dispatch(AppCommand::ClosePanel);
}
}
KeyCode::Tab => {
ctx.dispatch(AppCommand::ClosePanel);
}
_ => {}
@@ -848,7 +1063,7 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
.map(|p| p.display().to_string())
.unwrap_or_default();
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state)));
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('c') if ctrl => {
ctx.dispatch(AppCommand::CopySteps);
@@ -880,7 +1095,7 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
})
.unwrap_or_default();
let state = FileBrowserState::new_load(default_dir);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state)));
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
@@ -955,9 +1170,53 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
name: current_name,
}));
}
KeyCode::Char('o') => {
ctx.app.audio.config.layout = ctx.app.audio.config.layout.next();
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char('e') | KeyCode::Char('E') => {
let (bank, pattern, step) = (
ctx.app.editor_ctx.bank,
ctx.app.editor_ctx.pattern,
ctx.app.editor_ctx.step,
);
let pattern_len = ctx.app.current_edit_pattern().length;
let default_steps = pattern_len.min(32);
let default_pulses = (default_steps / 2).max(1).min(default_steps);
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
bank,
pattern,
source_step: step,
field: EuclideanField::Pulses,
pulses: default_pulses.to_string(),
steps: default_steps.to_string(),
rotation: "0".to_string(),
}));
}
KeyCode::Char('m') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
KeyCode::Char('x') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('X') => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('d') => {
ctx.dispatch(AppCommand::OpenPreludeEditor);
}
KeyCode::Char('D') => {
ctx.dispatch(AppCommand::EvaluatePrelude);
}
_ => {}
}
InputResult::Continue
@@ -967,26 +1226,87 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::PatternsColumn;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Up if shift => {
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
if ctx.app.patterns_nav.bank_anchor.is_none() {
ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor);
}
}
PatternsColumn::Patterns => {
if ctx.app.patterns_nav.pattern_anchor.is_none() {
ctx.app.patterns_nav.pattern_anchor =
Some(ctx.app.patterns_nav.pattern_cursor);
}
}
}
ctx.app.patterns_nav.move_up_clamped();
}
KeyCode::Down if shift => {
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
if ctx.app.patterns_nav.bank_anchor.is_none() {
ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor);
}
}
PatternsColumn::Patterns => {
if ctx.app.patterns_nav.pattern_anchor.is_none() {
ctx.app.patterns_nav.pattern_anchor =
Some(ctx.app.patterns_nav.pattern_cursor);
}
}
}
ctx.app.patterns_nav.move_down_clamped();
}
KeyCode::Up => {
ctx.app.patterns_nav.clear_selection();
ctx.dispatch(AppCommand::PatternsCursorUp);
}
KeyCode::Down => {
ctx.app.patterns_nav.clear_selection();
ctx.dispatch(AppCommand::PatternsCursorDown);
}
KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc => {
if !ctx.app.playback.staged_changes.is_empty() {
if ctx.app.patterns_nav.has_selection() {
ctx.app.patterns_nav.clear_selection();
} else if !ctx.app.playback.staged_changes.is_empty()
|| !ctx.app.playback.staged_mute_changes.is_empty()
|| !ctx.app.playback.staged_prop_changes.is_empty()
{
ctx.dispatch(AppCommand::ClearStagedChanges);
} else {
ctx.dispatch(AppCommand::PatternsBack);
}
}
KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter),
KeyCode::Char(' ') => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns {
ctx.dispatch(AppCommand::PatternsTogglePlay);
KeyCode::Enter => {
if !ctx.app.patterns_nav.has_selection() {
ctx.dispatch(AppCommand::PatternsEnter);
}
}
KeyCode::Char('p') => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns {
let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.app.stage_pattern_toggle(bank, pattern, ctx.snapshot);
}
}
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
KeyCode::Char('c') if !ctrl => {
let mute_changed = ctx.app.commit_staged_changes();
if mute_changed {
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
}
KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges),
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
selected: false,
@@ -996,11 +1316,21 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::CopyBank { bank });
let banks = ctx.app.patterns_nav.selected_banks();
if banks.len() > 1 {
ctx.dispatch(AppCommand::CopyBanks { banks });
} else {
ctx.dispatch(AppCommand::CopyBank { bank });
}
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::CopyPattern { bank, pattern });
let patterns = ctx.app.patterns_nav.selected_patterns();
if patterns.len() > 1 {
ctx.dispatch(AppCommand::CopyPatterns { bank, patterns });
} else {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::CopyPattern { bank, pattern });
}
}
}
}
@@ -1008,11 +1338,27 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::PasteBank { bank });
if ctx.app.copied_banks.as_ref().is_some_and(|v| v.len() > 1) {
ctx.dispatch(AppCommand::PasteBanks { start: bank });
} else {
ctx.dispatch(AppCommand::PasteBank { bank });
}
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::PastePattern { bank, pattern });
if ctx
.app
.copied_patterns
.as_ref()
.is_some_and(|v| v.len() > 1)
{
ctx.dispatch(AppCommand::PastePatterns {
bank,
start: pattern,
});
} else {
ctx.dispatch(AppCommand::PastePattern { bank, pattern });
}
}
}
}
@@ -1020,55 +1366,97 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank {
bank,
selected: false,
}));
let banks = ctx.app.patterns_nav.selected_banks();
if banks.len() > 1 {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBanks {
banks,
selected: false,
}));
} else {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank {
bank,
selected: false,
}));
}
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern {
bank,
pattern,
selected: false,
}));
let patterns = ctx.app.patterns_nav.selected_patterns();
if patterns.len() > 1 {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPatterns {
bank,
patterns,
selected: false,
}));
} else {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern {
bank,
pattern,
selected: false,
}));
}
}
}
}
KeyCode::Char('r') => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
let current_name = ctx.app.project_state.project.banks[bank]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank {
bank,
name: current_name,
}));
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern {
bank,
pattern,
name: current_name,
}));
if !ctx.app.patterns_nav.has_selection() {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
let current_name = ctx.app.project_state.project.banks[bank]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank {
bank,
name: current_name,
}));
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
let current_name = ctx.app.project_state.project.banks[bank].patterns
[pattern]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern {
bank,
pattern,
name: current_name,
}));
}
}
}
}
KeyCode::Char('e') if !ctrl => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns
&& !ctx.app.patterns_nav.has_selection()
{
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
}
}
KeyCode::Char('m') => {
let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
}
KeyCode::Char('x') => {
let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('X') => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
@@ -1179,14 +1567,6 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.nudge_us
.store((prev - 1000).max(-100_000), Ordering::Relaxed);
}
SettingKind::Lookahead => {
ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Lookahead,
delta: -1,
});
ctx.lookahead_ms
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
}
}
ctx.app.save_settings(ctx.link);
}
@@ -1215,14 +1595,6 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.nudge_us
.store((prev + 1000).min(100_000), Ordering::Relaxed);
}
SettingKind::Lookahead => {
ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Lookahead,
delta: 1,
});
ctx.lookahead_ms
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
}
}
ctx.app.save_settings(ctx.link);
}
@@ -1232,7 +1604,7 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('A') => {
use crate::state::file_browser::FileBrowserState;
let state = FileBrowserState::new_load(String::new());
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(state)));
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
}
KeyCode::Char('D') => {
if ctx.app.audio.section == EngineSection::Samples {
@@ -1264,6 +1636,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
_ => {}
}
InputResult::Continue
@@ -1288,6 +1665,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
};
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
}
OptionsFocus::HueRotation => {
let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 };
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
}
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
OptionsFocus::RuntimeHighlight => {
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
@@ -1301,10 +1683,6 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
OptionsFocus::ShowCompletion => {
ctx.dispatch(AppCommand::ToggleCompletion);
}
OptionsFocus::FlashBrightness => {
let delta = if key.code == KeyCode::Left { -0.1 } else { 0.1 };
ctx.dispatch(AppCommand::AdjustFlashBrightness(delta));
}
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
OptionsFocus::StartStopSync => ctx
.link
@@ -1487,6 +1865,11 @@ fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
_ => {}
}
InputResult::Continue
@@ -1535,6 +1918,11 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
_ => {}
}
InputResult::Continue
@@ -1547,11 +1935,15 @@ fn load_project_samples(ctx: &mut InputContext) {
}
let mut total_count = 0;
let mut all_preload_entries = Vec::new();
for path in &paths {
if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path);
let count = index.len();
total_count += count;
for e in &index {
all_preload_entries.push((e.name.clone(), e.path.clone()));
}
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
}
}
@@ -1560,6 +1952,15 @@ fn load_project_samples(ctx: &mut InputContext) {
ctx.app.audio.config.sample_count = total_count;
if total_count > 0 {
if let Some(registry) = ctx.app.audio.sample_registry.clone() {
let sr = ctx.app.audio.config.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
crate::init::preload_sample_heads(all_preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
ctx.dispatch(AppCommand::SetStatus(format!(
"Loaded {total_count} samples from project"
)));

View File

@@ -25,7 +25,8 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
}
let mods = convert_modifiers(*modifiers);
// For character keys without ctrl/alt, let Event::Text handle it
if is_character_key(*key) && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
if is_character_key(*key) && !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return None;
}
let code = convert_key(*key)?;
@@ -40,6 +41,12 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
}
None
}
// egui intercepts Ctrl+C/V/X and converts them to these high-level events
// instead of passing through raw Key events (see egui issue #4065).
// Synthesize the equivalent KeyEvent so the application's input handler receives them.
egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
_ => None,
}
}

View File

@@ -1,6 +1,7 @@
pub use cagire_forth as forth;
pub mod app;
pub mod init;
pub mod commands;
pub mod engine;
pub mod input;

View File

@@ -1,6 +1,7 @@
mod app;
mod commands;
mod engine;
mod init;
mod input;
mod midi;
mod model;
@@ -14,9 +15,9 @@ mod widgets;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use clap::Parser;
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
@@ -24,18 +25,12 @@ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use doux::EngineMetrics;
use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal;
use app::App;
use engine::{
build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SequencerConfig,
SpectrumBuffer,
};
use engine::{build_stream, AudioStreamConfig};
use init::InitArgs;
use input::{handle_key, InputContext, InputResult};
use settings::Settings;
use state::audio::RefreshRate;
#[derive(Parser)]
#[command(name = "cagire", version, about = "Forth-based live coding sequencer")]
@@ -62,139 +57,32 @@ struct Args {
}
fn main() -> io::Result<()> {
#[cfg(unix)]
engine::realtime::lock_memory();
let args = Args::parse();
let settings = Settings::load();
let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum));
if settings.link.enabled {
link.enable();
}
let b = init::init(InitArgs {
samples: args.samples,
output: args.output,
input: args.input,
channels: args.channels,
buffer: args.buffer,
});
let playing = Arc::new(AtomicBool::new(true));
let nudge_us = Arc::new(AtomicI64::new(0));
let mut app = App::new();
app.playback
.queued_changes
.push(crate::state::StagedChange {
change: engine::PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: crate::model::LaunchQuantization::Immediate,
sync_mode: crate::model::SyncMode::Reset,
});
app.audio.config.output_device = args.output.or(settings.audio.output_device);
app.audio.config.input_device = args.input.or(settings.audio.input_device);
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
app.audio.config.max_voices = settings.audio.max_voices;
app.audio.config.lookahead_ms = settings.audio.lookahead_ms;
app.audio.config.sample_paths = args.samples;
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum;
app.ui.show_completion = settings.display.show_completion;
app.ui.flash_brightness = settings.display.flash_brightness;
app.ui.color_scheme = settings.display.color_scheme;
theme::set(settings.display.color_scheme.to_theme());
// Load MIDI settings
let outputs = midi::list_midi_outputs();
let inputs = midi::list_midi_inputs();
for (slot, name) in settings.midi.output_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = outputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_output(slot, idx);
}
}
}
for (slot, name) in settings.midi.input_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = inputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_input(slot, idx);
}
}
}
let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let audio_sample_pos = Arc::new(AtomicU64::new(0));
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let lookahead_ms = Arc::new(AtomicU32::new(settings.audio.lookahead_ms));
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_count += index.len();
initial_samples.extend(index);
}
#[cfg(feature = "desktop")]
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
#[cfg(feature = "desktop")]
let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
#[cfg(feature = "desktop")]
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let seq_config = SequencerConfig {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
lookahead_ms: Arc::clone(&lookahead_ms),
cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc<dyn crate::model::CcAccess>),
#[cfg(feature = "desktop")]
mouse_x: Arc::clone(&mouse_x),
#[cfg(feature = "desktop")]
mouse_y: Arc::clone(&mouse_y),
#[cfg(feature = "desktop")]
mouse_down: Arc::clone(&mouse_down),
};
let (sequencer, initial_audio_rx, mut midi_rx) = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&app.variables),
Arc::clone(&app.dict),
Arc::clone(&app.rng),
settings.link.quantum,
Arc::clone(&app.live_keys),
Arc::clone(&nudge_us),
seq_config,
);
let stream_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(),
channels: app.audio.config.channels,
buffer_size: app.audio.config.buffer_size,
max_voices: app.audio.config.max_voices,
};
let (mut _stream, mut _analysis_handle) = match build_stream(
&stream_config,
initial_audio_rx,
Arc::clone(&scope_buffer),
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
initial_samples,
Arc::clone(&audio_sample_pos),
) {
Ok((s, sample_rate, analysis)) => {
app.audio.config.sample_rate = sample_rate;
sample_rate_shared.store(sample_rate as u32, Ordering::Relaxed);
(Some(s), Some(analysis))
}
Err(e) => {
app.ui.set_status(format!("Audio failed: {e}"));
app.audio.error = Some(e);
(None, None)
}
};
app.mark_all_patterns_dirty();
let mut app = b.app;
let link = b.link;
let sequencer = b.sequencer;
let playing = b.playing;
let nudge_us = b.nudge_us;
let metrics = b.metrics;
let scope_buffer = b.scope_buffer;
let spectrum_buffer = b.spectrum_buffer;
let audio_sample_pos = b.audio_sample_pos;
let sample_rate_shared = b.sample_rate_shared;
let mut _stream = b.stream;
let mut _analysis_handle = b.analysis_handle;
let mut midi_rx = b.midi_rx;
enable_raw_mode()?;
io::stdout().execute(EnableBracketedPaste)?;
@@ -203,6 +91,8 @@ fn main() -> io::Result<()> {
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut last_frame = Instant::now();
loop {
if app.audio.restart_pending {
app.audio.restart_pending = false;
@@ -228,6 +118,11 @@ fn main() -> io::Result<()> {
audio_sample_pos.store(0, Ordering::Relaxed);
let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples
.iter()
.map(|e| (e.name.clone(), e.path.clone()))
.collect();
match build_stream(
&new_config,
new_audio_rx,
@@ -237,13 +132,26 @@ fn main() -> io::Result<()> {
restart_samples,
Arc::clone(&audio_sample_pos),
) {
Ok((new_stream, sr, new_analysis)) => {
Ok((new_stream, info, new_analysis, registry)) => {
_stream = Some(new_stream);
_analysis_handle = Some(new_analysis);
app.audio.config.sample_rate = sr;
sample_rate_shared.store(sr as u32, Ordering::Relaxed);
app.audio.config.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels;
sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.error = None;
app.audio.sample_registry = Some(Arc::clone(&registry));
app.ui.set_status("Audio restarted".to_string());
if !preload_entries.is_empty() {
let sr = info.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
init::preload_sample_heads(preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
}
Err(e) => {
app.audio.error = Some(e.clone());
@@ -254,7 +162,6 @@ fn main() -> io::Result<()> {
app.playback.playing = playing.load(Ordering::Relaxed);
// Process pending MIDI commands
while let Ok(midi_cmd) = midi_rx.try_recv() {
match midi_cmd {
engine::MidiCommand::NoteOn {
@@ -321,29 +228,15 @@ fn main() -> io::Result<()> {
let seq_snapshot = sequencer.snapshot();
app.metrics.event_count = seq_snapshot.event_count;
app.metrics.dropped_events = seq_snapshot.dropped_events;
app.ui.event_flash = (app.ui.event_flash - 0.1).max(0.0);
let new_events = app
.metrics
.event_count
.saturating_sub(app.ui.last_event_count);
if new_events > 0 {
app.ui.event_flash = (new_events as f32 * 0.4).min(1.0);
}
app.ui.last_event_count = app.metrics.event_count;
app.flush_queued_changes(&sequencer.cmd_tx);
app.flush_dirty_patterns(&sequencer.cmd_tx);
app.flush_queued_changes(&sequencer.cmd_tx);
if app.ui.show_title {
app.ui.sparkles.tick(terminal.get_frame().area());
}
terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot))?;
if event::poll(Duration::from_millis(
let had_event = event::poll(Duration::from_millis(
app.audio.config.refresh_rate.millis(),
))? {
))?;
if had_event {
match event::read()? {
Event::Key(key) => {
let mut ctx = InputContext {
@@ -354,7 +247,6 @@ fn main() -> io::Result<()> {
audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &nudge_us,
lookahead_ms: &lookahead_ms,
};
if let InputResult::Quit = handle_key(&mut ctx, key) {
@@ -364,11 +256,29 @@ fn main() -> io::Result<()> {
Event::Paste(text) => {
if matches!(app.ui.modal, state::Modal::Editor) {
app.editor_ctx.editor.insert_str(&text);
if app.editor_ctx.show_stack {
services::stack_preview::update_cache(&app.editor_ctx);
}
}
}
_ => {}
}
}
state::effects::tick_effects(&mut app.ui, app.page);
let elapsed = last_frame.elapsed();
last_frame = Instant::now();
let effects_active = app.ui.effects.borrow().is_running()
|| app.ui.modal_fx.borrow().is_some()
|| app.ui.title_fx.borrow().is_some();
if app.playback.playing || had_event || app.ui.show_title || effects_active {
if app.ui.show_title {
app.ui.sparkles.tick(terminal.get_frame().area());
}
terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot, elapsed))?;
}
}
disable_raw_mode()?;

View File

@@ -1,8 +1,9 @@
use std::sync::{Arc, Mutex};
use parking_lot::Mutex;
use std::sync::Arc;
use midir::{MidiInput, MidiOutput};
use cagire_forth::CcAccess;
use crate::model::CcAccess;
pub const MAX_MIDI_OUTPUTS: usize = 4;
pub const MAX_MIDI_INPUTS: usize = 4;
@@ -28,9 +29,8 @@ impl CcMemory {
/// Set a CC value (for testing)
#[allow(dead_code)]
pub fn set_cc(&self, device: usize, channel: usize, cc: usize, value: u8) {
if let Ok(mut mem) = self.0.lock() {
mem[device.min(MAX_MIDI_DEVICES - 1)][channel.min(15)][cc.min(127)] = value;
}
let mut mem = self.0.lock();
mem[device.min(MAX_MIDI_DEVICES - 1)][channel.min(15)][cc.min(127)] = value;
}
}
@@ -42,11 +42,8 @@ impl Default for CcMemory {
impl CcAccess for CcMemory {
fn get_cc(&self, device: usize, channel: usize, cc: usize) -> u8 {
self.0
.lock()
.ok()
.map(|mem| mem[device.min(MAX_MIDI_DEVICES - 1)][channel.min(15)][cc.min(127)])
.unwrap_or(0)
let mem = self.0.lock();
mem[device.min(MAX_MIDI_DEVICES - 1)][channel.min(15)][cc.min(127)]
}
}
@@ -154,9 +151,8 @@ impl MidiState {
let data2 = message[2];
if (status & 0xF0) == 0xB0 && data1 < 128 {
let channel = (status & 0x0F) as usize;
if let Ok(mut mem) = cc_mem.lock() {
mem[*slot][channel][data1] = data2;
}
let mut mem = cc_mem.lock();
mem[*slot][channel][data1] = data2;
}
}
},

65
src/model/categories.rs Normal file
View File

@@ -0,0 +1,65 @@
pub enum CatEntry {
Section(&'static str),
Category(&'static str),
}
use CatEntry::{Category, Section};
pub const CATEGORIES: &[CatEntry] = &[
// Forth core
Section("Forth"),
Category("Stack"),
Category("Arithmetic"),
Category("Comparison"),
Category("Logic"),
Category("Control"),
Category("Variables"),
Category("Probability"),
Category("Definitions"),
// Live coding
Section("Live Coding"),
Category("Sound"),
Category("Time"),
Category("Context"),
Category("Music"),
Category("LFO"),
// Synthesis
Section("Synthesis"),
Category("Oscillator"),
Category("Wavetable"),
Category("Generator"),
Category("Envelope"),
Category("Sample"),
// Effects
Section("Effects"),
Category("Filter"),
Category("FM"),
Category("Modulation"),
Category("Mod FX"),
Category("Lo-fi"),
Category("Stereo"),
Category("Delay"),
Category("Reverb"),
// External I/O
Section("I/O"),
Category("MIDI"),
Category("Desktop"),
];
pub fn category_count() -> usize {
CATEGORIES
.iter()
.filter(|e| matches!(e, Category(_)))
.count()
}
pub fn get_category_name(index: usize) -> &'static str {
CATEGORIES
.iter()
.filter_map(|e| match e {
Category(name) => Some(*name),
Section(_) => None,
})
.nth(index)
.unwrap_or("Unknown")
}

91
src/model/docs.rs Normal file
View File

@@ -0,0 +1,91 @@
pub enum DocEntry {
Section(&'static str),
Topic(&'static str, &'static str),
}
use DocEntry::{Section, Topic};
pub const DOCS: &[DocEntry] = &[
// Getting Started
Section("Getting Started"),
Topic("Welcome", include_str!("../../docs/welcome.md")),
Topic("Moving Around", include_str!("../../docs/navigation.md")),
Topic(
"How Does It Work?",
include_str!("../../docs/how_it_works.md"),
),
Topic(
"Banks & Patterns",
include_str!("../../docs/banks_patterns.md"),
),
Topic("Stage / Commit", include_str!("../../docs/staging.md")),
Topic("Using the Sequencer", include_str!("../../docs/grid.md")),
Topic("Editing a Step", include_str!("../../docs/editing.md")),
// Forth fundamentals
Section("Forth"),
Topic("About Forth", include_str!("../../docs/about_forth.md")),
Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
Topic("The Stack", include_str!("../../docs/stack.md")),
Topic("Creating Words", include_str!("../../docs/definitions.md")),
Topic("The Prelude", include_str!("../../docs/prelude.md")),
Topic("Oddities", include_str!("../../docs/oddities.md")),
// Audio Engine
Section("Audio Engine"),
Topic("Introduction", include_str!("../../docs/engine_intro.md")),
Topic("Settings", include_str!("../../docs/engine_settings.md")),
Topic("Sources", include_str!("../../docs/engine_sources.md")),
Topic("Samples", include_str!("../../docs/engine_samples.md")),
Topic("Wavetables", include_str!("../../docs/engine_wavetable.md")),
Topic("Filters", include_str!("../../docs/engine_filters.md")),
Topic(
"Modulation",
include_str!("../../docs/engine_modulation.md"),
),
Topic(
"Distortion",
include_str!("../../docs/engine_distortion.md"),
),
Topic("Space & Time", include_str!("../../docs/engine_space.md")),
Topic(
"Audio-Rate Mod",
include_str!("../../docs/engine_audio_modulation.md"),
),
Topic("Words & Sounds", include_str!("../../docs/engine_words.md")),
// MIDI
Section("MIDI"),
Topic("Introduction", include_str!("../../docs/midi_intro.md")),
Topic("MIDI Output", include_str!("../../docs/midi_output.md")),
Topic("MIDI Input", include_str!("../../docs/midi_input.md")),
];
pub fn topic_count() -> usize {
DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count()
}
pub fn get_topic(index: usize) -> Option<(&'static str, &'static str)> {
DOCS.iter()
.filter_map(|e| match e {
Topic(name, content) => Some((*name, *content)),
Section(_) => None,
})
.nth(index)
}
pub fn find_match(query: &str) -> Option<(usize, usize)> {
let query = query.to_lowercase();
for (topic_idx, (_, content)) in DOCS
.iter()
.filter_map(|e| match e {
Topic(name, content) => Some((*name, *content)),
Section(_) => None,
})
.enumerate()
{
for (line_idx, line) in content.lines().enumerate() {
if line.to_lowercase().contains(&query) {
return Some((topic_idx, line_idx));
}
}
}
None
}

View File

@@ -1,11 +1,13 @@
pub mod categories;
pub mod docs;
mod script;
pub use cagire_forth::{Word, WordCompile, WORDS};
pub use cagire_forth::{
lookup_word, CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value,
Variables, Word, WordCompile, WORDS,
};
pub use cagire_project::{
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS,
MAX_PATTERNS,
};
pub use script::{
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value,
Variables,
};
pub use script::ScriptEngine;

View File

@@ -1,8 +1,4 @@
use cagire_forth::Forth;
pub use cagire_forth::{
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
};
use cagire_forth::{Dictionary, ExecutionTrace, Forth, Rng, StepContext, Value, Variables};
pub struct ScriptEngine {
forth: Forth,
@@ -27,4 +23,8 @@ impl ScriptEngine {
) -> Result<Vec<String>, String> {
self.forth.evaluate_with_trace(script, ctx, trace)
}
pub fn stack(&self) -> Vec<Value> {
self.forth.stack()
}
}

265
src/services/clipboard.rs Normal file
View File

@@ -0,0 +1,265 @@
use crate::model::{Bank, Pattern, Project};
use crate::state::{CopiedStepData, CopiedSteps};
fn annotate_copy_name(name: &Option<String>) -> Option<String> {
match name {
Some(n) if !n.ends_with(" (copy)") => Some(format!("{n} (copy)")),
Some(n) => Some(n.clone()),
None => Some("(copy)".to_string()),
}
}
pub fn copy_pattern(project: &Project, bank: usize, pattern: usize) -> Pattern {
project.banks[bank].patterns[pattern].clone()
}
pub fn paste_pattern(
project: &mut Project,
bank: usize,
pattern: usize,
source: &Pattern,
) {
let mut pat = source.clone();
pat.name = annotate_copy_name(&source.name);
project.banks[bank].patterns[pattern] = pat;
}
pub fn copy_patterns(project: &Project, bank: usize, indices: &[usize]) -> Vec<Pattern> {
indices
.iter()
.map(|&i| project.banks[bank].patterns[i].clone())
.collect()
}
pub fn paste_patterns(
project: &mut Project,
bank: usize,
start: usize,
sources: &[Pattern],
) -> usize {
let mut count = 0;
for (i, src) in sources.iter().enumerate() {
let target = start + i;
if target >= crate::model::MAX_PATTERNS {
break;
}
let mut pat = src.clone();
pat.name = annotate_copy_name(&src.name);
project.banks[bank].patterns[target] = pat;
count += 1;
}
count
}
pub fn copy_bank(project: &Project, bank: usize) -> Bank {
project.banks[bank].clone()
}
pub fn paste_bank(project: &mut Project, bank: usize, source: &Bank) -> usize {
let mut b = source.clone();
b.name = annotate_copy_name(&source.name);
project.banks[bank] = b;
project.banks[bank].patterns.len()
}
pub fn copy_banks(project: &Project, indices: &[usize]) -> Vec<Bank> {
indices
.iter()
.map(|&i| project.banks[i].clone())
.collect()
}
pub fn paste_banks(project: &mut Project, start: usize, sources: &[Bank]) -> usize {
let mut count = 0;
for (i, src) in sources.iter().enumerate() {
let target = start + i;
if target >= crate::model::MAX_BANKS {
break;
}
let mut b = src.clone();
b.name = annotate_copy_name(&src.name);
project.banks[target] = b;
count += 1;
}
count
}
pub fn copy_steps(
project: &Project,
bank: usize,
pattern: usize,
indices: &[usize],
) -> (CopiedSteps, Vec<String>) {
let pat = project.pattern_at(bank, pattern);
let mut steps = Vec::new();
let mut scripts = Vec::new();
for &idx in indices {
if let Some(step) = pat.step(idx) {
let resolved = pat.resolve_script(idx).unwrap_or("").to_string();
scripts.push(resolved.clone());
steps.push(CopiedStepData {
script: resolved,
active: step.active,
source: step.source,
original_index: idx,
name: step.name.clone(),
});
}
}
let copied = CopiedSteps {
bank,
pattern,
steps,
};
(copied, scripts)
}
pub struct PasteResult {
pub count: usize,
pub compile_targets: Vec<usize>,
}
pub fn paste_steps(
project: &mut Project,
bank: usize,
pattern: usize,
cursor: usize,
copied: &CopiedSteps,
) -> PasteResult {
let pat_len = project.pattern_at(bank, pattern).length;
let same_pattern = copied.bank == bank && copied.pattern == pattern;
let mut compile_targets = Vec::new();
for (i, data) in copied.steps.iter().enumerate() {
let target = cursor + i;
if target >= pat_len {
break;
}
if let Some(step) = project.pattern_at_mut(bank, pattern).step_mut(target) {
let source = if same_pattern { data.source } else { None };
step.active = data.active;
step.source = source;
step.name = data.name.clone();
if source.is_some() {
step.script.clear();
} else {
step.script = data.script.clone();
}
}
compile_targets.push(target);
}
PasteResult {
count: copied.steps.len(),
compile_targets,
}
}
pub fn link_paste_steps(
project: &mut Project,
bank: usize,
pattern: usize,
cursor: usize,
copied: &CopiedSteps,
) -> Option<usize> {
if copied.bank != bank || copied.pattern != pattern {
return None;
}
let pat_len = project.pattern_at(bank, pattern).length;
for (i, data) in copied.steps.iter().enumerate() {
let target = cursor + i;
if target >= pat_len {
break;
}
let source_idx = if data.source.is_some() {
data.source
} else {
Some(data.original_index as u8)
};
if source_idx == Some(target as u8) {
continue;
}
if let Some(step) = project.pattern_at_mut(bank, pattern).step_mut(target) {
step.source = source_idx;
step.script.clear();
}
}
Some(copied.steps.len())
}
pub fn harden_steps(
project: &mut Project,
bank: usize,
pattern: usize,
indices: &[usize],
) -> usize {
let pat = project.pattern_at(bank, pattern);
let resolutions: Vec<(usize, String)> = indices
.iter()
.filter_map(|&idx| {
let step = pat.step(idx)?;
step.source?;
let script = pat.resolve_script(idx)?.to_string();
Some((idx, script))
})
.collect();
let count = resolutions.len();
for (idx, script) in resolutions {
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(idx) {
s.source = None;
s.script = script;
}
}
count
}
pub fn duplicate_steps(
project: &mut Project,
bank: usize,
pattern: usize,
indices: &[usize],
) -> PasteResult {
let pat = project.pattern_at(bank, pattern);
let pat_len = pat.length;
let paste_at = *indices.last().unwrap() + 1;
let dupe_data: Vec<(bool, String, Option<u8>)> = indices
.iter()
.filter_map(|&idx| {
let step = pat.step(idx)?;
let script = pat.resolve_script(idx).unwrap_or("").to_string();
let source = step.source;
Some((step.active, script, source))
})
.collect();
let mut compile_targets = Vec::new();
for (i, (active, script, source)) in dupe_data.into_iter().enumerate() {
let target = paste_at + i;
if target >= pat_len {
break;
}
if let Some(step) = project.pattern_at_mut(bank, pattern).step_mut(target) {
step.active = active;
step.source = source;
if source.is_some() {
step.script.clear();
} else {
step.script = script;
}
}
compile_targets.push(target);
}
PasteResult {
count: indices.len(),
compile_targets,
}
}

54
src/services/dict_nav.rs Normal file
View File

@@ -0,0 +1,54 @@
use crate::model::categories;
use crate::state::{DictFocus, UiState};
pub fn toggle_focus(ui: &mut UiState) {
ui.dict_focus = match ui.dict_focus {
DictFocus::Categories => DictFocus::Words,
DictFocus::Words => DictFocus::Categories,
};
}
pub fn next_category(ui: &mut UiState) {
let count = categories::category_count();
ui.dict_category = (ui.dict_category + 1) % count;
}
pub fn prev_category(ui: &mut UiState) {
let count = categories::category_count();
ui.dict_category = (ui.dict_category + count - 1) % count;
}
pub fn scroll_down(ui: &mut UiState, n: usize) {
let s = ui.dict_scroll_mut();
*s = s.saturating_add(n);
}
pub fn scroll_up(ui: &mut UiState, n: usize) {
let s = ui.dict_scroll_mut();
*s = s.saturating_sub(n);
}
pub fn activate_search(ui: &mut UiState) {
ui.dict_search_active = true;
ui.dict_focus = DictFocus::Words;
}
pub fn clear_search(ui: &mut UiState) {
ui.dict_search_query.clear();
ui.dict_search_active = false;
*ui.dict_scroll_mut() = 0;
}
pub fn search_input(ui: &mut UiState, c: char) {
ui.dict_search_query.push(c);
*ui.dict_scroll_mut() = 0;
}
pub fn search_backspace(ui: &mut UiState) {
ui.dict_search_query.pop();
*ui.dict_scroll_mut() = 0;
}
pub fn search_confirm(ui: &mut UiState) {
ui.dict_search_active = false;
}

55
src/services/euclidean.rs Normal file
View File

@@ -0,0 +1,55 @@
use crate::model::Project;
pub fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec<bool> {
if pulses == 0 || steps == 0 || pulses > steps {
return vec![false; steps];
}
let mut pattern = vec![false; steps];
for i in 0..pulses {
let pos = (i * steps) / pulses;
pattern[pos] = true;
}
if rotation > 0 {
pattern.rotate_left(rotation % steps);
}
pattern
}
/// Applies euclidean distribution as linked steps from a source step.
/// Returns the indices of steps that were created (for compilation).
pub fn apply_distribution(
project: &mut Project,
bank: usize,
pattern: usize,
source_step: usize,
pulses: usize,
steps: usize,
rotation: usize,
) -> Vec<usize> {
let pat_len = project.pattern_at(bank, pattern).length;
let rhythm = euclidean_rhythm(pulses, steps, rotation);
let mut targets = Vec::new();
for (i, &is_hit) in rhythm.iter().enumerate() {
if !is_hit || i == 0 {
continue;
}
let target = (source_step + i) % pat_len;
if target == source_step {
continue;
}
if let Some(step) = project.pattern_at_mut(bank, pattern).step_mut(target) {
step.source = Some(source_step as u8);
step.script.clear();
step.active = true;
}
targets.push(target);
}
targets
}

61
src/services/help_nav.rs Normal file
View File

@@ -0,0 +1,61 @@
use crate::model::docs;
use crate::state::{HelpFocus, UiState};
pub fn toggle_focus(ui: &mut UiState) {
ui.help_focus = match ui.help_focus {
HelpFocus::Topics => HelpFocus::Content,
HelpFocus::Content => HelpFocus::Topics,
};
}
pub fn next_topic(ui: &mut UiState, n: usize) {
let count = docs::topic_count();
ui.help_topic = (ui.help_topic + n) % count;
}
pub fn prev_topic(ui: &mut UiState, n: usize) {
let count = docs::topic_count();
ui.help_topic = (ui.help_topic + count - (n % count)) % count;
}
pub fn scroll_down(ui: &mut UiState, n: usize) {
let s = ui.help_scroll_mut();
*s = s.saturating_add(n);
}
pub fn scroll_up(ui: &mut UiState, n: usize) {
let s = ui.help_scroll_mut();
*s = s.saturating_sub(n);
}
pub fn activate_search(ui: &mut UiState) {
ui.help_search_active = true;
}
pub fn clear_search(ui: &mut UiState) {
ui.help_search_query.clear();
ui.help_search_active = false;
}
pub fn search_input(ui: &mut UiState, c: char) {
ui.help_search_query.push(c);
if let Some((topic, line)) = docs::find_match(&ui.help_search_query) {
ui.help_topic = topic;
ui.help_scrolls[topic] = line;
}
}
pub fn search_backspace(ui: &mut UiState) {
ui.help_search_query.pop();
if ui.help_search_query.is_empty() {
return;
}
if let Some((topic, line)) = docs::find_match(&ui.help_search_query) {
ui.help_topic = topic;
ui.help_scrolls[topic] = line;
}
}
pub fn search_confirm(ui: &mut UiState) {
ui.help_search_active = false;
}

View File

@@ -1 +1,6 @@
pub mod clipboard;
pub mod dict_nav;
pub mod euclidean;
pub mod help_nav;
pub mod pattern_editor;
pub mod stack_preview;

View File

@@ -1,4 +1,4 @@
use crate::model::{PatternSpeed, Project};
use crate::model::{Bank, Pattern, PatternSpeed, Project};
#[derive(Debug, Clone, Copy)]
pub struct PatternEdit {
@@ -90,3 +90,36 @@ pub fn get_step_script(
.step(step)
.map(|s| s.script.clone())
}
pub fn delete_step(project: &mut Project, bank: usize, pattern: usize, step: usize) -> PatternEdit {
let pat = project.pattern_at_mut(bank, pattern);
for s in &mut pat.steps {
if s.source == Some(step as u8) {
s.source = None;
s.script.clear();
}
}
set_step_script(project, bank, pattern, step, String::new());
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) {
s.source = None;
}
PatternEdit::new(bank, pattern)
}
pub fn delete_steps(project: &mut Project, bank: usize, pattern: usize, steps: &[usize]) -> PatternEdit {
for &step in steps {
delete_step(project, bank, pattern, step);
}
PatternEdit::new(bank, pattern)
}
pub fn reset_pattern(project: &mut Project, bank: usize, pattern: usize) -> PatternEdit {
project.banks[bank].patterns[pattern] = Pattern::default();
PatternEdit::new(bank, pattern)
}
pub fn reset_bank(project: &mut Project, bank: usize) -> usize {
project.banks[bank] = Bank::default();
project.banks[bank].patterns.len()
}

View File

@@ -0,0 +1,106 @@
use arc_swap::ArcSwap;
use parking_lot::Mutex;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use rand::rngs::StdRng;
use rand::SeedableRng;
use crate::model::{ScriptEngine, StepContext, Value};
use crate::state::{EditorContext, StackCache};
pub fn update_cache(editor_ctx: &EditorContext) {
let lines = editor_ctx.editor.lines();
let cursor_line = editor_ctx.editor.cursor().0;
let mut hasher = DefaultHasher::new();
for (i, line) in lines.iter().enumerate() {
if i > cursor_line {
break;
}
line.hash(&mut hasher);
}
let lines_hash = hasher.finish();
if let Some(ref c) = *editor_ctx.stack_cache.borrow() {
if c.cursor_line == cursor_line && c.lines_hash == lines_hash {
return;
}
}
let partial: Vec<&str> = lines
.iter()
.take(cursor_line + 1)
.map(|s| s.as_str())
.collect();
let script = partial.join("\n");
let result = if script.trim().is_empty() {
"Stack: []".to_string()
} else {
let vars = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict = Arc::new(Mutex::new(HashMap::new()));
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
let engine = ScriptEngine::new(vars, dict, rng);
let ctx = StepContext {
step: 0,
beat: 0.0,
bank: 0,
pattern: 0,
tempo: 120.0,
phase: 0.0,
slot: 0,
runs: 0,
iter: 0,
speed: 1.0,
fill: false,
nudge_secs: 0.0,
cc_access: None,
speed_key: "",
chain_key: "",
#[cfg(feature = "desktop")]
mouse_x: 0.5,
#[cfg(feature = "desktop")]
mouse_y: 0.5,
#[cfg(feature = "desktop")]
mouse_down: 0.0,
};
match engine.evaluate(&script, &ctx) {
Ok(_) => {
let stack = engine.stack();
let formatted: Vec<String> = stack.iter().map(format_value).collect();
format!("Stack: [{}]", formatted.join(" "))
}
Err(e) => format!("Error: {e}"),
}
};
*editor_ctx.stack_cache.borrow_mut() = Some(StackCache {
cursor_line,
lines_hash,
result,
});
}
fn format_value(v: &Value) -> String {
match v {
Value::Int(n, _) => n.to_string(),
Value::Float(f, _) => {
if f.fract() == 0.0 && f.abs() < 1_000_000.0 {
format!("{f:.1}")
} else {
format!("{f:.4}")
}
}
Value::Str(s, _) => format!("\"{s}\""),
Value::Quotation(..) => "[...]".to_string(),
Value::CycleList(items) => {
let inner: Vec<String> = items.iter().map(format_value).collect();
format!("({})", inner.join(" "))
}
}
}

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::state::ColorScheme;
use crate::state::{ColorScheme, MainLayout};
const APP_NAME: &str = "cagire";
@@ -29,12 +29,9 @@ pub struct AudioSettings {
pub buffer_size: u32,
#[serde(default = "default_max_voices")]
pub max_voices: usize,
#[serde(default = "default_lookahead_ms")]
pub lookahead_ms: u32,
}
fn default_max_voices() -> usize { 32 }
fn default_lookahead_ms() -> u32 { 15 }
#[derive(Debug, Serialize, Deserialize)]
pub struct DisplaySettings {
@@ -44,20 +41,20 @@ pub struct DisplaySettings {
pub show_spectrum: bool,
#[serde(default = "default_true")]
pub show_completion: bool,
#[serde(default = "default_flash_brightness")]
pub flash_brightness: f32,
#[serde(default = "default_font")]
pub font: String,
#[serde(default)]
pub color_scheme: ColorScheme,
#[serde(default)]
pub layout: MainLayout,
#[serde(default)]
pub hue_rotation: f32,
}
fn default_font() -> String {
"8x13".to_string()
}
fn default_flash_brightness() -> f32 { 1.0 }
#[derive(Debug, Serialize, Deserialize)]
pub struct LinkSettings {
pub enabled: bool,
@@ -73,7 +70,6 @@ impl Default for AudioSettings {
channels: 2,
buffer_size: 512,
max_voices: 32,
lookahead_ms: 15,
}
}
}
@@ -88,9 +84,10 @@ impl Default for DisplaySettings {
show_scope: true,
show_spectrum: true,
show_completion: true,
flash_brightness: 1.0,
font: default_font(),
color_scheme: ColorScheme::default(),
layout: MainLayout::default(),
hue_rotation: 0.0,
}
}
}
@@ -107,7 +104,14 @@ impl Default for LinkSettings {
impl Settings {
pub fn load() -> Self {
confy::load(APP_NAME, None).unwrap_or_default()
let mut settings: Self = confy::load(APP_NAME, None).unwrap_or_default();
if settings.audio.channels == 0 {
settings.audio.channels = AudioSettings::default().channels;
}
if settings.audio.buffer_size == 0 {
settings.audio.buffer_size = AudioSettings::default().buffer_size;
}
settings
}
pub fn save(&self) {

View File

@@ -1,28 +1,46 @@
use doux::audio::AudioDeviceInfo;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::CyclicEnum;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum MainLayout {
#[default]
Top,
Bottom,
Left,
Right,
}
impl CyclicEnum for MainLayout {
const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right];
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum RefreshRate {
#[default]
Fps60,
Fps30,
Fps15,
}
impl RefreshRate {
pub fn from_fps(fps: u32) -> Self {
if fps >= 60 {
RefreshRate::Fps60
} else {
} else if fps >= 30 {
RefreshRate::Fps30
} else {
RefreshRate::Fps15
}
}
pub fn toggle(self) -> Self {
match self {
RefreshRate::Fps60 => RefreshRate::Fps30,
RefreshRate::Fps30 => RefreshRate::Fps60,
RefreshRate::Fps30 => RefreshRate::Fps15,
RefreshRate::Fps15 => RefreshRate::Fps60,
}
}
@@ -30,6 +48,7 @@ impl RefreshRate {
match self {
RefreshRate::Fps60 => 16,
RefreshRate::Fps30 => 33,
RefreshRate::Fps15 => 66,
}
}
@@ -37,6 +56,7 @@ impl RefreshRate {
match self {
RefreshRate::Fps60 => "60",
RefreshRate::Fps30 => "30",
RefreshRate::Fps15 => "15",
}
}
@@ -44,6 +64,7 @@ impl RefreshRate {
match self {
RefreshRate::Fps60 => 60,
RefreshRate::Fps30 => 30,
RefreshRate::Fps15 => 15,
}
}
}
@@ -56,12 +77,13 @@ pub struct AudioConfig {
pub buffer_size: u32,
pub max_voices: usize,
pub sample_rate: f32,
pub host_name: String,
pub sample_paths: Vec<PathBuf>,
pub sample_count: usize,
pub refresh_rate: RefreshRate,
pub show_scope: bool,
pub show_spectrum: bool,
pub lookahead_ms: u32,
pub layout: MainLayout,
}
impl Default for AudioConfig {
@@ -73,12 +95,13 @@ impl Default for AudioConfig {
buffer_size: 512,
max_voices: 32,
sample_rate: 44100.0,
host_name: String::new(),
sample_paths: Vec::new(),
sample_count: 0,
refresh_rate: RefreshRate::default(),
show_scope: true,
show_spectrum: true,
lookahead_ms: 15,
layout: MainLayout::default(),
}
}
}
@@ -148,7 +171,6 @@ pub enum SettingKind {
BufferSize,
Polyphony,
Nudge,
Lookahead,
}
impl CyclicEnum for SettingKind {
@@ -157,13 +179,11 @@ impl CyclicEnum for SettingKind {
Self::BufferSize,
Self::Polyphony,
Self::Nudge,
Self::Lookahead,
];
}
pub struct Metrics {
pub event_count: usize,
pub dropped_events: usize,
pub active_voices: usize,
pub peak_voices: usize,
pub cpu_load: f32,
@@ -179,7 +199,6 @@ impl Default for Metrics {
fn default() -> Self {
Self {
event_count: 0,
dropped_events: 0,
active_voices: 0,
peak_voices: 0,
cpu_load: 0.0,
@@ -204,6 +223,7 @@ pub struct AudioSettings {
pub input_list: ListSelectState,
pub restart_pending: bool,
pub error: Option<String>,
pub sample_registry: Option<std::sync::Arc<doux::SampleRegistry>>,
}
impl Default for AudioSettings {
@@ -225,6 +245,7 @@ impl Default for AudioSettings {
},
restart_pending: false,
error: None,
sample_registry: None,
}
}
}
@@ -288,11 +309,6 @@ impl AudioSettings {
self.config.max_voices = new_val;
}
pub fn adjust_lookahead(&mut self, delta: i32) {
let new_val = (self.config.lookahead_ms as i32 + delta).clamp(0, 50) as u32;
self.config.lookahead_ms = new_val;
}
pub fn toggle_refresh_rate(&mut self) {
self.config.refresh_rate = self.config.refresh_rate.toggle();
}

View File

@@ -3,6 +3,13 @@ use std::ops::RangeInclusive;
use cagire_ratatui::Editor;
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum EditorTarget {
#[default]
Step,
Prelude,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PatternField {
Length,
@@ -41,6 +48,32 @@ impl PatternPropsField {
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum EuclideanField {
#[default]
Pulses,
Steps,
Rotation,
}
impl EuclideanField {
pub fn next(&self) -> Self {
match self {
Self::Pulses => Self::Steps,
Self::Steps => Self::Rotation,
Self::Rotation => Self::Rotation,
}
}
pub fn prev(&self) -> Self {
match self {
Self::Pulses => Self::Pulses,
Self::Steps => Self::Pulses,
Self::Rotation => Self::Steps,
}
}
}
pub struct EditorContext {
pub bank: usize,
pub pattern: usize,
@@ -50,6 +83,7 @@ pub struct EditorContext {
pub copied_steps: Option<CopiedSteps>,
pub show_stack: bool,
pub stack_cache: RefCell<Option<StackCache>>,
pub target: EditorTarget,
}
#[derive(Clone)]
@@ -70,7 +104,7 @@ pub struct CopiedSteps {
pub struct CopiedStepData {
pub script: String,
pub active: bool,
pub source: Option<usize>,
pub source: Option<u8>,
pub original_index: usize,
pub name: Option<String>,
}
@@ -99,6 +133,7 @@ impl Default for EditorContext {
copied_steps: None,
show_stack: false,
stack_cache: RefCell::new(None),
target: EditorTarget::default(),
}
}
}

54
src/state/effects.rs Normal file
View File

@@ -0,0 +1,54 @@
use tachyonfx::{fx, Interpolation, Motion};
use crate::page::Page;
use crate::state::ui::UiState;
use crate::state::Modal;
use crate::theme;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FxId {
#[default]
PageTransition,
}
pub fn tick_effects(ui: &mut UiState, page: Page) {
if !ui.show_title && ui.prev_show_title {
ui.effects.borrow_mut().add_unique_effect(
FxId::PageTransition,
fx::coalesce((200, Interpolation::QuadOut)),
);
}
ui.prev_show_title = ui.show_title;
let modal_open = !matches!(ui.modal, Modal::None);
if modal_open && !ui.prev_modal_open {
let bg = theme::get().ui.bg;
*ui.modal_fx.borrow_mut() = Some(fx::fade_from_fg(bg, (50, Interpolation::QuadOut)));
}
ui.prev_modal_open = modal_open;
if page != ui.prev_page {
let direction = page_direction(ui.prev_page, page);
let bg = theme::get().ui.bg;
ui.effects.borrow_mut().add_unique_effect(
FxId::PageTransition,
fx::sweep_in(direction, 10, 0, bg, (200, Interpolation::QuadOut)),
);
ui.prev_page = page;
}
}
fn page_direction(from: Page, to: Page) -> Motion {
let (fc, fr) = from.grid_pos();
let (tc, tr) = to.grid_pos();
if tc > fc {
Motion::LeftToRight
} else if tc < fc {
Motion::RightToLeft
} else if tr > fr {
Motion::UpToDown
} else {
Motion::DownToUp
}
}

View File

@@ -16,9 +16,11 @@ pub trait CyclicEnum: Sized + Copy + PartialEq + 'static {
pub mod audio;
pub mod color_scheme;
pub mod editor;
pub mod effects;
pub mod file_browser;
pub mod live_keys;
pub mod modal;
pub mod mute;
pub mod options;
pub mod panel;
pub mod patterns_nav;
@@ -27,17 +29,19 @@ pub mod project;
pub mod sample_browser;
pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind};
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
pub use color_scheme::ColorScheme;
pub use editor::{
CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache,
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
PatternPropsField, StackCache,
};
pub use live_keys::LiveKeyState;
pub use modal::Modal;
pub use options::{OptionsFocus, OptionsState};
pub use panel::{PanelFocus, PanelState, SidePanel};
pub use patterns_nav::{PatternsColumn, PatternsNav};
pub use playback::{PlaybackState, StagedChange};
pub use mute::MuteState;
pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange};
pub use project::ProjectState;
pub use sample_browser::SampleBrowserState;
pub use sample_browser::{SampleBrowserState, SampleTree};
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};

View File

@@ -1,5 +1,5 @@
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::editor::{PatternField, PatternPropsField};
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
use crate::state::file_browser::FileBrowserState;
#[derive(Clone, PartialEq, Eq)]
@@ -29,7 +29,16 @@ pub enum Modal {
bank: usize,
selected: bool,
},
FileBrowser(FileBrowserState),
ConfirmResetPatterns {
bank: usize,
patterns: Vec<usize>,
selected: bool,
},
ConfirmResetBanks {
banks: Vec<usize>,
selected: bool,
},
FileBrowser(Box<FileBrowserState>),
RenameBank {
bank: usize,
name: String,
@@ -50,7 +59,7 @@ pub enum Modal {
input: String,
},
SetTempo(String),
AddSamplePath(FileBrowserState),
AddSamplePath(Box<FileBrowserState>),
Editor,
Preview,
PatternProps {
@@ -66,4 +75,13 @@ pub enum Modal {
KeybindingsHelp {
scroll: usize,
},
EuclideanDistribution {
bank: usize,
pattern: usize,
source_step: usize,
field: EuclideanField,
pulses: String,
steps: String,
rotation: String,
},
}

53
src/state/mute.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::collections::HashSet;
#[derive(Default)]
pub struct MuteState {
pub muted: HashSet<(usize, usize)>,
pub soloed: HashSet<(usize, usize)>,
}
impl MuteState {
pub fn toggle_mute(&mut self, bank: usize, pattern: usize) {
let key = (bank, pattern);
if self.muted.contains(&key) {
self.muted.remove(&key);
} else {
self.muted.insert(key);
}
}
pub fn toggle_solo(&mut self, bank: usize, pattern: usize) {
let key = (bank, pattern);
if self.soloed.contains(&key) {
self.soloed.remove(&key);
} else {
self.soloed.insert(key);
}
}
pub fn clear_mute(&mut self) {
self.muted.clear();
}
pub fn clear_solo(&mut self) {
self.soloed.clear();
}
pub fn is_muted(&self, bank: usize, pattern: usize) -> bool {
self.muted.contains(&(bank, pattern))
}
pub fn is_soloed(&self, bank: usize, pattern: usize) -> bool {
self.soloed.contains(&(bank, pattern))
}
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
if self.muted.contains(&(bank, pattern)) {
return true;
}
if !self.soloed.is_empty() && !self.soloed.contains(&(bank, pattern)) {
return true;
}
false
}
}

View File

@@ -4,12 +4,12 @@ use super::CyclicEnum;
pub enum OptionsFocus {
#[default]
ColorScheme,
HueRotation,
RefreshRate,
RuntimeHighlight,
ShowScope,
ShowSpectrum,
ShowCompletion,
FlashBrightness,
LinkEnabled,
StartStopSync,
Quantum,
@@ -26,12 +26,12 @@ pub enum OptionsFocus {
impl CyclicEnum for OptionsFocus {
const VARIANTS: &'static [Self] = &[
Self::ColorScheme,
Self::HueRotation,
Self::RefreshRate,
Self::RuntimeHighlight,
Self::ShowScope,
Self::ShowSpectrum,
Self::ShowCompletion,
Self::FlashBrightness,
Self::LinkEnabled,
Self::StartStopSync,
Self::Quantum,

View File

@@ -1,3 +1,5 @@
use std::ops::RangeInclusive;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
#[derive(Clone, Copy, PartialEq, Eq, Default)]
@@ -12,14 +14,18 @@ pub struct PatternsNav {
pub column: PatternsColumn,
pub bank_cursor: usize,
pub pattern_cursor: usize,
pub bank_anchor: Option<usize>,
pub pattern_anchor: Option<usize>,
}
impl PatternsNav {
pub fn move_left(&mut self) {
self.clear_selection();
self.column = PatternsColumn::Banks;
}
pub fn move_right(&mut self) {
self.clear_selection();
self.column = PatternsColumn::Patterns;
}
@@ -45,6 +51,28 @@ impl PatternsNav {
}
}
pub fn move_up_clamped(&mut self) {
match self.column {
PatternsColumn::Banks => {
self.bank_cursor = self.bank_cursor.saturating_sub(1);
}
PatternsColumn::Patterns => {
self.pattern_cursor = self.pattern_cursor.saturating_sub(1);
}
}
}
pub fn move_down_clamped(&mut self) {
match self.column {
PatternsColumn::Banks => {
self.bank_cursor = (self.bank_cursor + 1).min(MAX_BANKS - 1);
}
PatternsColumn::Patterns => {
self.pattern_cursor = (self.pattern_cursor + 1).min(MAX_PATTERNS - 1);
}
}
}
pub fn selected_bank(&self) -> usize {
self.bank_cursor
}
@@ -52,4 +80,44 @@ impl PatternsNav {
pub fn selected_pattern(&self) -> usize {
self.pattern_cursor
}
pub fn bank_selection_range(&self) -> Option<RangeInclusive<usize>> {
let anchor = self.bank_anchor?;
let a = anchor.min(self.bank_cursor);
let b = anchor.max(self.bank_cursor);
Some(a..=b)
}
pub fn pattern_selection_range(&self) -> Option<RangeInclusive<usize>> {
let anchor = self.pattern_anchor?;
let a = anchor.min(self.pattern_cursor);
let b = anchor.max(self.pattern_cursor);
Some(a..=b)
}
pub fn selected_banks(&self) -> Vec<usize> {
match self.bank_selection_range() {
Some(range) => range.collect(),
None => vec![self.bank_cursor],
}
}
pub fn selected_patterns(&self) -> Vec<usize> {
match self.pattern_selection_range() {
Some(range) => range.collect(),
None => vec![self.pattern_cursor],
}
}
pub fn clear_selection(&mut self) {
self.bank_anchor = None;
self.pattern_anchor = None;
}
pub fn has_selection(&self) -> bool {
match self.column {
PatternsColumn::Banks => self.bank_anchor.is_some(),
PatternsColumn::Patterns => self.pattern_anchor.is_some(),
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::engine::PatternChange;
use crate::model::{LaunchQuantization, SyncMode};
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use std::collections::{HashMap, HashSet};
#[derive(Clone)]
pub struct StagedChange {
@@ -8,10 +9,26 @@ pub struct StagedChange {
pub sync_mode: SyncMode,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum StagedMuteChange {
ToggleMute { bank: usize, pattern: usize },
ToggleSolo { bank: usize, pattern: usize },
}
pub struct StagedPropChange {
pub name: Option<String>,
pub length: Option<usize>,
pub speed: PatternSpeed,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
}
pub struct PlaybackState {
pub playing: bool,
pub staged_changes: Vec<StagedChange>,
pub queued_changes: Vec<StagedChange>,
pub staged_mute_changes: HashSet<StagedMuteChange>,
pub staged_prop_changes: HashMap<(usize, usize), StagedPropChange>,
}
impl Default for PlaybackState {
@@ -20,6 +37,8 @@ impl Default for PlaybackState {
playing: true,
staged_changes: Vec::new(),
queued_changes: Vec::new(),
staged_mute_changes: HashSet::new(),
staged_prop_changes: HashMap::new(),
}
}
}
@@ -28,4 +47,48 @@ impl PlaybackState {
pub fn toggle(&mut self) {
self.playing = !self.playing;
}
pub fn clear_queues(&mut self) {
self.staged_changes.clear();
self.queued_changes.clear();
self.staged_prop_changes.clear();
}
pub fn has_staged_props(&self, bank: usize, pattern: usize) -> bool {
self.staged_prop_changes.contains_key(&(bank, pattern))
}
pub fn stage_mute(&mut self, bank: usize, pattern: usize) {
let change = StagedMuteChange::ToggleMute { bank, pattern };
if self.staged_mute_changes.contains(&change) {
self.staged_mute_changes.remove(&change);
} else {
self.staged_mute_changes.insert(change);
}
}
pub fn stage_solo(&mut self, bank: usize, pattern: usize) {
let change = StagedMuteChange::ToggleSolo { bank, pattern };
if self.staged_mute_changes.contains(&change) {
self.staged_mute_changes.remove(&change);
} else {
self.staged_mute_changes.insert(change);
}
}
pub fn clear_staged_mutes(&mut self) {
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleMute { .. }));
}
pub fn clear_staged_solos(&mut self) {
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleSolo { .. }));
}
pub fn has_staged_mute(&self, bank: usize, pattern: usize) -> bool {
self.staged_mute_changes.contains(&StagedMuteChange::ToggleMute { bank, pattern })
}
pub fn has_staged_solo(&self, bank: usize, pattern: usize) -> bool {
self.staged_mute_changes.contains(&StagedMuteChange::ToggleSolo { bank, pattern })
}
}

View File

@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::path::PathBuf;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
@@ -7,7 +6,8 @@ use crate::model::Project;
pub struct ProjectState {
pub project: Project,
pub file_path: Option<PathBuf>,
pub dirty_patterns: HashSet<(usize, usize)>,
dirty_patterns: [[bool; MAX_PATTERNS]; MAX_BANKS],
dirty_count: usize,
}
impl Default for ProjectState {
@@ -15,7 +15,8 @@ impl Default for ProjectState {
let mut state = Self {
project: Project::default(),
file_path: None,
dirty_patterns: HashSet::new(),
dirty_patterns: [[false; MAX_PATTERNS]; MAX_BANKS],
dirty_count: 0,
};
state.mark_all_dirty();
state
@@ -24,18 +25,31 @@ impl Default for ProjectState {
impl ProjectState {
pub fn mark_dirty(&mut self, bank: usize, pattern: usize) {
self.dirty_patterns.insert((bank, pattern));
}
pub fn mark_all_dirty(&mut self) {
for bank in 0..MAX_BANKS {
for pattern in 0..MAX_PATTERNS {
self.dirty_patterns.insert((bank, pattern));
}
if !self.dirty_patterns[bank][pattern] {
self.dirty_patterns[bank][pattern] = true;
self.dirty_count += 1;
}
}
pub fn take_dirty(&mut self) -> HashSet<(usize, usize)> {
std::mem::take(&mut self.dirty_patterns)
pub fn mark_all_dirty(&mut self) {
self.dirty_patterns = [[true; MAX_PATTERNS]; MAX_BANKS];
self.dirty_count = MAX_BANKS * MAX_PATTERNS;
}
pub fn take_dirty(&mut self) -> Vec<(usize, usize)> {
if self.dirty_count == 0 {
return Vec::new();
}
let mut result = Vec::with_capacity(self.dirty_count);
for (bank, patterns) in self.dirty_patterns.iter_mut().enumerate() {
for (pattern, dirty) in patterns.iter_mut().enumerate() {
if *dirty {
*dirty = false;
result.push((bank, pattern));
}
}
}
self.dirty_count = 0;
result
}
}

View File

@@ -1,7 +1,7 @@
use std::fs;
use std::path::{Path, PathBuf};
use cagire_ratatui::{TreeLine, TreeLineKind};
use cagire_ratatui::{fuzzy_match, TreeLine, TreeLineKind};
const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "ogg", "aiff", "aif", "mp3"];
@@ -93,7 +93,6 @@ pub struct SampleTree {
impl SampleTree {
pub fn from_paths(paths: &[PathBuf]) -> Self {
if paths.len() == 1 {
// Single path: show its contents directly at root level
let roots = Self::scan_children(&paths[0]);
return Self { roots };
}
@@ -209,6 +208,29 @@ impl SampleTree {
})
}
pub fn all_folder_names(&self) -> Vec<String> {
let mut names = Vec::new();
for root in &self.roots {
Self::collect_folder_names(root, &mut names);
}
names.sort_by_key(|n| n.to_lowercase());
names
}
fn collect_folder_names(node: &SampleNode, out: &mut Vec<String>) {
match node {
SampleNode::Root { children, .. } => {
for child in children {
Self::collect_folder_names(child, out);
}
}
SampleNode::Folder { name, .. } => {
out.push(name.clone());
}
SampleNode::File { .. } => {}
}
}
pub fn visible_entries(&self) -> Vec<TreeLine> {
let mut out = Vec::new();
for root in &self.roots {
@@ -251,6 +273,87 @@ impl SampleTree {
}
None
}
fn find_folder_mut(&mut self, name: &str) -> Option<&mut SampleNode> {
for root in &mut self.roots {
if let Some(node) = Self::find_folder_in(root, name) {
return Some(node);
}
}
None
}
fn find_folder_in<'a>(node: &'a mut SampleNode, name: &str) -> Option<&'a mut SampleNode> {
match node {
SampleNode::Folder { name: n, .. } if n == name => Some(node),
SampleNode::Root { children, .. } | SampleNode::Folder { children, .. } => {
for child in children.iter_mut() {
if let Some(found) = Self::find_folder_in(child, name) {
return Some(found);
}
}
None
}
SampleNode::File { .. } => None,
}
}
fn filtered_entries(&self, names: &[String], collapsed: bool) -> Vec<TreeLine> {
let mut out = Vec::new();
for name in names {
for root in &self.roots {
Self::emit_filtered(root, name, collapsed, &mut out);
}
}
out
}
fn emit_filtered(
node: &SampleNode,
target_name: &str,
collapsed: bool,
out: &mut Vec<TreeLine>,
) {
match node {
SampleNode::Folder {
name,
children,
expanded,
} if name == target_name => {
let show_children = !collapsed && *expanded;
out.push(TreeLine {
depth: 0,
kind: TreeLineKind::Folder {
expanded: show_children,
},
label: name.clone(),
folder: String::new(),
index: 0,
});
if show_children {
let mut idx = 0;
for child in children {
if let SampleNode::File { name: fname } = child {
out.push(TreeLine {
depth: 1,
kind: TreeLineKind::File,
label: fname.clone(),
folder: name.clone(),
index: idx,
});
idx += 1;
}
}
}
}
SampleNode::Root { children, .. } => {
for child in children {
Self::emit_filtered(child, target_name, collapsed, out);
}
}
_ => {}
}
}
}
pub struct SampleBrowserState {
@@ -259,7 +362,7 @@ pub struct SampleBrowserState {
pub scroll_offset: usize,
pub search_query: String,
pub search_active: bool,
filtered: Option<Vec<TreeLine>>,
filter: Option<Vec<String>>,
}
impl SampleBrowserState {
@@ -270,15 +373,15 @@ impl SampleBrowserState {
scroll_offset: 0,
search_query: String::new(),
search_active: false,
filtered: None,
filter: None,
}
}
pub fn entries(&self) -> Vec<TreeLine> {
if let Some(ref filtered) = self.filtered {
return filtered.clone();
match &self.filter {
Some(names) => self.tree.filtered_entries(names, self.search_active),
None => self.tree.visible_entries(),
}
self.tree.visible_entries()
}
pub fn current_entry(&self) -> Option<TreeLine> {
@@ -286,11 +389,44 @@ impl SampleBrowserState {
entries.into_iter().nth(self.cursor)
}
pub fn visible_count(&self) -> usize {
if let Some(ref filtered) = self.filtered {
return filtered.len();
pub fn sample_key(&self) -> Option<String> {
let entry = self.current_entry()?;
if !matches!(entry.kind, TreeLineKind::File) {
return None;
}
if entry.folder.is_empty() {
Some(
entry
.label
.rsplit_once('.')
.map_or(entry.label.clone(), |(stem, _)| stem.to_string()),
)
} else {
Some(format!("{}/{}", entry.folder, entry.index))
}
}
pub fn visible_count(&self) -> usize {
self.entries().len()
}
pub fn has_filter(&self) -> bool {
self.filter.is_some()
}
fn clamp_view(&mut self) {
let count = self.entries().len();
if count == 0 {
self.cursor = 0;
self.scroll_offset = 0;
return;
}
if self.cursor >= count {
self.cursor = count - 1;
}
if self.scroll_offset > self.cursor {
self.scroll_offset = self.cursor;
}
self.tree.visible_entries().len()
}
pub fn move_up(&mut self) {
@@ -316,26 +452,76 @@ impl SampleBrowserState {
}
pub fn toggle_expand(&mut self) {
if self.filtered.is_some() {
if self.search_active {
return;
}
if let Some(node) = self.tree.node_at_mut(self.cursor) {
if let Some(ref names) = self.filter {
let entries = self.tree.filtered_entries(names, false);
if let Some(entry) = entries.get(self.cursor) {
if matches!(entry.kind, TreeLineKind::Folder { .. }) {
let label = entry.label.clone();
if let Some(node) = self.tree.find_folder_mut(&label) {
let new_val = !node.expanded();
node.set_expanded(new_val);
}
}
}
} else if let Some(node) = self.tree.node_at_mut(self.cursor) {
if node.is_expandable() {
let new_val = !node.expanded();
node.set_expanded(new_val);
}
}
self.clamp_view();
}
pub fn collapse_at_cursor(&mut self) {
if self.filtered.is_some() {
if self.search_active {
return;
}
if let Some(node) = self.tree.node_at_mut(self.cursor) {
if node.expanded() {
node.set_expanded(false);
let entries = self.entries();
let entry = match entries.get(self.cursor) {
Some(e) => e,
None => return,
};
let is_file = matches!(entry.kind, TreeLineKind::File);
if is_file {
// Scan backward to find parent folder
for i in (0..self.cursor).rev() {
if matches!(
entries[i].kind,
TreeLineKind::Folder { .. } | TreeLineKind::Root { .. }
) {
let label = entries[i].label.clone();
if self.filter.is_some() {
if let Some(node) = self.tree.find_folder_mut(&label) {
node.set_expanded(false);
}
} else if let Some(node) = self.tree.node_at_mut(i) {
node.set_expanded(false);
}
self.cursor = i;
if self.cursor < self.scroll_offset {
self.scroll_offset = self.cursor;
}
return;
}
}
} else {
let label = entry.label.clone();
if self.filter.is_some() {
if let Some(node) = self.tree.find_folder_mut(&label) {
if node.expanded() {
node.set_expanded(false);
}
}
} else if let Some(node) = self.tree.node_at_mut(self.cursor) {
if node.expanded() {
node.set_expanded(false);
}
}
}
self.clamp_view();
}
pub fn activate_search(&mut self) {
@@ -344,15 +530,16 @@ impl SampleBrowserState {
pub fn update_search(&mut self) {
if self.search_query.is_empty() {
self.filtered = None;
self.filter = None;
} else {
let query = self.search_query.to_lowercase();
let full = self.full_entries();
let filtered: Vec<TreeLine> = full
.into_iter()
.filter(|line| line.label.to_lowercase().contains(&query))
.collect();
self.filtered = Some(filtered);
let query = &self.search_query;
let mut scored: Vec<(usize, String)> = Vec::new();
for root in &self.tree.roots {
Self::collect_matching_folder_names(root, query, &mut scored);
}
scored.sort_by_key(|(score, _)| *score);
let names = scored.into_iter().map(|(_, name)| name).collect();
self.filter = Some(names);
}
self.cursor = 0;
self.scroll_offset = 0;
@@ -361,49 +548,35 @@ impl SampleBrowserState {
pub fn clear_search(&mut self) {
self.search_query.clear();
self.search_active = false;
self.filtered = None;
self.filter = None;
self.cursor = 0;
self.scroll_offset = 0;
}
fn full_entries(&self) -> Vec<TreeLine> {
let mut out = Vec::new();
for root in &self.tree.roots {
Self::flatten_all(root, 0, "", 0, &mut out);
}
out
pub fn clear_filter(&mut self) {
self.filter = None;
self.search_query.clear();
self.cursor = 0;
self.scroll_offset = 0;
}
fn flatten_all(
fn collect_matching_folder_names(
node: &SampleNode,
depth: u8,
parent_folder: &str,
file_index: usize,
out: &mut Vec<TreeLine>,
query: &str,
out: &mut Vec<(usize, String)>,
) {
let kind = match node {
SampleNode::Root { .. } => TreeLineKind::Root { expanded: true },
SampleNode::Folder { .. } => TreeLineKind::Folder { expanded: true },
SampleNode::File { .. } => TreeLineKind::File,
};
out.push(TreeLine {
depth,
kind,
label: node.label().to_string(),
folder: parent_folder.to_string(),
index: file_index,
});
let folder_name = node.label();
let mut idx = 0;
for child in node.children() {
let child_idx = if matches!(child, SampleNode::File { .. }) {
let i = idx;
idx += 1;
i
} else {
0
};
Self::flatten_all(child, depth + 1, folder_name, child_idx, out);
match node {
SampleNode::Root { children, .. } => {
for child in children {
Self::collect_matching_folder_names(child, query, out);
}
}
SampleNode::Folder { name, .. } => {
if let Some(score) = fuzzy_match(query, name) {
out.push((score, name.clone()));
}
}
SampleNode::File { .. } => {}
}
}
}

View File

@@ -1,7 +1,11 @@
use std::cell::RefCell;
use std::time::{Duration, Instant};
use cagire_ratatui::Sparkles;
use tachyonfx::{fx, Effect, EffectManager, Interpolation};
use crate::page::Page;
use crate::state::effects::FxId;
use crate::state::{ColorScheme, Modal};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -46,10 +50,14 @@ pub struct UiState {
pub runtime_highlight: bool,
pub show_completion: bool,
pub minimap_until: Option<Instant>,
pub last_event_count: usize,
pub event_flash: f32,
pub flash_brightness: f32,
pub color_scheme: ColorScheme,
pub hue_rotation: f32,
pub effects: RefCell<EffectManager<FxId>>,
pub modal_fx: RefCell<Option<Effect>>,
pub title_fx: RefCell<Option<Effect>>,
pub prev_modal_open: bool,
pub prev_page: Page,
pub prev_show_title: bool,
}
impl Default for UiState {
@@ -62,22 +70,26 @@ impl Default for UiState {
modal: Modal::None,
help_focus: HelpFocus::default(),
help_topic: 0,
help_scrolls: vec![0; crate::views::help_view::topic_count()],
help_scrolls: vec![0; crate::model::docs::topic_count()],
help_search_active: false,
help_search_query: String::new(),
dict_focus: DictFocus::default(),
dict_category: 0,
dict_scrolls: vec![0; crate::views::dict_view::category_count()],
dict_scrolls: vec![0; crate::model::categories::category_count()],
dict_search_query: String::new(),
dict_search_active: false,
show_title: true,
runtime_highlight: false,
show_completion: true,
minimap_until: None,
last_event_count: 0,
event_flash: 0.0,
flash_brightness: 1.0,
color_scheme: ColorScheme::default(),
hue_rotation: 0.0,
effects: RefCell::new(EffectManager::default()),
modal_fx: RefCell::new(None),
title_fx: RefCell::new(Some(fx::coalesce((400, Interpolation::QuadOut)))),
prev_modal_open: false,
prev_page: Page::default(),
prev_show_title: true,
}
}
}

View File

@@ -5,58 +5,13 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::model::categories::{get_category_name, CatEntry, CATEGORIES};
use crate::model::{Word, WORDS};
use crate::state::DictFocus;
use crate::theme;
enum CatEntry {
Section(&'static str),
Category(&'static str),
}
use CatEntry::{Category, Section};
const CATEGORIES: &[CatEntry] = &[
// Forth core
Section("Forth"),
Category("Stack"),
Category("Arithmetic"),
Category("Comparison"),
Category("Logic"),
Category("Control"),
Category("Variables"),
Category("Probability"),
Category("Definitions"),
// Live coding
Section("Live Coding"),
Category("Sound"),
Category("Time"),
Category("Context"),
Category("Music"),
Category("LFO"),
// Synthesis
Section("Synthesis"),
Category("Oscillator"),
Category("Wavetable"),
Category("Generator"),
Category("Envelope"),
Category("Sample"),
// Effects
Section("Effects"),
Category("Filter"),
Category("FM"),
Category("Modulation"),
Category("Mod FX"),
Category("Lo-fi"),
Category("Stereo"),
Category("Delay"),
Category("Reverb"),
// External I/O
Section("I/O"),
Category("MIDI"),
Category("Desktop"),
];
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [header_area, body_area] =
Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(area);
@@ -165,17 +120,6 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) {
frame.render_widget(list, area);
}
fn get_category_name(index: usize) -> &'static str {
CATEGORIES
.iter()
.filter_map(|e| match e {
Category(name) => Some(*name),
Section(_) => None,
})
.nth(index)
.unwrap_or("Unknown")
}
fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
let theme = theme::get();
let focused = app.ui.dict_focus == DictFocus::Words;
@@ -299,9 +243,3 @@ fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(vec![line]), area);
}
pub fn category_count() -> usize {
CATEGORIES
.iter()
.filter(|e| matches!(e, Category(_)))
.count()
}

View File

@@ -135,7 +135,12 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
Rect::new(
indicator_x,
padded.y + padded.height.saturating_sub(1),
1,
1,
),
);
}
}
@@ -211,9 +216,13 @@ fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Re
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let header_style = if focused {
Style::new().fg(theme.engine.header_focused).add_modifier(Modifier::BOLD)
Style::new()
.fg(theme.engine.header_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.engine.header).add_modifier(Modifier::BOLD)
Style::new()
.fg(theme.engine.header)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(Paragraph::new(title).style(header_style), header_area);
@@ -292,7 +301,9 @@ fn render_device_column(
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
let label_style = if focused {
Style::new().fg(theme.engine.focused).add_modifier(Modifier::BOLD)
Style::new()
.fg(theme.engine.focused)
.add_modifier(Modifier::BOLD)
} else if section_focused {
Style::new().fg(theme.engine.label_focused)
} else {
@@ -322,7 +333,9 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
render_section_header(frame, "SETTINGS", section_focused, header_area);
let highlight = Style::new().fg(theme.engine.focused).add_modifier(Modifier::BOLD);
let highlight = Style::new()
.fg(theme.engine.focused)
.add_modifier(Modifier::BOLD);
let normal = Style::new().fg(theme.engine.normal);
let label_style = Style::new().fg(theme.engine.label);
let value_style = Style::new().fg(theme.engine.value);
@@ -331,8 +344,6 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize;
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
let lookahead_focused = section_focused && app.audio.setting_kind == SettingKind::Lookahead;
let nudge_ms = app.metrics.nudge_ms;
let nudge_label = if nudge_ms == 0.0 {
"0 ms".to_string()
@@ -340,12 +351,6 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
format!("{nudge_ms:+.1} ms")
};
let lookahead_label = if app.audio.config.lookahead_ms == 0 {
"off".to_string()
} else {
format!("{} ms", app.audio.config.lookahead_ms)
};
let rows = vec![
Row::new(vec![
Span::styled(
@@ -373,7 +378,11 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
label_style,
),
render_selector(
&format!("{}", app.audio.config.buffer_size),
&if app.audio.config.host_name.to_lowercase().contains("jack") {
"JACK managed".to_string()
} else {
format!("{}", app.audio.config.buffer_size)
},
buffer_focused,
highlight,
normal,
@@ -402,17 +411,6 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
),
render_selector(&nudge_label, nudge_focused, highlight, normal),
]),
Row::new(vec![
Span::styled(
if lookahead_focused {
"> Lookahead"
} else {
" Lookahead"
},
label_style,
),
render_selector(&lookahead_label, lookahead_focused, highlight, normal),
]),
Row::new(vec![
Span::styled(" Sample rate", label_style),
Span::styled(
@@ -420,6 +418,17 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
value_style,
),
]),
Row::new(vec![
Span::styled(" Audio host", label_style),
Span::styled(
if app.audio.config.host_name.is_empty() {
"-".to_string()
} else {
app.audio.config.host_name.clone()
},
value_style,
),
]),
];
let table = Table::new(rows, [Constraint::Length(14), Constraint::Fill(1)]);

Some files were not shown because too many files have changed in this diff Show More