diff --git a/BLUEPRINT.md b/BLUEPRINT.md index 32e2bc5..fd06193 100644 --- a/BLUEPRINT.md +++ b/BLUEPRINT.md @@ -27,6 +27,7 @@ All drum sounds are **synthesized from scratch** — no samples, no external DSP - Save/Load dialogs (Ctrl+S / Ctrl+O), file picker, dirty flag in title bar - Activity bar with per-track trigger flash indicators and live parameter display - Help overlay with 2-column key binding reference (~22 rows, shorthand S-/A-/C- notation) +- **Ableton Link sync** (default-on `link` feature): F3 toggles Link; tempo, start/stop, and beat position are slaved to the network session — multiple TextStep instances or DAWs lock together by bar. --- @@ -50,6 +51,7 @@ textstep/ │ │ ├── clock.rs # SequencerClock: sample-accurate step advancement │ │ ├── drum_voice.rs # DrumVoiceDsp trait + 8 voice implementations + apply_drive() │ │ ├── effects.rs # ReverbEffect + DelayEffect + GlueCompressor +│ │ ├── link.rs # Ableton Link (gated by `link` feature) — Link-as-master timing │ │ └── mixer.rs # Mute/solo logic, soft_clip(tanh) │ │ │ ├── sequencer/ @@ -82,8 +84,22 @@ cpal = "0.15" # Cross-platform audio (CoreAudio on macOS) crossbeam-channel = "0.5" # Lock-free bounded channels for UI<->Audio serde = { version = "1", features = ["derive"] } # Serialization for project files serde_json = "1" # JSON format for .tsp project files +log = "0.4" +simplelog = "0.12" + +# Ableton Link sync (default-enabled via the `link` feature) +ableton-link-rs = { version = "0.1", optional = true } +tokio = { version = "1", features = ["rt", "time"], optional = true } +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3", optional = true } + +[features] +default = ["link"] +link = ["dep:ableton-link-rs", "dep:tokio", "dep:tracing", "dep:tracing-subscriber"] ``` +The `link` feature is on by default. Use `cargo build --no-default-features` to opt out. + --- ## 3. Thread Architecture @@ -371,6 +387,30 @@ Sample-accurate step timing: `samples_per_step = sample_rate * 60.0 / bpm / 4.0` - First call after `new()` or `reset()` immediately returns step 0 - Steps wrap at `loop_length` (configurable: 8/16/24/32) - `StepEvent` includes: `drum_step`, `beat` (0..3), `is_bar_start` +- When Ableton Link is master (see §5.6), `clock.advance()` is bypassed and the clock is re-stamped via `set_step()` / `set_sub_phase()` from `LinkStatus.beat_position` every callback. + +### 5.6 Ableton Link (`src/audio/link.rs`) + +Gated by the `link` cargo feature (default-on). A dedicated thread owns a `BasicLink` instance on a single-thread tokio runtime and communicates via: + +- `LinkCommand` channel (UI → Link thread): `Enable` / `Disable` / `SetTempo(bpm)` / `SetPlaying(bool)` +- `LinkStatus` (shared atomics, lock-free): `enabled`, `num_peers`, `tempo`, `beat_position`, `is_playing`. Read by both the audio callback and UI tick. + +**Link-as-master timing.** When Link is enabled and `is_playing`, the audio engine derives the current step every callback: + +``` +beat_pos = link_status.beat_position() // absolute beats since session start +target_step = floor(beat_pos * 4) % MAX_STEPS // 16th-note resolution +sub_phase = (beat_pos * 4).fract() * samples_per_step +``` + +The local `SequencerClock` is re-stamped (`set_step` + `set_sub_phase`) — not advanced. This eliminates drift by construction: there is no free-running sample counter while Link is master. + +**Quantum-aligned start.** The Link thread asks Link to begin at the next bar (`request_beat_at_start_playing_time(0.0, 4.0)`). The audio thread does *not* call `clock.reset()` on `is_playing` rising edge — it waits for `beat_position >= 0` and lets the per-callback derivation take over. On the falling edge it does reset the clock (preserves stop sound behavior). + +**BPM routing.** User-driven BPM changes (keys, scenes, pattern switches) go through `App::set_bpm()`. When Link is enabled it sends `LinkCommand::SetTempo(bpm)` and skips the local write — the new tempo round-trips through Link to all peers and is published back to the audio thread within ~1 ms. The 0.01 BPM threshold is kept only for delay-time recompute (it's expensive); tempo assignment runs every callback. + +Full design: `docs/superpowers/specs/2026-05-30-ableton-link-sync-design.md`. ### 5.3 Drum Voices (`src/audio/drum_voice.rs`) diff --git a/CLAUDE.md b/CLAUDE.md index c81356d..6015d6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,13 +42,14 @@ Two-thread model: UI thread (ratatui + crossterm) and audio thread (cpal/CoreAud #### Audio (`src/audio/`) - `engine.rs` — audio callback, receives messages, triggers voices -- `clock.rs` — beat/step timing, swing +- `clock.rs` — beat/step timing, swing. When Link is master, `set_step` / `set_sub_phase` are used to re-stamp from `beat_position` every callback instead of `advance()`. - `drum_voice.rs` — per-track DSP (kick, snare, hats, etc.), params: tune/sweep/color/snap/filter/drive/decay/volume - `synth_voice.rs` — polyphonic synth DSP, 2 oscillators, 2 envelopes, filter with own ADSR - `mixer.rs` — channel mixing, send effects - `effects.rs` — FDN reverb, delay, compressor, saturator, lookahead limiter, sidechain envelope, oversampler - `display_buffer.rs` — lock-free audio→UI buffer for waveform display - `fft.rs` — FFT for spectrum analyzer +- `link.rs` — Ableton Link integration (gated by `link` cargo feature). Dedicated thread owns `BasicLink`; UI sends `LinkCommand`s, audio + UI read `LinkStatus` atomics. See `docs/superpowers/specs/2026-05-30-ableton-link-sync-design.md`. #### Sequencer (`src/sequencer/`) - `drum_pattern.rs` — `DrumPattern` (8 tracks x 32 steps + 8 `DrumTrackParams`), `TrackId` enum, `NUM_DRUM_TRACKS=8`, `MAX_STEPS=32` @@ -68,6 +69,9 @@ Two-thread model: UI thread (ratatui + crossterm) and audio thread (cpal/CoreAud ### Layout Must Match in Two Places Render layout (`ui/mod.rs`) and mouse hit-testing (`mouse.rs`) must compute identical layouts. Always use constants from `ui/layout.rs`. If you change a height/width in rendering, update the corresponding mouse hit-test. +### Link Is the Clock When Enabled +When the `link` feature is enabled and a Link session is active, `LinkStatus.beat_position` is the source of truth — the local `SequencerClock` is re-stamped every callback via `set_step` / `set_sub_phase`. Do not call `clock.advance()` while Link is master, and do not write `transport.bpm` / `transport.state` from the UI thread (audio thread is sole writer). User-driven BPM changes go through `App::set_bpm()`, which routes to `LinkCommand::SetTempo` when Link is on. + ### Synth Knobs Grouped Layout `synth_knobs.rs` uses percentage-based `Layout::split()` for side-by-side sections. `mouse.rs` replicates the exact same splits. If you add/remove a synth param group, update both files. @@ -93,3 +97,66 @@ Render layout (`ui/mod.rs`) and mouse hit-testing (`mouse.rs`) must compute iden ### Data Directory `~/Library/Application Support/textstep/` (macOS). Projects saved as `.tsp` (JSON). + +# context-mode — MANDATORY routing rules + +You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session. + +## BLOCKED commands — do NOT attempt these + +### curl / wget — BLOCKED +Any Bash command containing `curl` or `wget` is intercepted and replaced with an error message. Do NOT retry. +Instead use: +- `ctx_fetch_and_index(url, source)` to fetch and index web pages +- `ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox + +### Inline HTTP — BLOCKED +Any Bash command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` is intercepted and replaced with an error message. Do NOT retry with Bash. +Instead use: +- `ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context + +### WebFetch — BLOCKED +WebFetch calls are denied entirely. The URL is extracted and you are told to use `ctx_fetch_and_index` instead. +Instead use: +- `ctx_fetch_and_index(url, source)` then `ctx_search(queries)` to query the indexed content + +## REDIRECTED tools — use sandbox equivalents + +### Bash (>20 lines output) +Bash is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands. +For everything else, use: +- `ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call +- `ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context + +### Read (for analysis) +If you are reading a file to **Edit** it → Read is correct (Edit needs content in context). +If you are reading to **analyze, explore, or summarize** → use `ctx_execute_file(path, language, code)` instead. Only your printed summary enters context. The raw file content stays in the sandbox. + +### Grep (large results) +Grep results can flood context. Use `ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context. + +## Tool selection hierarchy + +1. **GATHER**: `ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls. +2. **FOLLOW-UP**: `ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call. +3. **PROCESSING**: `ctx_execute(language, code)` | `ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context. +4. **WEB**: `ctx_fetch_and_index(url, source)` then `ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context. +5. **INDEX**: `ctx_index(content, source)` — Store content in FTS5 knowledge base for later search. + +## Subagent routing + +When spawning subagents (Agent/Task tool), the routing block is automatically injected into their prompt. Bash-type subagents are upgraded to general-purpose so they have access to MCP tools. You do NOT need to manually instruct subagents about context-mode. + +## Output constraints + +- Keep responses under 500 words. +- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description. +- When indexing content, use descriptive source labels so others can `ctx_search(source: "label")` later. + +## ctx commands + +| Command | Action | +|---------|--------| +| `ctx stats` | Call the `ctx_stats` MCP tool and display the full output verbatim | +| `ctx doctor` | Call the `ctx_doctor` MCP tool, run the returned shell command, display as checklist | +| `ctx upgrade` | Call the `ctx_upgrade` MCP tool, run the returned shell command, display as checklist | diff --git a/Cargo.lock b/Cargo.lock index a589934..44a2d01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ableton-link-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567dd73ccb0cc603f3eb83d81cea167292ae832d8f5b3879ecd293bba0b726e3" +dependencies = [ + "async-trait", + "bincode", + "chrono", + "hex", + "if-addrs", + "libc", + "local-ip-address", + "rand", + "rodio", + "socket2 0.5.10", + "termios", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "triple_buffer", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -19,9 +43,21 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alsa" -version = "0.11.0" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" dependencies = [ "alsa-sys", "bitflags 2.11.0", @@ -31,20 +67,46 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.4.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" dependencies = [ "libc", "pkg-config", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -66,6 +128,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -123,6 +205,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -138,6 +226,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -156,6 +254,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "combine" version = "4.6.7" @@ -189,13 +300,19 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "coreaudio-rs" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15c3c3cee7c087938f7ad1c3098840b3ef1f1bdc7f6e496336c3b1e7a6f3914" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" dependencies = [ - "bitflags 2.11.0", + "bitflags 1.3.2", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -205,17 +322,43 @@ dependencies = [ [[package]] name = "cpal" -version = "0.17.3" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa 0.9.1", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2 0.4.3", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpal" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" dependencies = [ - "alsa", + "alsa 0.10.0", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", - "mach2", + "mach2 0.5.0", "ndk", "ndk-context", "num-derive", @@ -230,7 +373,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows", + "windows 0.62.2", ] [[package]] @@ -416,6 +559,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -441,6 +593,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -462,6 +620,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -526,6 +690,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -583,6 +758,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -595,6 +794,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "if-addrs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabb0019d51a643781ff15c9c8a3e5dedc365c47211270f4e8f82812fedd8f0a" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -732,6 +941,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "local-ip-address" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612ed4ea9ce5acfb5d26339302528a5e1e59dfed95e9e11af3c083236ff1d15d" +dependencies = [ + "libc", + "neli", + "thiserror 1.0.69", + "windows-sys 0.48.0", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -766,6 +987,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mach2" version = "0.5.0" @@ -843,6 +1073,31 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "neli" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" +dependencies = [ + "byteorder", + "libc", + "log", + "neli-proc-macros", +] + +[[package]] +name = "neli-proc-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8034b7fbb6f9455b2a96c19e6edf8dc9fc34c70449938d8ee3b4df363f61fe" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 1.0.109", +] + [[package]] name = "nix" version = "0.29.0" @@ -866,6 +1121,25 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -883,6 +1157,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1169,6 +1463,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1224,6 +1527,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -1232,6 +1547,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "ratatui" @@ -1356,6 +1674,18 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rodio" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" +dependencies = [ + "cpal 0.16.0", + "dasp_sample", + "num-rational", + "symphonia", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1465,6 +1795,21 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1525,6 +1870,26 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1559,19 +1924,166 @@ dependencies = [ ] [[package]] -name = "syn" -version = "1.0.109" +name = "symphonia" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", ] [[package]] -name = "syn" -version = "2.0.117" +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ @@ -1654,9 +2166,10 @@ dependencies = [ [[package]] name = "textstep" -version = "3.0.0" +version = "3.3.3" dependencies = [ - "cpal", + "ableton-link-rs", + "cpal 0.17.1", "crossbeam-channel", "crossterm", "log", @@ -1664,6 +2177,9 @@ dependencies = [ "serde", "serde_json", "simplelog", + "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1706,6 +2222,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1739,6 +2264,34 @@ dependencies = [ "time-core", ] +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -1769,6 +2322,72 @@ dependencies = [ "winnow", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "triple_buffer" +version = "8.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420466259f9fa5decc654c490b9ab538400e5420df8237f84ecbe20368bcf72b" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1816,6 +2435,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1834,12 +2459,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vtparse" version = "0.6.2" @@ -2089,6 +2726,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.62.2" @@ -2096,7 +2743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -2107,7 +2754,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", ] [[package]] @@ -2119,7 +2776,7 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] @@ -2129,7 +2786,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] @@ -2168,10 +2825,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2196,7 +2862,25 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -2214,13 +2898,44 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2238,42 +2953,132 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -2371,6 +3176,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 75f325b..6c8771b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,12 @@ [package] name = "textstep" -version = "3.0.0" +version = "3.3.3" edition = "2024" +[features] +default = ["link"] +link = ["dep:ableton-link-rs", "dep:tokio", "dep:tracing-subscriber", "dep:tracing"] + [dependencies] ratatui = "0.30" crossterm = "0.29" @@ -12,3 +16,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" log = "0.4" simplelog = "0.12" +ableton-link-rs = { version = "0.1", optional = true } +tokio = { version = "1", features = ["rt", "time"], optional = true } +tracing-subscriber = { version = "0.3", optional = true } +tracing = { version = "0.1", optional = true } diff --git a/README.md b/README.md index 9cb7c31..a6326e3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - 💾 **Project System** — save/load `.tsp` files, standalone kit export, preset browser - 📐 **Collapsible Panels** — minimize/expand synth, drum knobs, and waveform sections; auto-adapts to small terminals - 📊 **Spectrum Analyzer** — real-time FFT spectrum display and VU meter with 90s Hi-Fi LED aesthetic +- 🔗 **Ableton Link** — sync tempo, start/stop, and beat position with other TextStep instances or any Link-enabled DAW on the network. Press `F3` to toggle. Link-as-master: when enabled, the network's shared timeline drives this instance's clock continuously, eliminating drift. Ships with **10 demo scenes** ready to play: 🎵 Acid Techno, Classic House, Deep House, Driving Techno, Lo-Fi Hip Hop, Trance, Drum & Bass, Electro Funk, Dub Techno, and Ambient — each with matched drum patterns, synth patterns, and kits. @@ -54,6 +55,9 @@ cd textStep # Build and run (release mode recommended for audio performance) cargo build --release cargo run --release + +# Build without Ableton Link (skips link / tokio dependencies) +cargo build --release --no-default-features ``` ### Run Tests @@ -110,6 +114,7 @@ xattr -d com.apple.quarantine ./textstep | `Enter` | Toggle step (and advance — hold to fill) | | `;` | Cycle parameter page: SYN → AMP → FX | | `F2` | Collapse/expand all synth panels (A + B) | +| `F3` | Toggle Ableton Link sync | | `~` | Toggle spectrum analyzer / VU meter | | `?` | Help overlay | diff --git a/docs/superpowers/specs/2026-05-30-ableton-link-sync-design.md b/docs/superpowers/specs/2026-05-30-ableton-link-sync-design.md new file mode 100644 index 0000000..c43874e --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-ableton-link-sync-design.md @@ -0,0 +1,220 @@ +# Ableton Link Sync — Link-as-Master Design + +**Status:** Design +**Date:** 2026-05-30 +**Author:** lobo +**Feature flag:** `link` (cargo feature, already exists) + +## Goal + +Two or more TextStep instances on the same network (or sharing a Link session +with Ableton Live, etc.) play in lock-step indefinitely with no audible drift. + +The current implementation aligns once at start and then drifts apart over +seconds-to-minutes. This spec replaces that with a continuously Link-driven +step clock. + +## Non-Goals + +- Syncing pattern data, kits, or per-pattern loop lengths across peers. Link + only provides a shared *timeline* (tempo + beat position + start/stop). + TextStep instances may have independent patterns and independent loop + lengths; they will phase against each other naturally — that's intentional. +- Syncing transport position scrubbing, pattern switching, or scene changes + across peers. Each peer manages its own arrangement. +- Sub-buffer sample-accurate alignment. Audio-buffer granularity (~1–10 ms) + is acceptable; humans cannot perceive it as drift. + +## Background: Why the Current Implementation Drifts + +`src/audio/engine.rs:198-264` (and the helpers added in `src/audio/clock.rs`) +treat Link as a *one-shot phase nudge*: + +1. On `is_playing` rising edge, `clock.reset()` runs the local sequencer + from sample zero. +2. ~50 ms later, exactly once, the code reads `beat_position`, computes the + sub-step offset, and calls `clock.adjust_phase(delta)`. +3. The `link_phase_corrected` flag is set, and Link is never consulted again + for timing. + +Three independent root causes follow from this: + +1. **The local `SequencerClock` is the source of truth.** It free-runs by + counting samples at `transport.bpm`. Two machines have slightly different + sample-rate clocks; over minutes they drift. Link's correction never + re-fires to compensate. +2. **Phase correction nudges only the sub-16th fraction**, never the step + index. If the two peers happen to land on different steps at start, they + stay on different steps forever — same sub-step phase, wrong bar. +3. **Start-of-play race against the quantum scheduler.** `link.rs:148-152` + asks Link to begin at the next bar boundary + (`request_beat_at_start_playing_time(0.0, 4.0)`). But `engine.rs:223-231` + calls `clock.reset()` the instant `is_playing` flips true — so the local + sequencer starts *now*, not at the next bar. The two peers start tens of + milliseconds (or more) apart depending on audio-callback timing. + +A bonus bug: `engine.rs:249-252` sets `link_phase_corrected = true` +unconditionally, even when the delta is too large to apply. Result: large +offsets are silently abandoned forever. + +## Design: Link Is the Clock + +When Link is enabled, the local `SequencerClock` is *not* the source of +truth. Every audio callback, the engine derives the current step and +sub-step phase directly from Link's continuous beat timeline. + +### Per-callback algorithm + +``` +if link.enabled && link.is_playing: + beat_pos = link.beat_position() // f64, absolute beats since session start + if beat_pos < 0.0: + // Quantum-aligned start hasn't arrived yet — produce silence, + // do not advance steps. + skip step processing + else: + steps_f = beat_pos * 4.0 // 16th notes per beat + target_step = (steps_f.floor() as usize) % MAX_STEPS + sub_phase = steps_f.fract() * samples_per_step + + if target_step != clock.current_step: + fire StepEvent { step: target_step, ... } + clock.set_step(target_step) + clock.set_sub_phase(sub_phase) + + transport.bpm = link.tempo() // continuous, no threshold +``` + +`MAX_STEPS = 32` is the universal grid resolution. Each pattern (drums, +synth A, synth B) applies its own `loop_length` modulo when it looks up +which step to play; this already happens downstream and does not change. + +### Why this eliminates drift + +There is no free-running local sample counter while Link is master. The +step index and sub-step phase are *recomputed from Link* on every callback. +The local clock cannot drift because it is not advancing on its own — it is +re-stamped continuously. + +### Tempo + +Read `link.tempo()` every callback and assign to `transport.bpm` +unconditionally. The 0.01 BPM threshold currently in `engine.rs:205` is +removed: it is a quantization that produces stair-step tempo behavior. The +threshold for delay-time recalculation can stay, since recomputing +`set_single_knob` every sample is wasteful — keep that gated by a small +delta. + +### Start / stop + +- **Start (`is_playing` flips false → true):** Do nothing locally beyond + arming the synth voices (clear `note_end_step`). Do *not* call + `clock.reset()`. Wait for `beat_position >= 0`, then the per-callback + algorithm above takes over. +- **Stop (`is_playing` flips true → false):** Reset the local clock and + clear synth note ends. (Confirmed by user — preserves current sound + behavior on stop.) + +### Pattern-BPM behavior (option (a)) + +Per-pattern stored BPM is preserved as a project-level concept. When the +user switches patterns while Link is enabled, the new BPM is *pushed to +Link* via `LinkCommand::SetTempo(new_bpm)`. All peers in the session then +see the tempo change. Within ~1 ms the Link thread publishes the new tempo +to atomics, the audio callback reads it, and `transport.bpm` updates. + +When Link is disabled, pattern switching behaves as today: it writes +`transport.bpm` directly. + +### Loop length across peers + +Not synced. Each peer's `loop_length` only affects its own modulo over +`target_step`. Two peers with different loop lengths will phase against +each other in a musically interesting way. Link's `beat_position` is +absolute (not modulo quantum), so peers with mismatched loop lengths still +agree on what beat 0, 1, 2, 3, 4, 5… *means* — they just rotate their +patterns through it differently. + +### Synth A / B and drums + +All three sequencer streams derive `target_step` from the same +`beat_position`, then each modulos by its own loop length. No additional +state needed; existing per-stream loop logic is unchanged. + +## Code Changes + +### `src/audio/engine.rs` + +Remove: + +- Field `link_phase_corrected: bool` +- The one-shot phase-correction block (`engine.rs:233-254`) +- The `is_playing` rising-edge `clock.reset()` block (`engine.rs:223-231`) +- The 0.01 BPM threshold guard around tempo assignment + +Replace with: a single Link-driven step-advance block that runs *before* +the existing step-advance loop. When Link is master, the existing +`clock.advance(...)` call is skipped entirely; otherwise it runs as today. + +Keep: + +- The `link_active` guard in `UiToAudio::SetTransport` handling + (`engine.rs:230-261`) that ignores UI play/stop while Link is enabled. +- The stop-edge clock reset and synth note-end clearing. + +### `src/audio/clock.rs` + +Add: + +- `pub fn set_step(&mut self, step: usize)` — write `current_step` directly, + clear `first_step_pending`. +- `pub fn set_sub_phase(&mut self, samples: f64)` — write + `samples_since_last_step` directly. + +Remove (now unused): + +- `reset_to_step` (was already `#[allow(dead_code)]`) +- `adjust_phase` +- `sub_step_phase` + +### `src/app.rs` + +Remove the Link → transport mutation block (`app.rs:781-810`): + +- `self.transport.bpm = link_tempo` +- `self.transport.state = ...` from `link_playing` +- `sync_link_tempo()` is fine to keep — it's UI-driven only. + +The UI layer reads `link_handle.status` atomics directly for display +(`link_enabled`, `link_peers`, optionally `is_playing` and `tempo` for +visual sync), but does not write into `self.transport`. The audio thread +is the sole writer of `transport` while Link is enabled. + +### `src/audio/link.rs` + +No changes. The current `request_beat_at_start_playing_time(0.0, 4.0)` +quantum-aligned start is correct for Link-as-master and is now actually +respected by the consumer side. + +## Testing + +Manual: + +1. Two TextStep instances on one machine, Link enabled, both playing. + Identical patterns at 120 BPM. Verify lock-step over 5+ minutes. +2. Tempo change on instance A → instance B follows within one audio buffer. +3. Pattern switch on A with different stored BPM → both follow. +4. Different loop lengths (16 vs 32 steps) → both peers stay on the same + beat 0; patterns rotate independently. +5. Stop on A → B continues. Start on A while B is playing → A joins at + next bar boundary. + +Automated: existing 55 tests stay green. No new audio-thread tests +proposed; the Link interaction is hard to fixture without spawning +two engines. + +## Migration / Compatibility + +The `link` cargo feature already gates everything. Default builds are +unaffected. Project file format does not change. No persistent state in +this layer. diff --git a/src/app.rs b/src/app.rs index ad013d8..ecf8c30 100644 --- a/src/app.rs +++ b/src/app.rs @@ -577,6 +577,13 @@ pub struct UiState { // ── Synth state ── pub synth_a: SynthUiState, pub synth_b: SynthUiState, + // ── Ableton Link ── + #[cfg(feature = "link")] + pub link_enabled: bool, + #[cfg(feature = "link")] + pub link_peers: usize, + #[cfg(feature = "link")] + pub link_playing: bool, } impl UiState { @@ -623,6 +630,12 @@ impl Default for UiState { scope_intensity: Vec::new(), synth_a: SynthUiState::default(), synth_b: SynthUiState::default(), + #[cfg(feature = "link")] + link_enabled: false, + #[cfg(feature = "link")] + link_peers: 0, + #[cfg(feature = "link")] + link_playing: false, } } } @@ -643,6 +656,8 @@ pub struct App { pub tx_to_audio: Sender, pub rx_from_audio: Receiver, pub should_quit: bool, + #[cfg(feature = "link")] + pub link_handle: crate::audio::link::LinkHandle, } impl App { @@ -650,6 +665,7 @@ impl App { tx: Sender, rx: Receiver, display_buf: Arc, + #[cfg(feature = "link")] link_handle: crate::audio::link::LinkHandle, ) -> Self { let project = project::demo_project(); @@ -731,6 +747,8 @@ impl App { tx_to_audio: tx, rx_from_audio: rx, should_quit: false, + #[cfg(feature = "link")] + link_handle, } } @@ -767,6 +785,17 @@ impl App { } } + // Poll Ableton Link status (display only — audio thread is the sole + // writer of `self.transport` while Link is enabled). + #[cfg(feature = "link")] + { + use std::sync::atomic::Ordering; + let status = &self.link_handle.status; + self.ui.link_enabled = status.enabled.load(Ordering::Relaxed); + self.ui.link_peers = status.num_peers.load(Ordering::Relaxed); + self.ui.link_playing = status.is_playing.load(Ordering::Relaxed); + } + while let Ok(msg) = self.rx_from_audio.try_recv() { match msg { AudioToUi::PlaybackPosition { @@ -883,6 +912,23 @@ impl App { } } + /// True when sequencer playback is active. Returns true if the local + /// transport is Playing OR — when Link is enabled — Link reports the + /// session as playing (the audio thread drives this instance directly + /// from Link, so `transport.state` may stay Stopped on the follower). + pub fn is_playing(&self) -> bool { + if self.transport.state == crate::sequencer::transport::PlayState::Playing { + return true; + } + #[cfg(feature = "link")] + { + if self.ui.link_enabled && self.ui.link_playing { + return true; + } + } + false + } + /// Send current transport state to the audio thread. pub fn send_transport(&self) { let _ = self @@ -890,6 +936,39 @@ impl App { .send(UiToAudio::SetTransport(self.transport)); } + /// Notify Ableton Link of a local tempo change (call after updating transport.bpm). + /// Prefer `set_bpm`, which routes through Link automatically. + #[cfg(feature = "link")] + #[allow(dead_code)] + pub fn sync_link_tempo(&self) { + if self.ui.link_enabled { + self.link_handle + .send(crate::audio::link::LinkCommand::SetTempo( + self.transport.bpm, + )); + } + } + + /// Apply a user-driven BPM change. When Link is enabled, the new tempo is + /// pushed to Link (which then publishes it back to the audio thread on the + /// next callback) — `self.transport.bpm` is *not* written directly because + /// Link's published tempo would overwrite it anyway. When Link is + /// disabled, write `transport.bpm` and send transport to the audio thread + /// as before. + pub fn set_bpm(&mut self, new_bpm: f64) { + let bpm = new_bpm.clamp(60.0, 300.0); + #[cfg(feature = "link")] + { + if self.ui.link_enabled { + self.link_handle + .send(crate::audio::link::LinkCommand::SetTempo(bpm)); + return; + } + } + self.transport.bpm = bpm; + self.send_transport(); + } + /// Send the full drum pattern to the audio thread. pub fn send_drum_pattern(&self) { let _ = self @@ -1012,7 +1091,8 @@ impl App { self.switch_synth_kit(scene.synth_a_kit); self.switch_synth_pattern_for(SynthId::B, scene.synth_b_pattern); self.switch_synth_kit_for(SynthId::B, scene.synth_b_kit); - self.transport.bpm = scene.bpm; + // BPM goes through `set_bpm` so it routes through Link when enabled. + self.set_bpm(scene.bpm); self.transport.swing = scene.swing; self.send_transport(); } @@ -1062,8 +1142,7 @@ impl App { // Apply per-pattern BPM if set if let Some(pat) = self.project.patterns.get(index) { if pat.bpm > 0.0 { - self.transport.bpm = pat.bpm; - self.send_transport(); + self.set_bpm(pat.bpm); } } } diff --git a/src/audio/clock.rs b/src/audio/clock.rs index f270a90..731e489 100644 --- a/src/audio/clock.rs +++ b/src/audio/clock.rs @@ -46,6 +46,23 @@ impl SequencerClock { self.first_step_pending = true; } + /// Set the current step directly (for Ableton Link continuous re-stamping). + /// Clears `first_step_pending` so we don't re-emit step 0 on the next + /// `advance`. Leaves `samples_since_last_step` untouched — callers that + /// need to set the sub-phase should follow with `set_sub_phase`. + #[cfg(feature = "link")] + pub fn set_step(&mut self, step: usize) { + self.current_step = step; + self.first_step_pending = false; + } + + /// Set the sub-step sample accumulator directly (for Ableton Link continuous + /// re-stamping). Clamped to >= 0. + #[cfg(feature = "link")] + pub fn set_sub_phase(&mut self, samples: f64) { + self.samples_since_last_step = samples.max(0.0); + } + /// Call once per audio sample. Returns `Some(StepEvent)` when the /// sequencer advances to a new step, `None` otherwise. /// @@ -91,6 +108,12 @@ impl SequencerClock { } } + /// Current step index (for diagnostics / phase alignment). + #[allow(dead_code)] + pub fn current_step(&self) -> usize { + self.current_step + } + /// Build a `StepEvent` from the current state. fn make_event(&self) -> StepEvent { StepEvent { diff --git a/src/audio/engine.rs b/src/audio/engine.rs index 9d75b92..d218074 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -151,6 +151,18 @@ pub struct AudioEngine { // Channels rx: Receiver, tx: Sender, + + // Ableton Link + #[cfg(feature = "link")] + link_status: Option>, + #[cfg(feature = "link")] + link_was_playing: bool, + #[cfg(feature = "link")] + link_last_tempo: f64, + /// Last `global_step` we fired under Link master. `None` means "no step + /// fired yet this play session". + #[cfg(feature = "link")] + link_last_global_step: Option, } impl AudioEngine { @@ -159,6 +171,7 @@ impl AudioEngine { rx: Receiver, tx: Sender, display_buf: Arc, + #[cfg(feature = "link")] link_status: Option>, ) -> Self { let effect_params = EffectParams::default(); let mut drum_reverb = FdnReverb::new(sample_rate); @@ -193,6 +206,14 @@ impl AudioEngine { peak_tracker: 0.0, rx, tx, + #[cfg(feature = "link")] + link_last_tempo: 121.0, + #[cfg(feature = "link")] + link_was_playing: false, + #[cfg(feature = "link")] + link_status, + #[cfg(feature = "link")] + link_last_global_step: None, } } @@ -204,16 +225,37 @@ impl AudioEngine { UiToAudio::SetTransport(t) => { let prev_state = self.transport.state; let bpm_changed = (self.transport.bpm - t.bpm).abs() > 0.01; - self.transport = t; - if self.transport.state == PlayState::Stopped - && prev_state != PlayState::Stopped - { - self.clock.reset(); - self.synth_a.lfo.reset(); - self.synth_b.lfo.reset(); - self.synth_a.note_end_step = None; - self.synth_b.note_end_step = None; + + // When Link is enabled, Link owns play/stop — ignore UI + // transport state changes. We still accept BPM, swing, etc. + #[cfg(feature = "link")] + let link_active = self + .link_status + .as_ref() + .map_or(false, |ls| { + ls.enabled.load(std::sync::atomic::Ordering::Relaxed) + }); + #[cfg(not(feature = "link"))] + let link_active = false; + + if link_active { + // Accept everything except play state + let keep_state = self.transport.state; + self.transport = t; + self.transport.state = keep_state; + } else { + self.transport = t; + if self.transport.state == PlayState::Stopped + && prev_state != PlayState::Stopped + { + self.clock.reset(); + self.synth_a.lfo.reset(); + self.synth_b.lfo.reset(); + self.synth_a.note_end_step = None; + self.synth_b.note_end_step = None; + } } + // Update delay time when BPM changes if bpm_changed { let dt = self.effect_params.delay_time; @@ -293,6 +335,93 @@ impl AudioEngine { } } + // ── Ableton Link sync ──────────────────────────────────────────── + // When Link is enabled, Link is the source of truth for tempo, + // play/stop, and step position. The local SequencerClock is + // continuously re-stamped from `beat_position` on every callback; + // it is NOT free-running while Link is master. + #[cfg(feature = "link")] + let mut link_master_active = false; + // (global_step, target_step) when a Link-driven step boundary should fire this callback. + #[cfg(feature = "link")] + let mut link_step_to_fire: Option<(usize, usize)> = None; + #[cfg(feature = "link")] + if let Some(ref link_status) = self.link_status { + use std::sync::atomic::Ordering; + if link_status.enabled.load(Ordering::Relaxed) { + link_master_active = true; + + // Tempo: read continuously, no 0.01 BPM threshold on the + // transport assignment. We do gate the (relatively expensive) + // delay-time recomputation behind a small delta. + let link_tempo = link_status.tempo(); + self.transport.bpm = link_tempo; + if (link_tempo - self.link_last_tempo).abs() > 0.01 { + self.link_last_tempo = link_tempo; + let dt = self.effect_params.delay_time; + self.drum_delay + .set_single_knob(dt, self.transport.bpm, self.sample_rate); + self.synth_a + .delay + .set_single_knob(dt, self.transport.bpm, self.sample_rate); + self.synth_b + .delay + .set_single_knob(dt, self.transport.bpm, self.sample_rate); + } + + // Play/stop edges. + let link_playing = link_status.is_playing.load(Ordering::Relaxed); + + if link_playing && !self.link_was_playing { + // Rising edge: arm voices but DO NOT clock.reset(). We wait + // for `beat_position >= 0` (Link's quantum-aligned start) + // before firing any steps. + self.transport.state = PlayState::Playing; + self.synth_a.lfo.reset(); + self.synth_b.lfo.reset(); + self.synth_a.note_end_step = None; + self.synth_b.note_end_step = None; + self.link_last_global_step = None; + } + + if !link_playing && self.link_was_playing { + // Falling edge: reset clock + voices (preserves stop sound + // behavior). + self.transport.state = PlayState::Stopped; + self.clock.reset(); + self.synth_a.note_end_step = None; + self.synth_b.note_end_step = None; + self.link_last_global_step = None; + } + self.link_was_playing = link_playing; + + // Continuously derive target step + sub-phase from Link's + // beat_position. This replaces the free-running step counter. + if link_playing { + let beat_pos = link_status.beat_position(); + if beat_pos >= 0.0 { + use crate::sequencer::drum_pattern::MAX_STEPS; + let steps_f = beat_pos * 4.0; + let global_step = steps_f.floor() as usize; + let target_step = global_step % MAX_STEPS; + let samples_per_step = + self.sample_rate * 60.0 / self.transport.bpm / 4.0; + let sub_phase = steps_f.fract() * samples_per_step; + + let fire = match self.link_last_global_step { + None => true, + Some(prev) => global_step != prev, + }; + if fire { + link_step_to_fire = Some((global_step, target_step)); + self.clock.set_step(target_step); + } + self.clock.set_sub_phase(sub_phase); + } + } + } + } + // Build mute/solo arrays for effective_mute let mut muted = [false; 8]; let mut soloed = [false; 8]; @@ -317,14 +446,56 @@ impl AudioEngine { crate::sequencer::synth_pattern::MAX_STEPS }; + // ── Link-driven step fire (once per callback, buffer-granular) ─── + // When Link is master, we already computed (global_step, target_step) + // above. Fire the step event NOW (before the per-frame loop) by + // synthesising a `StepEvent` and running the same step-handling block + // the per-frame loop runs. The per-frame loop will then skip + // `clock.advance()`. + #[cfg(feature = "link")] + let link_event: Option = + if let Some((global_step, _target_step)) = link_step_to_fire { + self.link_last_global_step = Some(global_step); + Some(crate::audio::clock::StepEvent { + global_step, + beat: ((global_step / 4) % 4) as u8, + is_bar_start: global_step % 16 == 0, + }) + } else { + None + }; + // 2. Process each sample frame (stereo interleaved) - for frame in buffer.chunks_mut(2) { - // Advance clock if playing - if self.transport.state == PlayState::Playing { - if let Some(event) = + for (frame_idx, frame) in buffer.chunks_mut(2).enumerate() { + // Advance clock if playing. When Link is master, the step has + // already been computed above; we fire it on `frame_idx == 0` and + // skip `clock.advance()` entirely. + #[cfg(feature = "link")] + let step_event_this_frame: Option = + if link_master_active { + if frame_idx == 0 { + link_event + } else { + None + } + } else if self.transport.state == PlayState::Playing { + self.clock + .advance(self.transport.bpm, self.sample_rate, self.transport.swing) + } else { + None + }; + #[cfg(not(feature = "link"))] + let step_event_this_frame: Option = + if self.transport.state == PlayState::Playing { self.clock .advance(self.transport.bpm, self.sample_rate, self.transport.swing) - { + } else { + None + }; + // Suppress unused warning when not using frame_idx in some configs. + let _ = frame_idx; + + if let Some(event) = step_event_this_frame { // Map free-running global_step into per-instrument pattern positions let drum_step = event.global_step % drum_loop_len.max(1); let synth_a_step = event.global_step % synth_a_loop_len.max(1); @@ -430,7 +601,6 @@ impl AudioEngine { synth_b_step, synth_b_triggered, }); - } } // Decrement synth gate counters and release when expired diff --git a/src/audio/link.rs b/src/audio/link.rs new file mode 100644 index 0000000..bc230b9 --- /dev/null +++ b/src/audio/link.rs @@ -0,0 +1,169 @@ +//! Ableton Link integration: tempo, phase, and start/stop sync across the network. +//! +//! Architecture: a dedicated Link thread owns the `BasicLink` instance and runs a +//! small tokio runtime. All other threads communicate via: +//! - `LinkCommand` channel (UI → Link thread) for control +//! - `LinkStatus` (shared atomics) for lock-free reads from audio + UI threads + +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; + +use crossbeam_channel::{bounded, Receiver, Sender}; + +// ── Commands (UI → Link thread) ──────────────────────────────────────────── + +pub enum LinkCommand { + Enable, + Disable, + SetTempo(f64), + SetPlaying(bool), +} + +// ── Shared state (Link thread writes, everyone reads) ────────────────────── + +pub struct LinkStatus { + pub enabled: AtomicBool, + pub num_peers: AtomicUsize, + tempo_bits: AtomicU64, + beat_position_bits: AtomicU64, + pub is_playing: AtomicBool, +} + +impl LinkStatus { + fn new(initial_bpm: f64) -> Self { + Self { + enabled: AtomicBool::new(false), + num_peers: AtomicUsize::new(0), + tempo_bits: AtomicU64::new(initial_bpm.to_bits()), + beat_position_bits: AtomicU64::new(0.0f64.to_bits()), + is_playing: AtomicBool::new(false), + } + } + + pub fn tempo(&self) -> f64 { + f64::from_bits(self.tempo_bits.load(Ordering::Relaxed)) + } + + pub fn set_tempo(&self, bpm: f64) { + self.tempo_bits.store(bpm.to_bits(), Ordering::Relaxed); + } + + #[allow(dead_code)] // used for future phase alignment + pub fn beat_position(&self) -> f64 { + f64::from_bits(self.beat_position_bits.load(Ordering::Relaxed)) + } + + pub fn set_beat_position(&self, beats: f64) { + self.beat_position_bits + .store(beats.to_bits(), Ordering::Relaxed); + } +} + +// ── Handle (held by UI thread + App) ─────────────────────────────────────── + +pub struct LinkHandle { + pub status: Arc, + cmd_tx: Sender, +} + +impl LinkHandle { + pub fn send(&self, cmd: LinkCommand) { + let _ = self.cmd_tx.try_send(cmd); + } +} + +// ── Spawn ────────────────────────────────────────────────────────────────── + +/// Spawn the Link thread. Returns a handle for sending commands and an +/// `Arc` that can be cloned for the audio engine. +pub fn spawn_link_thread(initial_bpm: f64) -> LinkHandle { + let status = Arc::new(LinkStatus::new(initial_bpm)); + let (cmd_tx, cmd_rx) = bounded(32); + + let status_clone = Arc::clone(&status); + + std::thread::Builder::new() + .name("link".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create Link tokio runtime"); + + rt.block_on(link_thread_main(status_clone, cmd_rx, initial_bpm)); + }) + .expect("failed to spawn Link thread"); + + LinkHandle { status, cmd_tx } +} + +// ── Link thread main loop ────────────────────────────────────────────────── + +async fn link_thread_main( + status: Arc, + cmd_rx: Receiver, + initial_bpm: f64, +) { + use ableton_link_rs::link::BasicLink; + + // Install a no-op tracing subscriber BEFORE BasicLink::new() — the library + // calls tracing_subscriber::fmt::try_init() which would write to stdout and + // corrupt the TUI. Pre-installing a sink subscriber makes that call a no-op. + let _ = tracing_subscriber::fmt() + .with_writer(std::io::sink) + .with_max_level(tracing::Level::ERROR) + .try_init(); + + let mut link = BasicLink::new(initial_bpm).await; + let quantum = 4.0; // 4 beats = 1 bar (standard 4/4) + + let mut poll_interval = tokio::time::interval(tokio::time::Duration::from_millis(1)); + + loop { + poll_interval.tick().await; + + // Process pending commands + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + LinkCommand::Enable => { + link.enable().await; + link.enable_start_stop_sync(true); + status.enabled.store(true, Ordering::Relaxed); + } + LinkCommand::Disable => { + link.disable().await; + link.enable_start_stop_sync(false); + status.enabled.store(false, Ordering::Relaxed); + status.num_peers.store(0, Ordering::Relaxed); + } + LinkCommand::SetTempo(bpm) => { + let mut state = link.capture_app_session_state(); + let time = link.clock().micros(); + state.set_tempo(bpm, time); + link.commit_app_session_state(state).await; + } + LinkCommand::SetPlaying(playing) => { + let mut state = link.capture_app_session_state(); + let time = link.clock().micros(); + state.set_is_playing(playing, time); + if playing { + state.request_beat_at_start_playing_time(0.0, quantum); + } + link.commit_app_session_state(state).await; + } + } + } + + // Publish current Link state to shared atomics + let session = link.capture_app_session_state(); + let time = link.clock().micros(); + + status.num_peers.store(link.num_peers(), Ordering::Relaxed); + status.set_tempo(session.tempo()); + status + .set_beat_position(session.beat_at_time(time, quantum)); + status + .is_playing + .store(session.is_playing(), Ordering::Relaxed); + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 646cbe1..08ce828 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -4,6 +4,8 @@ pub mod drum_voice; pub mod fft; pub mod effects; pub mod engine; +#[cfg(feature = "link")] +pub mod link; pub mod mixer; pub mod synth_voice; @@ -23,6 +25,7 @@ pub fn start_audio_stream( rx: Receiver, tx: Sender, display_buf: Arc, + #[cfg(feature = "link")] link_status: Option>, ) -> Result { let host = cpal::default_host(); let device = host @@ -36,7 +39,14 @@ pub fn start_audio_stream( let sample_rate = config.sample_rate() as f64; let channels = config.channels() as usize; - let mut engine = AudioEngine::new(sample_rate, rx, tx, display_buf); + let mut engine = AudioEngine::new( + sample_rate, + rx, + tx, + display_buf, + #[cfg(feature = "link")] + link_status, + ); // Pre-allocate fallback stereo buffer for non-stereo configs (3d) let mut fallback_buf: Vec = Vec::new(); diff --git a/src/keys.rs b/src/keys.rs index 14abb5a..1c40b45 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -211,6 +211,21 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } + // Ableton Link toggle + #[cfg(feature = "link")] + KeyCode::F(3) => { + use crate::audio::link::LinkCommand; + let new_enabled = !app.ui.link_enabled; + if new_enabled { + app.link_handle.send(LinkCommand::Enable); + app.show_status("Link: ON".to_string()); + } else { + app.link_handle.send(LinkCommand::Disable); + app.show_status("Link: OFF".to_string()); + } + return; + } + // Focus navigation KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => { app.ui.focus = app.ui.focus.next(&app.ui.panel_vis); @@ -229,6 +244,12 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { PlayState::Paused => app.transport.state = PlayState::Playing, } app.send_transport(); + #[cfg(feature = "link")] + if app.ui.link_enabled { + use crate::audio::link::LinkCommand; + let playing = app.transport.state == PlayState::Playing; + app.link_handle.send(LinkCommand::SetPlaying(playing)); + } return; } @@ -236,31 +257,32 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Esc => { app.transport.state = PlayState::Stopped; app.send_transport(); + #[cfg(feature = "link")] + if app.ui.link_enabled { + use crate::audio::link::LinkCommand; + app.link_handle.send(LinkCommand::SetPlaying(false)); + } return; } // BPM adjustments: -/= for ±1, _/+ for ±10 KeyCode::Char('-') => { - app.transport.bpm = (app.transport.bpm - 1.0).clamp(60.0, 300.0); - app.send_transport(); + app.set_bpm(app.transport.bpm - 1.0); app.dirty = true; return; } KeyCode::Char('=') => { - app.transport.bpm = (app.transport.bpm + 1.0).clamp(60.0, 300.0); - app.send_transport(); + app.set_bpm(app.transport.bpm + 1.0); app.dirty = true; return; } KeyCode::Char('_') => { - app.transport.bpm = (app.transport.bpm - 10.0).clamp(60.0, 300.0); - app.send_transport(); + app.set_bpm(app.transport.bpm - 10.0); app.dirty = true; return; } KeyCode::Char('+') => { - app.transport.bpm = (app.transport.bpm + 10.0).clamp(60.0, 300.0); - app.send_transport(); + app.set_bpm(app.transport.bpm + 10.0); app.dirty = true; return; } diff --git a/src/main.rs b/src/main.rs index 32a4e74..c6cd949 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,8 +53,18 @@ fn main() -> io::Result<()> { // Shared display buffer for waveform/VU meter visualization let display_buf = Arc::new(audio::display_buffer::AudioDisplayBuffer::new()); + // Spawn Ableton Link thread (if compiled with "link" feature) + #[cfg(feature = "link")] + let link_handle = audio::link::spawn_link_thread(121.0); + // Start the audio stream (must keep _stream alive) - let _stream = match audio::start_audio_stream(rx_from_ui, tx_to_ui, Arc::clone(&display_buf)) { + let _stream = match audio::start_audio_stream( + rx_from_ui, + tx_to_ui, + Arc::clone(&display_buf), + #[cfg(feature = "link")] + Some(Arc::clone(&link_handle.status)), + ) { Ok(s) => s, Err(e) => { log::error!("Audio error: {e}"); @@ -70,7 +80,13 @@ fn main() -> io::Result<()> { let mut terminal = Terminal::new(backend)?; // Create the app - let mut app = app::App::new(tx_to_audio, rx_from_audio, display_buf); + let mut app = app::App::new( + tx_to_audio, + rx_from_audio, + display_buf, + #[cfg(feature = "link")] + link_handle, + ); // Main event loop loop { diff --git a/src/mouse.rs b/src/mouse.rs index 44c24d8..a622e7e 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -201,6 +201,18 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { }; app.send_transport(); } + #[cfg(feature = "link")] + if hit_test_link_button(col, row, ly.transport) { + use crate::audio::link::LinkCommand; + let new_enabled = !app.ui.link_enabled; + if new_enabled { + app.link_handle.send(LinkCommand::Enable); + app.show_status("Link: ON".to_string()); + } else { + app.link_handle.send(LinkCommand::Disable); + app.show_status("Link: OFF".to_string()); + } + } } // ── Panel toggle click detection ───────────────────────────────────────────── @@ -1061,6 +1073,16 @@ fn hit_test_record_button(col: u16, row: u16, transport_area: Rect) -> bool { row == content_y && col >= right_edge.saturating_sub(5) && col < right_edge } +/// Check if click is on the LINK toggle area (content row 0, after all other indicators). +/// Spans render left-to-right: play(7) + bpm(12) + beats(8) + swing(12) + rec(7) ≈ 46 chars. +/// LINK is the last item, so detect any click past column ~44 on the first content row. +#[cfg(feature = "link")] +fn hit_test_link_button(col: u16, row: u16, transport_area: Rect) -> bool { + let content_y = transport_area.y + 1; + let inner_x = transport_area.x + 2; // inside border + row == content_y && col >= inner_x + 44 +} + /// Returns (pattern_index, is_synth_row) if the click is on a pattern selector key. /// Identify which status line section a row belongs to. /// Line 2 (area.y+2) = SA, Line 3 (area.y+3) = SB, Line 4 (area.y+4) = DR. diff --git a/src/presets/drum_presets.rs b/src/presets/drum_presets.rs index f8b431e..05b777a 100644 --- a/src/presets/drum_presets.rs +++ b/src/presets/drum_presets.rs @@ -34,6 +34,18 @@ const fn ds( } } +// Helper with explicit send effects and pan +const fn ds_fx( + tune: f32, sweep: f32, color: f32, snap: f32, shape: f32, attack: f32, + filter: f32, drive: f32, decay: f32, volume: f32, + send_reverb: f32, send_delay: f32, pan: f32, +) -> DrumSoundParams { + DrumSoundParams { + tune, sweep, color, snap, shape, attack, filter, drive, decay, volume, + send_reverb, send_delay, pan, + } +} + // ── Kick Presets ───────────────────────────────────────────────────────────── // Tuned for: dual-stage pitch env (stage1=2.5ms fast, stage2=color-controlled), // subharmonic at 0.5× (octave below), BP/LP click blend, @@ -142,6 +154,62 @@ pub static KICK_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::Kick, params: ds(0.30, 0.20, 0.15, 0.50, 0.8, 0.03, 0.65, 0.05, 0.12, 0.68), }, + // Dub — deep 808 body, delay echo for roots/dub reggae bounce + DrumSoundPreset { + name: "Dub Kick", + category: "Dub", + voice: DrumTrackId::Kick, + params: ds_fx(0.22, 0.70, 0.12, 0.15, 0.0, 0.08, 0.55, 0.12, 0.75, 0.75, 0.08, 0.25, 0.5), + }, + // Techno — tight punch with room reverb + DrumSoundPreset { + name: "Techno Thump", + category: "Techno", + voice: DrumTrackId::Kick, + params: ds_fx(0.28, 0.42, 0.25, 0.50, 0.5, 0.03, 0.80, 0.25, 0.32, 0.78, 0.10, 0.0, 0.5), + }, + // 808 — very long sustaining sub for trap, no sends (pure dry sub) + DrumSoundPreset { + name: "Trap 808", + category: "808", + voice: DrumTrackId::Kick, + params: ds(0.18, 0.75, 0.08, 0.08, 0.0, 0.05, 0.45, 0.08, 0.95, 0.82), + }, + // Industrial — gabber: massive distortion, full 909 shape, huge sweep + DrumSoundPreset { + name: "Gabber", + category: "Industrial", + voice: DrumTrackId::Kick, + params: ds(0.22, 0.85, 0.55, 0.90, 1.0, 0.01, 1.0, 0.80, 0.65, 0.85), + }, + // Electro — UKG-style tight warm punch + DrumSoundPreset { + name: "UK Garage Kick", + category: "Electro", + voice: DrumTrackId::Kick, + params: ds_fx(0.26, 0.48, 0.18, 0.55, 0.3, 0.05, 0.78, 0.15, 0.35, 0.76, 0.05, 0.0, 0.5), + }, + // Ambient — soft, muffled, lots of reverb, slow attack + DrumSoundPreset { + name: "Ambient Kick", + category: "Ambient", + voice: DrumTrackId::Kick, + params: ds_fx(0.23, 0.60, 0.10, 0.10, 0.0, 0.15, 0.40, 0.05, 0.60, 0.65, 0.30, 0.15, 0.5), + }, + // Breakbeat — crunchy, driven lo-fi with room + DrumSoundPreset { + name: "Breakbeat Kick", + category: "Breakbeat", + voice: DrumTrackId::Kick, + params: ds_fx(0.28, 0.45, 0.38, 0.50, 0.6, 0.08, 0.55, 0.35, 0.38, 0.72, 0.12, 0.08, 0.5), + }, + // Acoustic — stadium: 909-style attack + big reverb tail + DrumSoundPreset { + name: "Stadium Kick", + category: "Acoustic", + voice: DrumTrackId::Kick, + params: ds_fx(0.30, 0.35, 0.40, 0.70, 0.85, 0.04, 0.90, 0.18, 0.35, 0.80, 0.25, 0.0, 0.5), + }, ]; // ── Snare Presets ──────────────────────────────────────────────────────────── @@ -225,6 +293,55 @@ pub static SNARE_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::Snare, params: ds(0.40, 0.05, 0.40, 0.15, 0.1, 0.08, 0.45, 0.00, 0.20, 0.48), }, + // Dub — spacious, heavy delay+reverb + DrumSoundPreset { + name: "Dub Snare", + category: "Dub", + voice: DrumTrackId::Snare, + params: ds_fx(0.38, 0.12, 0.45, 0.35, 0.2, 0.12, 0.48, 0.08, 0.42, 0.72, 0.25, 0.30, 0.5), + }, + // 808 — tight trap snap with subtle delay width + DrumSoundPreset { + name: "Trap Snap", + category: "808", + voice: DrumTrackId::Snare, + params: ds_fx(0.48, 0.08, 0.35, 0.75, 0.4, 0.08, 0.70, 0.10, 0.18, 0.80, 0.08, 0.10, 0.5), + }, + // 909 — big gated reverb, Phil Collins energy + DrumSoundPreset { + name: "Gated Verb", + category: "909", + voice: DrumTrackId::Snare, + params: ds_fx(0.42, 0.12, 0.48, 0.55, 0.5, 0.10, 0.60, 0.15, 0.20, 0.78, 0.35, 0.0, 0.5), + }, + // Ambient — gentle, washy, distant + DrumSoundPreset { + name: "Ambient Snare", + category: "Ambient", + voice: DrumTrackId::Snare, + params: ds_fx(0.35, 0.05, 0.50, 0.15, 0.1, 0.18, 0.40, 0.00, 0.50, 0.55, 0.40, 0.20, 0.5), + }, + // Industrial — crushed, chaotic, delayed + DrumSoundPreset { + name: "Breakcore", + category: "Industrial", + voice: DrumTrackId::Snare, + params: ds_fx(0.38, 0.18, 0.65, 0.75, 0.7, 0.20, 0.55, 0.55, 0.25, 0.80, 0.10, 0.15, 0.5), + }, + // Techno — tight, punchy, slight room + DrumSoundPreset { + name: "Techno Snare", + category: "Techno", + voice: DrumTrackId::Snare, + params: ds_fx(0.45, 0.08, 0.42, 0.60, 0.4, 0.08, 0.65, 0.12, 0.18, 0.78, 0.12, 0.0, 0.5), + }, + // Electro — sharp, bright, punchy + DrumSoundPreset { + name: "Electro Snare", + category: "Electro", + voice: DrumTrackId::Snare, + params: ds_fx(0.46, 0.10, 0.40, 0.65, 0.5, 0.08, 0.72, 0.15, 0.20, 0.78, 0.08, 0.05, 0.5), + }, ]; // ── Closed Hi-Hat Presets ──────────────────────────────────────────────────── @@ -279,6 +396,41 @@ pub static CHH_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::ClosedHiHat, params: ds(0.50, 0.05, 0.75, 0.40, 0.2, 0.1, 0.35, 0.35, 0.06, 0.60), }, + // Dub — offbeat bounce with delay, panned slightly right + DrumSoundPreset { + name: "Dub Hat", + category: "Dub", + voice: DrumTrackId::ClosedHiHat, + params: ds_fx(0.58, 0.00, 0.48, 0.25, 0.4, 0.05, 0.60, 0.00, 0.07, 0.62, 0.05, 0.25, 0.55), + }, + // Techno — precise, bright, tight + DrumSoundPreset { + name: "Techno Tick", + category: "Techno", + voice: DrumTrackId::ClosedHiHat, + params: ds_fx(0.72, 0.00, 0.35, 0.45, 0.7, 0.03, 0.85, 0.00, 0.04, 0.60, 0.05, 0.0, 0.55), + }, + // Ambient — soft, distant, reverbed + DrumSoundPreset { + name: "Ambient Tick", + category: "Ambient", + voice: DrumTrackId::ClosedHiHat, + params: ds_fx(0.55, 0.00, 0.42, 0.12, 0.3, 0.08, 0.55, 0.00, 0.08, 0.48, 0.25, 0.15, 0.5), + }, + // Electro — bright, punchy, metallic + DrumSoundPreset { + name: "Electro Hat", + category: "Electro", + voice: DrumTrackId::ClosedHiHat, + params: ds_fx(0.68, 0.02, 0.45, 0.40, 0.6, 0.05, 0.78, 0.08, 0.06, 0.62, 0.05, 0.0, 0.55), + }, + // Breakbeat — driven, crunchy, lo-fi + DrumSoundPreset { + name: "Breakbeat Hat", + category: "Breakbeat", + voice: DrumTrackId::ClosedHiHat, + params: ds_fx(0.52, 0.04, 0.62, 0.30, 0.3, 0.10, 0.48, 0.25, 0.08, 0.58, 0.08, 0.05, 0.55), + }, ]; // ── Open Hi-Hat Presets ────────────────────────────────────────────────────── @@ -321,6 +473,34 @@ pub static OHH_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::OpenHiHat, params: ds(0.45, 0.55, 0.50, 0.15, 0.2, 0.15, 0.35, 0.30, 0.55, 0.60), }, + // Dub — offbeat open hat with delay bounce + DrumSoundPreset { + name: "Dub Open", + category: "Dub", + voice: DrumTrackId::OpenHiHat, + params: ds_fx(0.48, 0.55, 0.32, 0.20, 0.2, 0.10, 0.48, 0.00, 0.50, 0.62, 0.10, 0.30, 0.55), + }, + // Ambient — long, soft, like ocean wash + DrumSoundPreset { + name: "Ambient Wash", + category: "Ambient", + voice: DrumTrackId::OpenHiHat, + params: ds_fx(0.42, 0.65, 0.28, 0.10, 0.1, 0.15, 0.40, 0.00, 0.75, 0.55, 0.35, 0.20, 0.5), + }, + // Techno — medium, bright, controlled + DrumSoundPreset { + name: "Techno Open", + category: "Techno", + voice: DrumTrackId::OpenHiHat, + params: ds_fx(0.52, 0.48, 0.38, 0.30, 0.3, 0.08, 0.58, 0.05, 0.42, 0.62, 0.08, 0.0, 0.5), + }, + // Electro — bright sizzle, tight + DrumSoundPreset { + name: "Electro Sizzle", + category: "Electro", + voice: DrumTrackId::OpenHiHat, + params: ds_fx(0.55, 0.52, 0.42, 0.35, 0.4, 0.08, 0.65, 0.08, 0.48, 0.62, 0.05, 0.0, 0.5), + }, ]; // ── Ride Presets ───────────────────────────────────────────────────────────── @@ -362,6 +542,34 @@ pub static RIDE_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::Ride, params: ds(0.65, 0.00, 0.25, 0.15, 0.4, 0.05, 0.50, 0.00, 0.33, 0.50), }, + // Acoustic — jazz bell with natural room + DrumSoundPreset { + name: "Jazz Bell", + category: "Acoustic", + voice: DrumTrackId::Ride, + params: ds_fx(0.62, 0.00, 0.35, 0.30, 0.4, 0.08, 0.55, 0.00, 0.55, 0.58, 0.20, 0.0, 0.55), + }, + // Dub — ride with delay echo + DrumSoundPreset { + name: "Dub Ride", + category: "Dub", + voice: DrumTrackId::Ride, + params: ds_fx(0.55, 0.05, 0.40, 0.20, 0.3, 0.10, 0.48, 0.00, 0.50, 0.55, 0.15, 0.25, 0.55), + }, + // Ambient — long shimmer, heavy reverb+delay + DrumSoundPreset { + name: "Ambient Shimmer", + category: "Ambient", + voice: DrumTrackId::Ride, + params: ds_fx(0.50, 0.08, 0.45, 0.10, 0.2, 0.15, 0.42, 0.00, 0.65, 0.50, 0.35, 0.20, 0.5), + }, + // Techno — tight, precise ping + DrumSoundPreset { + name: "Techno Ping", + category: "Techno", + voice: DrumTrackId::Ride, + params: ds_fx(0.68, 0.00, 0.30, 0.45, 0.5, 0.05, 0.70, 0.00, 0.35, 0.55, 0.08, 0.0, 0.55), + }, ]; // ── Clap Presets ───────────────────────────────────────────────────────────── @@ -409,6 +617,41 @@ pub static CLAP_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::Clap, params: ds(0.65, 0.05, 0.25, 0.90, 0.8, 0.08, 0.85, 0.00, 0.08, 0.60), }, + // Acoustic — stadium clap, huge reverb tail + DrumSoundPreset { + name: "Stadium Clap", + category: "Acoustic", + voice: DrumTrackId::Clap, + params: ds_fx(0.52, 0.30, 0.45, 0.55, 0.5, 0.10, 0.55, 0.05, 0.45, 0.72, 0.35, 0.0, 0.5), + }, + // Dub — clap with delay echo + DrumSoundPreset { + name: "Dub Clap", + category: "Dub", + voice: DrumTrackId::Clap, + params: ds_fx(0.48, 0.28, 0.48, 0.45, 0.4, 0.12, 0.50, 0.08, 0.40, 0.68, 0.20, 0.30, 0.5), + }, + // 808 — tight trap clap + DrumSoundPreset { + name: "Trap Clap", + category: "808", + voice: DrumTrackId::Clap, + params: ds_fx(0.55, 0.22, 0.35, 0.65, 0.5, 0.08, 0.65, 0.10, 0.25, 0.75, 0.08, 0.05, 0.5), + }, + // Techno — sharp, tight, slight room + DrumSoundPreset { + name: "Techno Clap", + category: "Techno", + voice: DrumTrackId::Clap, + params: ds_fx(0.52, 0.25, 0.42, 0.60, 0.6, 0.08, 0.62, 0.12, 0.22, 0.76, 0.10, 0.0, 0.5), + }, + // Ambient — soft, distant, washy + DrumSoundPreset { + name: "Ambient Clap", + category: "Ambient", + voice: DrumTrackId::Clap, + params: ds_fx(0.45, 0.32, 0.50, 0.30, 0.3, 0.15, 0.45, 0.00, 0.50, 0.58, 0.40, 0.20, 0.5), + }, ]; // ── Cowbell Presets ────────────────────────────────────────────────────────── @@ -450,6 +693,27 @@ pub static COWBELL_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::Cowbell, params: ds(0.50, 0.10, 0.30, 0.25, 0.4, 0.05, 0.45, 0.00, 0.20, 0.55), }, + // Dub — cowbell with delay echo + DrumSoundPreset { + name: "Dub Bell", + category: "Dub", + voice: DrumTrackId::Cowbell, + params: ds_fx(0.52, 0.28, 0.48, 0.22, 0.3, 0.10, 0.48, 0.05, 0.42, 0.62, 0.12, 0.25, 0.55), + }, + // Techno — tight, room reverb + DrumSoundPreset { + name: "Techno Bell", + category: "Techno", + voice: DrumTrackId::Cowbell, + params: ds_fx(0.55, 0.22, 0.40, 0.30, 0.4, 0.05, 0.58, 0.08, 0.35, 0.62, 0.10, 0.0, 0.55), + }, + // Ambient — soft, gentle chime + DrumSoundPreset { + name: "Ambient Chime", + category: "Ambient", + voice: DrumTrackId::Cowbell, + params: ds_fx(0.65, 0.15, 0.35, 0.15, 0.3, 0.12, 0.52, 0.00, 0.50, 0.52, 0.30, 0.20, 0.5), + }, ]; // ── Tom Presets ────────────────────────────────────────────────────────────── @@ -503,6 +767,41 @@ pub static TOM_PRESETS: &[DrumSoundPreset] = &[ voice: DrumTrackId::Tom, params: ds(0.65, 0.20, 0.05, 0.50, 0.9, 0.08, 0.60, 0.00, 0.25, 0.60), }, + // 808 — long 808 tom with delay for fill echoes + DrumSoundPreset { + name: "808 Drop", + category: "808", + voice: DrumTrackId::Tom, + params: ds_fx(0.35, 0.65, 0.08, 0.30, 0.2, 0.10, 0.72, 0.08, 0.58, 0.78, 0.05, 0.15, 0.5), + }, + // Acoustic — tribal: reverb-heavy for world percussion + DrumSoundPreset { + name: "Tribal", + category: "Acoustic", + voice: DrumTrackId::Tom, + params: ds_fx(0.42, 0.48, 0.12, 0.40, 0.4, 0.12, 0.82, 0.05, 0.52, 0.78, 0.25, 0.0, 0.5), + }, + // Techno — tight, punchy FM character + DrumSoundPreset { + name: "Techno Tom", + category: "Techno", + voice: DrumTrackId::Tom, + params: ds_fx(0.48, 0.55, 0.25, 0.50, 0.5, 0.08, 0.70, 0.15, 0.35, 0.75, 0.10, 0.0, 0.5), + }, + // Dub — tom with delay echo + DrumSoundPreset { + name: "Dub Tom", + category: "Dub", + voice: DrumTrackId::Tom, + params: ds_fx(0.38, 0.55, 0.10, 0.30, 0.2, 0.10, 0.68, 0.05, 0.55, 0.72, 0.12, 0.25, 0.5), + }, + // Ambient — soft, reverb-heavy + DrumSoundPreset { + name: "Ambient Tom", + category: "Ambient", + voice: DrumTrackId::Tom, + params: ds_fx(0.40, 0.45, 0.08, 0.20, 0.2, 0.15, 0.60, 0.00, 0.58, 0.62, 0.30, 0.15, 0.5), + }, ]; // ── Lookup functions ───────────────────────────────────────────────────────── diff --git a/src/presets/synth_presets.rs b/src/presets/synth_presets.rs index c65e135..da2f9f7 100644 --- a/src/presets/synth_presets.rs +++ b/src/presets/synth_presets.rs @@ -683,6 +683,674 @@ pub static SYNTH_PRESETS: &[SynthSoundPreset] = &[ 0, 0.05, 0.20, 2, // LFO1: Sine, 1/16D, fast pitch → ring mod 4, 0.22, 0.40, 10), // LFO2: Square, 1/8, strong tremolo → Volume }, + + // ═══════════════════════════════════════════════════════════════════ + // EXPANDED PRESETS — deeper sound design using LFOs, sends, sync, + // glide, key follow, and full parameter ranges. + // ═══════════════════════════════════════════════════════════════════ + + // ── Bass (expanded) ───────────────────────────────────────────── + SynthSoundPreset { + name: "Dub Bass", category: "Bass", + // Pure sine + heavy sub, glide for legato basslines, dub delay + params: with_sends(with_glide(sp( + 2, 0.5, 0.0, 0.9, + 2, 0.5, 0.0, 0.0, 0.5, + 0.7, + 0.01, 0.15, 0.85, 0.15, + 0.01, 0.15, 0.85, 0.15, + 0, 0.32, 0.10, 0.0, + 0.0, 0.2, 0.0, 0.15, + 0.80), 0.35), + 0.15, 0.30), + }, + SynthSoundPreset { + name: "DnB Reese", category: "Bass", + // Heavily detuned saws, aggressive LFO1 wobbles filter, LFO2 slow detune drift + params: with_lfos(sp( + 1, 0.5, 0.0, 0.8, + 1, 0.5, 0.0, 0.8, 0.54, + 0.5, + 0.01, 0.1, 1.0, 0.2, + 0.01, 0.1, 1.0, 0.2, + 0, 0.30, 0.35, 0.20, + 0.0, 0.4, 0.15, 0.2, + 0.82), + 1, 0.29, 0.35, 0, // LFO1: Tri, 1/8, strong → Filter + 0, 0.76, 0.10, 8), // LFO2: Sine, 1bar, subtle → Osc2 Detune + }, + SynthSoundPreset { + name: "Squelch Bass", category: "Bass", + // Acid-inspired: saw + square, high resonance, LFO modulates resonance + params: with_lfo(sp( + 1, 0.5, 0.0, 0.9, + 0, 0.5, 0.5, 0.3, 0.5, + 0.4, + 0.01, 0.45, 0.50, 0.08, + 0.01, 0.45, 0.50, 0.08, + 0, 0.20, 0.75, 0.55, + 0.0, 0.30, 0.10, 0.08, + 0.78), + 1, 0.47, 0.15, 1), // LFO: Tri, 1/4, → Filter Resonance + }, + SynthSoundPreset { + name: "Sync Bass", category: "Bass", + // Hard sync osc2 to osc1 for metallic harmonics, snappy filter env + params: with_sync(sp( + 1, 0.5, 0.0, 0.8, + 1, 0.62, 0.0, 0.7, 0.5, + 0.5, + 0.01, 0.18, 0.7, 0.10, + 0.01, 0.18, 0.7, 0.10, + 0, 0.35, 0.25, 0.40, + 0.0, 0.12, 0.1, 0.08, + 0.80)), + }, + SynthSoundPreset { + name: "Pluck Bass", category: "Bass", + // Detuned saws, zero sustain, strong filter env snap — fingered bass + params: sp(1, 0.5, 0.0, 0.9, + 1, 0.5, 0.0, 0.5, 0.52, + 0.4, + 0.00, 0.12, 0.0, 0.06, + 0.00, 0.12, 0.0, 0.06, + 0, 0.28, 0.15, 0.55, + 0.0, 0.08, 0.0, 0.04, + 0.82), + }, + SynthSoundPreset { + name: "Distorted Bass", category: "Bass", + // Saw full blast + sub sine, high reso LP, aggressive filter env + params: with_sub_waveform(sp( + 1, 0.5, 0.0, 1.0, + 1, 0.5, 0.0, 0.6, 0.53, + 0.7, + 0.01, 0.12, 0.85, 0.10, + 0.01, 0.12, 0.85, 0.10, + 0, 0.25, 0.50, 0.35, + 0.0, 0.15, 0.2, 0.1, + 0.85), 1), + }, + SynthSoundPreset { + name: "Tape Bass", category: "Bass", + // Sine with slight fold for warmth, dark LP, very subtle pitch drift + params: with_lfo(sp( + 2, 0.5, 0.15, 0.8, + 2, 0.5, 0.0, 0.0, 0.5, + 0.6, + 0.01, 0.2, 0.8, 0.15, + 0.01, 0.2, 0.8, 0.15, + 0, 0.28, 0.05, 0.0, + 0.0, 0.3, 0.0, 0.2, + 0.78), + 0, 0.76, 0.04, 2), // LFO: Sine, 1bar, subtle pitch drift + }, + + // ── Lead (expanded) ───────────────────────────────────────────── + SynthSoundPreset { + name: "Sync Lead", category: "Lead", + // Hard sync creates rich moving harmonics, vibrato LFO + params: with_lfo(with_sync(sp( + 1, 0.5, 0.0, 0.85, + 1, 0.65, 0.0, 0.75, 0.5, + 0.0, + 0.03, 0.2, 0.8, 0.3, + 0.03, 0.2, 0.8, 0.3, + 0, 0.55, 0.20, 0.25, + 0.02, 0.25, 0.3, 0.25, + 0.72)), + 0, 0.35, 0.10, 2), // LFO: Sine, vibrato → Pitch + }, + SynthSoundPreset { + name: "Supersaw Lead", category: "Lead", + // Wide 4-osc supersaw spread, big and wide, reverb for space + params: with_sends(sp( + 1, 0.5, 0.6, 0.9, + 1, 0.5, 0.5, 0.7, 0.54, + 0.0, + 0.03, 0.15, 0.9, 0.35, + 0.03, 0.15, 0.9, 0.35, + 0, 0.60, 0.15, 0.10, + 0.01, 0.2, 0.3, 0.3, + 0.72), 0.25, 0.10), + }, + SynthSoundPreset { + name: "Acid Lead", category: "Lead", + // Classic 303: single saw, screaming resonance, big filter env + params: sp(1, 0.5, 0.0, 0.9, + 1, 0.5, 0.0, 0.0, 0.5, + 0.2, + 0.01, 0.50, 0.40, 0.05, + 0.01, 0.50, 0.40, 0.05, + 0, 0.18, 0.70, 0.65, + 0.0, 0.35, 0.15, 0.05, + 0.78), + }, + SynthSoundPreset { + name: "Flute", category: "Lead", + // Pure sine, soft breathy attack, gentle delayed vibrato + params: with_lfo(with_sends(sp( + 2, 0.5, 0.0, 0.8, + 2, 0.5, 0.0, 0.0, 0.5, + 0.0, + 0.12, 0.15, 0.85, 0.25, + 0.12, 0.15, 0.85, 0.25, + 0, 0.55, 0.05, 0.0, + 0.0, 0.2, 0.0, 0.2, + 0.72), 0.20, 0.0), + 0, 0.35, 0.08, 2), // LFO: Sine, gentle vibrato → Pitch + }, + SynthSoundPreset { + name: "Theremin", category: "Lead", + // Sine, lots of glide, prominent vibrato — eerie wavering tone + params: with_lfo(with_glide(sp( + 2, 0.5, 0.0, 0.9, + 2, 0.5, 0.0, 0.0, 0.5, + 0.0, + 0.08, 0.1, 1.0, 0.3, + 0.08, 0.1, 1.0, 0.3, + 0, 0.60, 0.08, 0.0, + 0.0, 0.2, 0.0, 0.2, + 0.75), 0.60), + 0, 0.29, 0.15, 2), // LFO: Sine, 1/8, strong vibrato + }, + SynthSoundPreset { + name: "Ambient Lead", category: "Lead", + // Sine + saw, heavy reverb+delay, slow filter breathing + params: with_lfos(with_sends(sp( + 2, 0.5, 0.0, 0.7, + 1, 0.5, 0.0, 0.4, 0.52, + 0.2, + 0.10, 0.25, 0.75, 0.45, + 0.10, 0.25, 0.75, 0.45, + 0, 0.50, 0.15, 0.10, + 0.08, 0.4, 0.3, 0.4, + 0.68), 0.35, 0.25), + 0, 0.76, 0.10, 0, // LFO1: Sine, 1bar, → Filter + 0, 0.47, 0.06, 2), // LFO2: Sine, 1/4, vibrato → Pitch + }, + SynthSoundPreset { + name: "PWM Lead", category: "Lead", + // Dual squares with out-of-phase PWM modulation — rich, animated + params: with_lfos(sp( + 0, 0.5, 0.35, 0.8, + 0, 0.5, 0.65, 0.6, 0.52, + 0.0, + 0.03, 0.2, 0.85, 0.3, + 0.03, 0.2, 0.85, 0.3, + 0, 0.58, 0.18, 0.12, + 0.01, 0.25, 0.3, 0.25, + 0.72), + 1, 0.47, 0.35, 3, // LFO1: Tri, 1/4, → Osc1 PWM + 1, 0.65, 0.30, 6), // LFO2: Tri, 1/2, → Osc2 PWM + }, + + // ── Pad (expanded) ────────────────────────────────────────────── + SynthSoundPreset { + name: "Glass Pad", category: "Pad", + // Octave sines, reverb+delay shimmer, very slow detune drift + params: with_lfos(with_sends(sp( + 2, 0.5, 0.0, 0.5, + 2, 0.75, 0.0, 0.4, 0.52, + 0.2, + 0.55, 0.3, 0.7, 0.65, + 0.55, 0.3, 0.7, 0.65, + 0, 0.50, 0.15, 0.08, + 0.4, 0.6, 0.3, 0.5, + 0.60), 0.35, 0.20), + 0, 0.88, 0.10, 0, // LFO1: Sine, 4bar, → Filter + 1, 0.82, 0.06, 8), // LFO2: Tri, 2bar, → Osc2 Detune + }, + SynthSoundPreset { + name: "Analog Strings", category: "Pad", + // Detuned saws with ensemble-like motion, slow filter sweep + params: with_lfos(with_sends(sp( + 1, 0.5, 0.0, 0.7, + 1, 0.5, 0.0, 0.6, 0.53, + 0.2, + 0.30, 0.3, 0.8, 0.50, + 0.30, 0.3, 0.8, 0.50, + 0, 0.42, 0.12, 0.08, + 0.25, 0.5, 0.3, 0.4, + 0.68), 0.25, 0.0), + 1, 0.76, 0.12, 0, // LFO1: Tri, 1bar, → Filter + 0, 0.88, 0.06, 8), // LFO2: Sine, 4bar, → Osc2 Detune + }, + SynthSoundPreset { + name: "Choir Pad", category: "Pad", + // Squares with asymmetric PWM modulation — vocal, breathy + params: with_lfos(with_sends(sp( + 0, 0.5, 0.45, 0.6, + 0, 0.5, 0.55, 0.5, 0.52, + 0.3, + 0.40, 0.3, 0.75, 0.55, + 0.40, 0.3, 0.75, 0.55, + 0, 0.38, 0.12, 0.06, + 0.3, 0.5, 0.25, 0.45, + 0.65), 0.30, 0.08), + 1, 0.65, 0.25, 3, // LFO1: Tri, 1/2, → Osc1 PWM + 1, 0.76, 0.20, 6), // LFO2: Tri, 1bar, → Osc2 PWM + }, + SynthSoundPreset { + name: "Drone", category: "Pad", + // Detuned low saws, very slow attack, ultra-slow LFO movement + params: with_lfos(with_sends(sp( + 1, 0.38, 0.0, 0.7, + 1, 0.38, 0.0, 0.6, 0.53, + 0.5, + 0.70, 0.2, 0.9, 0.70, + 0.70, 0.2, 0.9, 0.70, + 0, 0.30, 0.18, 0.05, + 0.5, 0.7, 0.25, 0.6, + 0.72), 0.30, 0.15), + 0, 0.94, 0.08, 0, // LFO1: Sine, 8bar, → Filter + 1, 0.88, 0.05, 8), // LFO2: Tri, 4bar, → Osc2 Detune + }, + SynthSoundPreset { + name: "Ice Pad", category: "Pad", + // HP filtered! Airy, bright, lots of reverb+delay + params: with_lfos(with_sends(sp( + 1, 0.5, 0.0, 0.6, + 2, 0.75, 0.0, 0.5, 0.52, + 0.0, + 0.45, 0.3, 0.7, 0.55, + 0.45, 0.3, 0.7, 0.55, + 1, 0.35, 0.20, 0.10, + 0.3, 0.5, 0.3, 0.5, + 0.60), 0.40, 0.25), + 0, 0.82, 0.08, 0, // LFO1: Sine, 2bar, → Filter + 1, 0.88, 0.05, 8), // LFO2: Tri, 4bar, → Detune + }, + SynthSoundPreset { + name: "Cathedral", category: "Pad", + // Detuned saws, very slow attack, massive reverb+delay — organ-like bloom + params: with_lfo(with_sends(sp( + 1, 0.5, 0.0, 0.6, + 1, 0.5, 0.0, 0.5, 0.53, + 0.3, + 0.65, 0.2, 0.75, 0.70, + 0.65, 0.2, 0.75, 0.70, + 0, 0.40, 0.15, 0.08, + 0.5, 0.6, 0.3, 0.5, + 0.62), 0.45, 0.30), + 0, 0.88, 0.12, 0), // LFO: Sine, 4bar, → Filter + }, + SynthSoundPreset { + name: "Swarm", category: "Pad", + // Supersaws + fast detune LFO creates thick swirling movement + params: with_lfos(with_sends(sp( + 1, 0.5, 0.3, 0.7, + 1, 0.5, 0.2, 0.6, 0.53, + 0.2, + 0.35, 0.3, 0.8, 0.50, + 0.35, 0.3, 0.8, 0.50, + 0, 0.45, 0.15, 0.08, + 0.3, 0.5, 0.3, 0.4, + 0.65), 0.25, 0.10), + 0, 0.47, 0.20, 8, // LFO1: Sine, 1/4, strong → Osc2 Detune + 1, 0.76, 0.12, 0), // LFO2: Tri, 1bar, → Filter + }, + + // ── Pluck (expanded) ──────────────────────────────────────────── + SynthSoundPreset { + name: "Guitar Pluck", category: "Pluck", + // Saw + detuned saw, short decay, moderate filter env + params: sp(1, 0.5, 0.0, 0.8, + 1, 0.5, 0.0, 0.4, 0.52, + 0.0, + 0.00, 0.25, 0.0, 0.15, + 0.00, 0.18, 0.0, 0.12, + 0, 0.52, 0.15, 0.45, + 0.0, 0.18, 0.0, 0.12, + 0.78), + }, + SynthSoundPreset { + name: "Music Box", category: "Pluck", + // Octave sines, short, very reverb+delay heavy — dreamy + params: with_sends(sp( + 2, 0.5, 0.0, 0.7, + 2, 0.75, 0.0, 0.35, 0.5, + 0.0, + 0.00, 0.15, 0.0, 0.10, + 0.00, 0.10, 0.0, 0.08, + 0, 0.62, 0.05, 0.25, + 0.0, 0.08, 0.0, 0.06, + 0.72), 0.35, 0.25), + }, + SynthSoundPreset { + name: "Pizzicato", category: "Pluck", + // Saw + sine, very short, strong filter env snap + params: sp(1, 0.5, 0.0, 0.9, + 2, 0.75, 0.0, 0.3, 0.5, + 0.0, + 0.00, 0.10, 0.0, 0.05, + 0.00, 0.06, 0.0, 0.04, + 0, 0.55, 0.10, 0.50, + 0.0, 0.06, 0.0, 0.04, + 0.80), + }, + SynthSoundPreset { + name: "Metallic Pluck", category: "Pluck", + // Sync saws create complex metallic harmonics, short decay + params: with_sync(sp( + 1, 0.5, 0.0, 0.8, + 1, 0.65, 0.0, 0.7, 0.5, + 0.0, + 0.00, 0.18, 0.0, 0.10, + 0.00, 0.15, 0.0, 0.08, + 0, 0.50, 0.18, 0.45, + 0.0, 0.12, 0.0, 0.08, + 0.76)), + }, + SynthSoundPreset { + name: "Koto", category: "Pluck", + // Bright sine, resonant filter env pluck, reverb+delay for room + params: with_sends(sp( + 2, 0.5, 0.0, 0.9, + 2, 0.75, 0.0, 0.2, 0.5, + 0.0, + 0.00, 0.22, 0.0, 0.12, + 0.00, 0.15, 0.0, 0.08, + 0, 0.45, 0.20, 0.55, + 0.0, 0.15, 0.0, 0.10, + 0.75), 0.20, 0.10), + }, + + // ── Stab (expanded) ───────────────────────────────────────────── + SynthSoundPreset { + name: "Rave Stab", category: "Stab", + // Huge detuned saws, aggressive filter env snap — hands in the air + params: with_sends(sp( + 1, 0.5, 0.0, 0.9, + 1, 0.5, 0.0, 0.8, 0.56, + 0.4, + 0.01, 0.08, 0.0, 0.06, + 0.01, 0.08, 0.0, 0.06, + 0, 0.25, 0.20, 0.65, + 0.0, 0.06, 0.0, 0.04, + 0.82), 0.15, 0.08), + }, + SynthSoundPreset { + name: "Tech Stab", category: "Stab", + // Saw + square, ultra-short, razor sharp filter env + params: sp(1, 0.5, 0.0, 0.9, + 0, 0.5, 0.5, 0.5, 0.5, + 0.2, + 0.00, 0.06, 0.0, 0.04, + 0.00, 0.06, 0.0, 0.04, + 0, 0.35, 0.25, 0.60, + 0.0, 0.04, 0.0, 0.03, + 0.80), + }, + SynthSoundPreset { + name: "Funk Stab", category: "Stab", + // Dual squares, snappy filter — clavinet-like funk hit + params: sp(0, 0.5, 0.5, 0.8, + 0, 0.5, 0.5, 0.5, 0.52, + 0.3, + 0.01, 0.10, 0.0, 0.06, + 0.01, 0.10, 0.0, 0.06, + 0, 0.35, 0.15, 0.55, + 0.0, 0.08, 0.0, 0.05, + 0.78), + }, + SynthSoundPreset { + name: "Dub Stab", category: "Stab", + // Square stab with heavy delay echo — roots reggae skank + params: with_sends(sp( + 0, 0.5, 0.5, 0.8, + 0, 0.5, 0.5, 0.5, 0.52, + 0.3, + 0.01, 0.10, 0.0, 0.08, + 0.01, 0.10, 0.0, 0.08, + 0, 0.40, 0.15, 0.50, + 0.0, 0.08, 0.0, 0.06, + 0.75), 0.20, 0.35), + }, + + // ── Keys (expanded) ───────────────────────────────────────────── + SynthSoundPreset { + name: "Rhodes", category: "Keys", + // Classic Fender Rhodes: sine harmonics, warm tremolo, key follow + params: with_lfo(with_key_follow(with_sends(sp( + 2, 0.5, 0.0, 0.7, + 2, 0.75, 0.0, 0.25, 0.5, + 0.0, + 0.01, 0.35, 0.55, 0.30, + 0.01, 0.25, 0.35, 0.20, + 0, 0.48, 0.08, 0.18, + 0.0, 0.25, 0.12, 0.20, + 0.72), 0.20, 0.0), 0.3), + 0, 0.47, 0.10, 10), // LFO: Sine, 1/4, tremolo → Volume + }, + SynthSoundPreset { + name: "DX Piano", category: "Keys", + // FM-style: sine + octave sine, bright attack, osc2 decays faster + params: with_key_follow(sp( + 2, 0.5, 0.0, 0.8, + 2, 0.75, 0.0, 0.4, 0.5, + 0.0, + 0.00, 0.30, 0.25, 0.18, + 0.00, 0.15, 0.0, 0.12, + 0, 0.58, 0.05, 0.30, + 0.0, 0.20, 0.0, 0.15, + 0.78), 0.4), + }, + SynthSoundPreset { + name: "Harpsichord", category: "Keys", + // Square + octave saw, bright, very short — plucked string + params: with_key_follow(sp( + 0, 0.5, 0.3, 0.8, + 1, 0.75, 0.0, 0.4, 0.5, + 0.0, + 0.00, 0.15, 0.0, 0.08, + 0.00, 0.10, 0.0, 0.06, + 0, 0.60, 0.15, 0.50, + 0.0, 0.08, 0.0, 0.05, + 0.78), 0.5), + }, + SynthSoundPreset { + name: "Celesta", category: "Keys", + // Octave sines, short, reverby — crystalline bell-like + params: with_sends(sp( + 2, 0.5, 0.0, 0.7, + 2, 0.75, 0.0, 0.35, 0.5, + 0.0, + 0.00, 0.20, 0.0, 0.12, + 0.00, 0.12, 0.0, 0.08, + 0, 0.62, 0.05, 0.20, + 0.0, 0.10, 0.0, 0.08, + 0.72), 0.30, 0.15), + }, + SynthSoundPreset { + name: "Vibes", category: "Keys", + // Sine + octave, motor tremolo, reverb — jazz vibraphone + params: with_lfo(with_sends(sp( + 2, 0.5, 0.0, 0.8, + 2, 0.75, 0.0, 0.3, 0.5, + 0.0, + 0.00, 0.35, 0.3, 0.20, + 0.00, 0.25, 0.2, 0.15, + 0, 0.55, 0.08, 0.15, + 0.0, 0.20, 0.1, 0.15, + 0.72), 0.25, 0.10), + 0, 0.47, 0.12, 10), // LFO: Sine, 1/4, tremolo → Volume + }, + + // ── Drums (expanded) ──────────────────────────────────────────── + SynthSoundPreset { + name: "TR-909 Kick", category: "Drums", + // Sine body + noise click layer, fast env — punchy kick + params: sp(2, 0.25, 0.0, 1.0, + 3, 0.5, 0.5, 0.3, 0.5, + 0.6, + 0.00, 0.18, 0.0, 0.02, + 0.00, 0.04, 0.0, 0.01, + 0, 0.50, 0.0, 0.35, + 0.0, 0.08, 0.0, 0.02, + 0.88), + }, + SynthSoundPreset { + name: "Gabber Kick", category: "Drums", + // Very long sine sub, resonant LP, strong filter env — distorted EDM kick + params: sp(2, 0.22, 0.0, 1.0, + 2, 0.5, 0.0, 0.0, 0.5, + 0.8, + 0.00, 0.55, 0.0, 0.08, + 0.00, 0.35, 0.0, 0.05, + 0, 0.30, 0.40, 0.50, + 0.0, 0.20, 0.0, 0.08, + 0.90), + }, + SynthSoundPreset { + name: "Brush Hit", category: "Drums", + // Filtered noise, very short — percussive texture + params: sp(3, 0.5, 0.6, 0.8, + 3, 0.5, 0.4, 0.3, 0.5, + 0.0, + 0.00, 0.06, 0.0, 0.02, + 0.00, 0.04, 0.0, 0.01, + 0, 0.50, 0.15, 0.30, + 0.0, 0.04, 0.0, 0.01, + 0.72), + }, + SynthSoundPreset { + name: "Cymbal", category: "Drums", + // Noise + high sine for metallic shimmer, HP filtered + params: with_sends(sp( + 3, 0.5, 0.5, 0.7, + 2, 0.80, 0.0, 0.3, 0.5, + 0.0, + 0.00, 0.40, 0.0, 0.25, + 0.00, 0.25, 0.0, 0.15, + 1, 0.30, 0.10, 0.20, + 0.0, 0.30, 0.0, 0.15, + 0.65), 0.20, 0.0), + }, + SynthSoundPreset { + name: "Laser Drum", category: "Drums", + // Sine, very strong filter env sweep — pew pew + params: sp(2, 0.70, 0.0, 1.0, + 2, 0.5, 0.0, 0.0, 0.5, + 0.0, + 0.00, 0.08, 0.0, 0.03, + 0.00, 0.06, 0.0, 0.02, + 0, 0.75, 0.0, 0.85, + 0.0, 0.05, 0.0, 0.02, + 0.78), + }, + + // ── FX (expanded) ─────────────────────────────────────────────── + SynthSoundPreset { + name: "Space Debris", category: "FX", + // Noise + sine, heavy reverb+delay, LFO1 sweeps filter, LFO2 tremolo + params: with_lfos(with_sends(sp( + 3, 0.5, 0.6, 0.6, + 2, 0.5, 0.0, 0.3, 0.5, + 0.0, + 0.40, 0.5, 0.4, 0.50, + 0.40, 0.5, 0.4, 0.50, + 0, 0.35, 0.30, 0.15, + 0.3, 0.6, 0.2, 0.4, + 0.55), 0.40, 0.35), + 0, 0.82, 0.20, 0, // LFO1: Sine, 2bar, → Filter + 1, 0.47, 0.15, 10), // LFO2: Tri, 1/4, → Volume + }, + SynthSoundPreset { + name: "Alarm", category: "FX", + // Square, fast square-wave LFO on pitch — alternating two-tone alarm + params: with_lfo(sp( + 0, 0.5, 0.5, 0.9, + 0, 0.5, 0.5, 0.0, 0.5, + 0.0, + 0.01, 0.1, 1.0, 0.1, + 0.01, 0.1, 1.0, 0.1, + 0, 0.60, 0.0, 0.0, + 0.0, 0.1, 0.0, 0.05, + 0.70), + 4, 0.12, 0.50, 2), // LFO: Square, 1/16, strong → Pitch + }, + SynthSoundPreset { + name: "Sweep Up", category: "FX", + // Detuned saws, very long attack, rising filter — tension builder + params: sp(1, 0.5, 0.0, 0.7, + 1, 0.5, 0.0, 0.5, 0.53, + 0.3, + 0.85, 0.1, 0.8, 0.30, + 0.85, 0.1, 0.8, 0.30, + 0, 0.15, 0.40, 0.60, + 0.80, 0.1, 0.7, 0.3, + 0.65), + }, + SynthSoundPreset { + name: "Bit Rain", category: "FX", + // BP-filtered noise, slow filter LFO, heavy delay — digital rain + params: with_lfo(with_sends(sp( + 3, 0.5, 0.4, 0.7, + 3, 0.5, 0.6, 0.3, 0.5, + 0.0, + 0.01, 0.3, 0.5, 0.30, + 0.01, 0.3, 0.5, 0.30, + 2, 0.40, 0.35, 0.20, + 0.0, 0.5, 0.2, 0.3, + 0.55), 0.20, 0.35), + 0, 0.76, 0.20, 0), // LFO: Sine, 1bar, → Filter + }, + SynthSoundPreset { + name: "Tape Stop", category: "FX", + // Saws, SawDown LFO on pitch — decelerating turntable effect + params: with_lfo(sp( + 1, 0.5, 0.0, 0.8, + 1, 0.5, 0.0, 0.5, 0.52, + 0.3, + 0.01, 0.1, 0.9, 0.3, + 0.01, 0.1, 0.9, 0.3, + 0, 0.45, 0.10, 0.0, + 0.0, 0.2, 0.0, 0.2, + 0.65), + 2, 0.76, 0.40, 2), // LFO: SawDown, 1bar, strong → Pitch + }, + SynthSoundPreset { + name: "Metallic Ring", category: "FX", + // Hard sync + high resonance creates bell-like metallic ringing + params: with_lfo(with_sync(with_sends(sp( + 1, 0.5, 0.0, 0.7, + 1, 0.70, 0.0, 0.7, 0.5, + 0.0, + 0.01, 0.3, 0.5, 0.25, + 0.01, 0.3, 0.5, 0.25, + 0, 0.45, 0.65, 0.30, + 0.0, 0.4, 0.2, 0.3, + 0.62), 0.25, 0.15)), + 0, 0.47, 0.15, 5), // LFO: Sine, 1/4, → Osc2 Tune (moving sync) + }, + SynthSoundPreset { + name: "Crystal", category: "FX", + // Folded sine + octave, short, heavy reverb+delay — sparkling + params: with_sends(sp( + 2, 0.5, 0.35, 0.7, + 2, 0.75, 0.20, 0.4, 0.5, + 0.0, + 0.00, 0.25, 0.0, 0.15, + 0.00, 0.15, 0.0, 0.10, + 0, 0.60, 0.10, 0.30, + 0.0, 0.15, 0.0, 0.10, + 0.65), 0.35, 0.25), + }, + SynthSoundPreset { + name: "Submarine", category: "FX", + // Deep sine, sub, slow pitch LFO — underwater sonar + params: with_lfo(sp( + 2, 0.35, 0.0, 0.9, + 2, 0.35, 0.0, 0.0, 0.5, + 0.5, + 0.10, 0.2, 0.8, 0.3, + 0.10, 0.2, 0.8, 0.3, + 0, 0.30, 0.10, 0.0, + 0.0, 0.3, 0.0, 0.2, + 0.70), + 0, 0.82, 0.12, 2), // LFO: Sine, 2bar, → Pitch + }, ]; pub fn categories() -> Vec<&'static str> { diff --git a/src/ui/drum_grid.rs b/src/ui/drum_grid.rs index 74c5e1a..cae8274 100644 --- a/src/ui/drum_grid.rs +++ b/src/ui/drum_grid.rs @@ -8,7 +8,6 @@ use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; use crate::sequencer::drum_pattern::{MAX_STEPS, NUM_DRUM_TRACKS, TRACK_IDS}; -use crate::sequencer::transport::PlayState; use crate::ui::theme; /// Track name column: ">Cowbell " or " Cowbell " = 9 chars (padded to longest name) @@ -19,7 +18,7 @@ const NAME_WIDTH: usize = 9; pub fn render_drum_grid(f: &mut Frame, area: Rect, app: &App) { let focused_grid = app.ui.focus == FocusSection::DrumGrid; let border_style = theme::focus_border_style(focused_grid); - let is_playing = app.transport.state == PlayState::Playing; + let is_playing = app.is_playing(); let pattern_name = app.current_pattern_name(); let pattern_num = app.ui.active_pattern + 1; diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs index a23dcf7..126c99d 100644 --- a/src/ui/help_overlay.rs +++ b/src/ui/help_overlay.rs @@ -90,7 +90,7 @@ pub fn render_help(f: &mut Frame, area: Rect) { "~", "Spectrum/VU", "Shift+D", "Sidechain"), row3("( / )", "Crossfader A/B", - "", "", + "F3", "Toggle Link", "", ""), Line::from(Span::raw("")), hdr("🔀 Patterns & Kits", "🎹 Synth A + B", "💾 File / Project"), diff --git a/src/ui/synth_grid.rs b/src/ui/synth_grid.rs index 6712e36..47bc754 100644 --- a/src/ui/synth_grid.rs +++ b/src/ui/synth_grid.rs @@ -9,7 +9,6 @@ use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; use crate::messages::SynthId; use crate::sequencer::synth_pattern::MAX_STEPS; -use crate::sequencer::transport::PlayState; use crate::ui::theme; /// Track name column width, matching drum grid (padded to longest name "Cowbell") @@ -104,7 +103,7 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App, synth_id: SynthId } let playback_step = ui_state.playback_step; - let is_playing = app.transport.state == PlayState::Playing; + let is_playing = app.is_playing(); // Multi-step note tracking: `covered_until` holds the last step index covered by // the current note's length. Steps within that range render as continuation bars diff --git a/src/ui/theme.rs b/src/ui/theme.rs index e48a391..7775afb 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -30,6 +30,7 @@ pub const CURSOR_FG: Color = BG; pub const MUTED_COLOR: Color = Color::Red; pub const SOLOED_COLOR: Color = Color::Green; +pub const LINK_ACTIVE: Color = Color::Rgb(0, 200, 100); // #00c864 green pub const BEAT_LED_ON: Color = CYAN; pub const BEAT_LED_OFF: Color = BORDER; pub const BAR_START_LED: Color = Color::Red; diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 3e5b3f2..7a395f8 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -31,19 +31,24 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { .border_style(border_style); // ── Line 1: Play state + BPM + Beat LEDs + Swing + Record ──── - let play_icon = match app.transport.state { - PlayState::Playing => Span::styled( + // When Link is driving playback, `transport.state` stays Stopped on the + // follower — fall back to `app.is_playing()` so the icon reflects reality. + let play_icon = if app.is_playing() { + Span::styled( "\u{25B6} PLAY ", Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD), - ), - PlayState::Paused => Span::styled( - "\u{23F8} PAUSE", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), - ), - PlayState::Stopped => Span::styled( - "\u{25A0} STOP ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), + ) + } else { + match app.transport.state { + PlayState::Paused => Span::styled( + "\u{23F8} PAUSE", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ), + _ => Span::styled( + "\u{25A0} STOP ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + } }; let bpm_span = Span::styled( @@ -52,7 +57,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { ); // Beat LEDs: filled cyan for active, hollow dim for inactive, bar start = red - let is_playing = app.transport.state == PlayState::Playing; + let is_playing = app.is_playing(); let beat = app.ui.current_beat as usize; let beat_spans: Vec = (0..4) .flat_map(|i| { @@ -93,9 +98,34 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { ), }; + // Link indicator (green = connected, amber = enabled no peers, dim = off) + #[cfg(feature = "link")] + let link_span = { + if app.ui.link_enabled { + if app.ui.link_peers > 0 { + Span::styled( + format!(" LINK:{}", app.ui.link_peers), + Style::default().fg(theme::LINK_ACTIVE).add_modifier(Modifier::BOLD), + ) + } else { + Span::styled( + " LINK:0", + Style::default().fg(theme::GOLD).add_modifier(Modifier::BOLD), + ) + } + } else { + Span::styled( + " LINK", + Style::default().fg(theme::DIM_TEXT), + ) + } + }; + let mut top_spans = vec![play_icon, bpm_span]; top_spans.extend(beat_spans); top_spans.extend(vec![swing_span, rec_span]); + #[cfg(feature = "link")] + top_spans.push(link_span); let top_line = Line::from(top_spans);