From a6ff19bb086576ff4d0eb0ae743bcb994de3f03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Tue, 24 Feb 2026 13:13:56 +0100 Subject: [PATCH] Feat: internal recording / overdubbing --- crates/forth/src/ops.rs | 5 ++ crates/forth/src/vm.rs | 18 +++++ crates/forth/src/words/compile.rs | 4 + crates/forth/src/words/sound.rs | 41 ++++++++++ docs/tutorials/recording.md | 122 ++++++++++++++++++++++++++++++ src/model/docs.rs | 4 + tests/forth/sound.rs | 18 +++++ 7 files changed, 212 insertions(+) create mode 100644 docs/tutorials/recording.md diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 45cfe29..d0c81e9 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -140,6 +140,11 @@ pub enum Op { MidiStart, MidiStop, MidiContinue, + // Recording + Rec, + Overdub, + Orec, + Odub, // Bracket syntax (mark/count for auto-counting) Mark, Count(Option), diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index aed9264..6497820 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1610,6 +1610,24 @@ impl Forth { } drain_select_run(count, resolved_idx, stack, outputs, cmd)?; } + Op::Rec => { + let name = pop(stack)?; + outputs.push(format!("/doux/rec/sound/{}", name.as_str()?)); + } + Op::Overdub => { + let name = pop(stack)?; + outputs.push(format!("/doux/rec/sound/{}/overdub/1", name.as_str()?)); + } + Op::Orec => { + let orbit = pop(stack)?.as_int()?; + let name = pop(stack)?; + outputs.push(format!("/doux/rec/sound/{}/orbit/{}", name.as_str()?, orbit)); + } + Op::Odub => { + let orbit = pop(stack)?.as_int()?; + let name = pop(stack)?; + outputs.push(format!("/doux/rec/sound/{}/overdub/1/orbit/{}", name.as_str()?, orbit)); + } Op::Forget => { let name = pop(stack)?; self.dict.lock().remove(name.as_str()?); diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 7819a5a..8b92402 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -113,6 +113,10 @@ pub(super) fn simple_op(name: &str) -> Option { "mstart" => Op::MidiStart, "mstop" => Op::MidiStop, "mcont" => Op::MidiContinue, + "rec" => Op::Rec, + "overdub" | "dub" => Op::Overdub, + "orec" => Op::Orec, + "odub" => Op::Odub, "forget" => Op::Forget, "index" => Op::Index(None), "key!" => Op::SetKey, diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index b5249b6..1c9ed0a 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -63,6 +63,47 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + // Recording + Word { + name: "rec", + aliases: &[], + category: "Sound", + stack: "(name --)", + desc: "Toggle recording audio output to named sample", + example: "\"loop1\" rec", + compile: Simple, + varargs: false, + }, + Word { + name: "overdub", + aliases: &["dub"], + category: "Sound", + stack: "(name --)", + desc: "Toggle overdub recording onto existing named sample", + example: "\"loop1\" overdub", + compile: Simple, + varargs: false, + }, + Word { + name: "orec", + aliases: &[], + category: "Sound", + stack: "(name orbit --)", + desc: "Toggle recording a single orbit into named sample", + example: "\"drums\" 0 orec", + compile: Simple, + varargs: false, + }, + Word { + name: "odub", + aliases: &[], + category: "Sound", + stack: "(name orbit --)", + desc: "Toggle overdub recording a single orbit onto named sample", + example: "\"drums\" 0 odub", + compile: Simple, + varargs: false, + }, // Sample Word { name: "bank", diff --git a/docs/tutorials/recording.md b/docs/tutorials/recording.md new file mode 100644 index 0000000..dd30c87 --- /dev/null +++ b/docs/tutorials/recording.md @@ -0,0 +1,122 @@ +# Recording + +Live recording captures the master output into a sample. One word to start, the same word to stop. The result is a first-class sample you can play, slice, and layer. + +## rec + +`rec` takes a name from the stack and toggles recording. Everything the engine outputs goes into a buffer until you call `rec` again: + +```forth +"drums" rec ;; start recording +``` + +Play something -- a pattern, a live input, anything that makes sound. When you're done: + +```forth +"drums" rec ;; stop recording, register sample +``` + +The recording is now available as a sample: + +```forth +drums s . +``` + +## Playback + +Recorded samples are ordinary samples. Everything you can do with a loaded sample works here: + +```forth +drums s 0.5 speed . ;; half speed +drums s 0.25 begin 0.5 end . ;; slice the middle quarter +drums s 800 lpf 0.3 verb . ;; filter and reverb +drums s -1 speed . ;; reverse +``` + +## Overdub + +`overdub` (or `dub`) layers new audio on top of an existing recording. It wraps at the buffer boundary, so the loop length stays fixed: + +```forth +"drums" overdub ;; start layering onto drums +``` + +Play new material over the existing content. Stop with the same call: + +```forth +"drums" overdub ;; stop, register updated sample +``` + +If the target name doesn't exist yet, overdub falls back to a fresh recording. + +## Building Layers + +Record a foundation, then overdub to build up: + +```forth +;; 1. record a kick pattern +"loop" rec +;; ... play kick pattern ... +"loop" rec + +;; 2. overdub hats +"loop" dub +;; ... play hat pattern ... +"loop" dub + +;; 3. overdub a melody +"loop" dub +;; ... play melody ... +"loop" dub + +;; 4. play the result +loop s . +``` + +Each overdub pass adds to what's already there. The buffer wraps, so longer passes layer cyclically over the original length. + +## Slicing a Recording + +Once you have a recording, carve it up: + +```forth +loop s 0.0 begin 0.25 end . ;; first quarter +loop s 0.25 begin 0.5 end . ;; second quarter +loop s 0.5 begin 0.75 end . ;; third quarter +loop s 0.75 begin 1.0 end . ;; last quarter +``` + +Combine with randomness for variation: + +```forth +loop s +0.0 0.25 0.5 0.75 4 choose begin +0.5 speed +. +``` + +## Orbit Recording + +`orec` records a single orbit instead of the master output. Useful for capturing one layer (e.g. drums on orbit 0) without bleeding other orbits in: + +```forth +"drums" 0 orec ;; start recording orbit 0 +``` + +Play patterns routed to orbit 0. When done: + +```forth +"drums" 0 orec ;; stop, register sample +``` + +`odub` overdubs onto a single orbit: + +```forth +"drums" 0 odub ;; overdub orbit 0 onto "drums" +``` + +`rec` and `overdub` still record the master output as before. + +## Constraints + +Recordings have a 60-second maximum. Recording is native only -- not available in the WASM build. diff --git a/src/model/docs.rs b/src/model/docs.rs index f736ae1..e0bc63a 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -119,6 +119,10 @@ pub const DOCS: &[DocEntry] = &[ "Using Variables", include_str!("../../docs/tutorials/variables.md"), ), + Topic( + "Recording", + include_str!("../../docs/tutorials/recording.md"), + ), ]; pub fn topic_count() -> usize { diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index 1f80de3..d5e1503 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -227,6 +227,24 @@ fn noall_clears_across_evaluations() { assert!(!outputs[0].contains("lpf"), "lpf should be cleared: {}", outputs[0]); } +#[test] +fn rec() { + let outputs = expect_outputs(r#""loop1" rec"#, 1); + assert_eq!(outputs[0], "/rec/rec/sound/loop1"); +} + +#[test] +fn overdub() { + let outputs = expect_outputs(r#""loop1" overdub"#, 1); + assert_eq!(outputs[0], "/rec/rec/sound/loop1/overdub/1"); +} + +#[test] +fn overdub_alias_dub() { + let outputs = expect_outputs(r#""loop1" dub"#, 1); + assert_eq!(outputs[0], "/rec/rec/sound/loop1/overdub/1"); +} + #[test] fn all_replaces_previous_global() { let f = forth();