From 663a57408ad69b5020262884444fba2e3f1f7733 Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 11 Nov 2026 08:53:47 +0000 Subject: [PATCH 1/2] Lowering: patch repeat restarts pitch slide and arpeggio, not the envelope; README one-way store note age now always runs; each voice wraps a pitch age at repeat_interval for the pitch ramp and arpeggio (as sfxr does). Powerup (repeat 0.113 s vs 0.31 s envelope) now reaches its release. Also changes bfxr/ui-bloom, advanced/ritual-sequence, examples/language-tour. README: stores written after the CultLib pin move are one-way; older 1.x pins do not refuse them. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 + src/AquaSynth.Core/FaustExport.cs | 15 ++++--- .../RepeatLoweringTests.cs | 44 +++++++++++++++++++ tools/aqua-feel/SHELF.md | 8 ++-- tools/aqua-feel/shelf.json | 8 ++-- .../lowered/advanced-ritual-sequence.dsp | 21 +++++---- .../aqua-playground/lowered/bfxr-ui-bloom.dsp | 13 +++--- .../lowered/examples-language-tour.dsp | 18 +++++--- .../aqua-playground/lowered/sfxr-powerup.dsp | 8 ++-- 9 files changed, 99 insertions(+), 38 deletions(-) create mode 100644 tests/AquaSynth.Dsl.Tests/RepeatLoweringTests.cs diff --git a/README.md b/README.md index da440b3..bc5422c 100644 --- a/README.md +++ b/README.md @@ -164,3 +164,5 @@ dotnet build -p:CultLibRoot= # or set CULTLIB_ROOT= ``` Without an override the sibling `..\CultLib` checkout is used and must itself be at that revision. + +**Stores are one-way, and rolling back is dangerous.** Since the pin move (#35) list elements carry ids. A store holding any element id has header v3 (v5 for a directory store); a store with none keeps its old header. CultLib 1.x pins (`cultlib-unity-v1.0.61`, `cultlib-v1.0.32`) do not refuse such a store: they read the header string without comparing it, so a rolled-back build silently drops the ids and can rewrite elements without them. Refusal by name starts only at `cultlib-unity-v2.0.0`. So: keep a copy of any store taken before the upgrade, upgrade every reader before any writer, and do not roll the pin back after writing. Stores written before the move still open; ids are minted on load. diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index ca98116..ca70c36 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -29,12 +29,10 @@ public static partial class FaustEmitter if (patch.Repeat is { } repeat) { var interval = parameters.Expression("/patch/repeat", repeat.IntervalSeconds); - source.AppendLine($"age = time - floor(time / {interval}) * {interval};"); - } - else - { - source.AppendLine("age = time;"); + // sfxr's repeat restarts the pitch slide and the arpeggio, not the envelope: age keeps running, the voices wrap their pitch age. + source.AppendLine($"repeat_interval = {interval};"); } + source.AppendLine("age = time;"); source.AppendLine("clip01(x) = min(1.0, max(0.0, x));"); source.AppendLine("wrap01(x) = x - floor(x);"); source.AppendLine("slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR));"); @@ -325,6 +323,9 @@ public static partial class FaustEmitter ? $"{voiceAge} = wrap01(age / {F(voice.Note.TriggerIntervalSeconds)}) * {F(voice.Note.TriggerIntervalSeconds)};" : $"{voiceAge} = age;"); + var pitchAge = patch.Repeat is null ? voiceAge : $"{name}_pitch_age"; + if (patch.Repeat is not null) source.AppendLine($"{pitchAge} = {voiceAge} - floor({voiceAge} / repeat_interval) * repeat_interval;"); + var minFreq = parameters.Expression(OwnerField(ownerPath, "pitch/min_freq"), voice.Pitch.MinFrequencyHz); var noteFreq = NoteFrequencyExpression(source, patch.Playback, voice.Note, name, OwnerField(ownerPath, "note/frequency"), parameters); var noteGate = NoteGateExpression(source, patch.Playback, voice.Note, name, OwnerField(ownerPath, "note/gate"), parameters); @@ -333,7 +334,7 @@ public static partial class FaustEmitter var vibratoDepth = parameters.Expression(OwnerField(ownerPath, "pitch/vibrato"), voice.Pitch.VibratoDepth); var vibratoHz = parameters.Expression(OwnerField(ownerPath, "pitch/vibrato_hz"), voice.Pitch.VibratoHz); var vibratoDelay = parameters.Expression(OwnerField(ownerPath, "pitch/vibrato_delay"), voice.Pitch.VibratoDelaySeconds); - var baseFreq = $"max({minFreq}, {noteFreq} * pow(2.0, {pitchRamp} * {voiceAge} + 0.5 * {pitchDelta} * {voiceAge} * {voiceAge}))"; + var baseFreq = $"max({minFreq}, {noteFreq} * pow(2.0, {pitchRamp} * {pitchAge} + 0.5 * {pitchDelta} * {pitchAge} * {pitchAge}))"; var hasVibrato = voice.Pitch.VibratoDepth != 0 && voice.Pitch.VibratoHz > 0 || parameters.IsBound(OwnerField(ownerPath, "pitch/vibrato")) || parameters.IsBound(OwnerField(ownerPath, "pitch/vibrato_hz")); @@ -342,7 +343,7 @@ public static partial class FaustEmitter : ""; var arpeggio = voice.Arpeggio is null ? "1.0" - : $"select2({voiceAge} < {parameters.Expression(OwnerField(ownerPath, "arpeggio/delay"), voice.Arpeggio.DelaySeconds)}, {parameters.Expression(OwnerField(ownerPath, "arpeggio/multiplier"), voice.Arpeggio.Multiplier)}, 1.0)"; + : $"select2({pitchAge} < {parameters.Expression(OwnerField(ownerPath, "arpeggio/delay"), voice.Arpeggio.DelaySeconds)}, {parameters.Expression(OwnerField(ownerPath, "arpeggio/multiplier"), voice.Arpeggio.Multiplier)}, 1.0)"; var frequency = $"(({baseFreq}){vibrato}) * {arpeggio} * pow(2.0, patch_mod_pitch + {pitch})"; var dutyExpression = $"clip01({parameters.Expression(OwnerField(ownerPath, "osc/duty"), voice.Oscillator.Duty)} + {parameters.Expression(OwnerField(ownerPath, "duty/ramp"), voice.Duty.RampPerSecond)} * {voiceAge} + patch_mod_duty + {duty})"; var fmIndex = $"max(0.0, {parameters.Expression(OwnerField(ownerPath, "fm/index"), voice.Fm.Index)} + patch_mod_fm_index + {fmIndexMod}) * {FmDecay(parameters.Expression(OwnerField(ownerPath, "fm/decay"), voice.Fm.IndexDecaySeconds), voice.Fm.IndexDecaySeconds, parameters.IsBound(OwnerField(ownerPath, "fm/decay")), voiceAge)}"; diff --git a/tests/AquaSynth.Dsl.Tests/RepeatLoweringTests.cs b/tests/AquaSynth.Dsl.Tests/RepeatLoweringTests.cs new file mode 100644 index 0000000..3e1c65e --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/RepeatLoweringTests.cs @@ -0,0 +1,44 @@ +using AquaSynth.Dsl; + +namespace AquaSynth.Dsl.Tests; + +// sfxr's repeat restarts the pitch slide and the arpeggio; it does not restart the envelope. Before this, `patch repeat` wrapped the +// whole age, so a repeat shorter than sustain+decay never reached the release (the 2026-11-11 explosion/golf/powerup finding). +public sealed class RepeatLoweringTests +{ + private const string Script = """ + patch repeat=0.11315 + voice + wave=sine + freq=57.5946 + sustain=0.1306122449 + decay=0.1777777778 + pitch_ramp=-0.208544 + arp_delay=0.05 + arp_mult=1.5 + """; + + [Fact] + public void RepeatWrapsPitchAndArpeggioAgeButNotTheEnvelope() + { + var source = FaustEmitter.EmitScript(Script).Source; + + Assert.Contains("age = time;", source); + Assert.DoesNotContain("floor(time /", source); + Assert.Contains("voice_0_pitch_age = voice_0_age - floor(voice_0_age / repeat_interval) * repeat_interval;", source); + Assert.Contains("0.208544", source); + Assert.Contains("* voice_0_pitch_age", source); + Assert.Contains("select2(voice_0_pitch_age <", source); + Assert.Contains("oneshot_adsr_at(voice_0_age,", source); + Assert.DoesNotContain("oneshot_adsr_at(voice_0_pitch_age", source); + } + + [Fact] + public void WithoutRepeatThereIsNoPitchAge() + { + var source = FaustEmitter.EmitScript(Script.Replace("patch repeat=0.11315\n", "")).Source; + + Assert.DoesNotContain("pitch_age", source); + Assert.DoesNotContain("repeat_interval", source); + } +} diff --git a/tools/aqua-feel/SHELF.md b/tools/aqua-feel/SHELF.md index 1727e78..b57964f 100644 --- a/tools/aqua-feel/SHELF.md +++ b/tools/aqua-feel/SHELF.md @@ -85,18 +85,18 @@ Sustained patches such as the thruster keep sounding until stopped; the render c | `patch/advanced-glass-creature` | start | none | decays | 3 s | unheard | | `patch/advanced-human-larynx-graph` | start | curves/breath_phrase/depth 0..1 (default 0.9); curves/fold_onset/depth 0..1 (default 0.8); curves/lip_vowel/depth 0..1 (default 0.65); human/constriction/diameter 0.02..3 (default 0.95); human/constriction/index 0..6 (default 4.8); human/fold/opening 0..1 (default 0.16); human/fold/tension 0..1 (default 0.58); human/lip/opening 0..2 (default 1.2); human/subglottal 0..1.5 (default 0.78); human/tongue/diameter 0.05..3 (default 1.25); human/tongue/index 0..6 (default 3.3) | decays | 3 s | unheard | | `patch/advanced-machine-breath` | start | none | decays | 3 s | unheard | -| `patch/advanced-ritual-sequence` | start | none | sustained | 3 s | unheard | +| `patch/advanced-ritual-sequence` | start | none | decays | 3 s | unheard | | `patch/advanced-subtractive-drums` | start | none | decays | 3 s | unheard | | `patch/advanced-syrinx-voice` | start | curves/beak_phrase/depth 0..1 (default 0.72); curves/left_phrase_opening/depth 0..1 (default 0.85); curves/left_phrase_pressure/depth 0..1 (default 0.92); curves/right_phrase_opening/depth 0..1 (default 0.85); curves/right_phrase_pressure/depth 0..1 (default 0.92); voice/beak/opening 0..1.5 (default 0.88); voice/left/opening 0..1 (default 0.23); voice/left/pressure 0..1.5 (default 0.78); voice/left/tension 0..1 (default 0.43); voice/right/opening 0..1 (default 0.19); voice/right/pressure 0..1.5 (default 0.66); voice/right/tension 0..1 (default 0.5) | decays | 3 s | unheard | | `patch/bfxr-coin-spark` | start | none | decays | 3 s | unheard | | `patch/bfxr-portal-chirp` | start | none | decays | 3 s | unheard | | `patch/bfxr-shield-pop` | start | none | decays | 3 s | unheard | -| `patch/bfxr-ui-bloom` | start | none | sustained | 3 s | unheard | +| `patch/bfxr-ui-bloom` | start | none | decays | 3 s | unheard | | `patch/dx7-algorithm-32-additive-organ` | start | macro/drawbar 0..1 (default 0.78); macro/shimmer 0..0.3 (default 0.08) | decays | 3 s | unheard | | `patch/dx7-algorithm-8-bright-pair` | start | macro/brightness 0..5 (default 2.6); macro/strike 0.02..0.6 (default 0.18) | decays | 3 s | unheard | | `patch/dx7-public-domain-mc-mm-5-3` | start | none | decays | 3 s | unheard | | `patch/dx7-public-domain-prc-synth1-calibrated` | start | none | decays | 3 s | unheard | -| `patch/examples-language-tour` | start | none | sustained | 3 s | unheard | +| `patch/examples-language-tour` | start | none | decays | 3 s | unheard | | `patch/fm-bell-bell` | start | none | decays | 3 s | unheard | | `patch/fm-bell-chime` | start | none | decays | 3 s | unheard | | `patch/fm-bell-coin` | start | none | decays | 3 s | unheard | @@ -108,7 +108,7 @@ Sustained patches such as the thruster keep sounding until stopped; the render c | `patch/sfxr-jump` | start | none | decays | 3 s | unheard | | `patch/sfxr-laser` | start | none | decays | 3 s | unheard | | `patch/sfxr-pickup` | start | none | decays | 3 s | unheard | -| `patch/sfxr-powerup` | start | none | sustained | 3 s | unheard | +| `patch/sfxr-powerup` | start | none | decays | 3 s | unheard | | `patch/wobble-bass-growl` | start | none | decays | 3 s | unheard | | `patch/wobble-bass-neuro` | start | none | decays | 3 s | unheard | | `patch/wobble-bass-talker` | start | none | decays | 3 s | unheard | diff --git a/tools/aqua-feel/shelf.json b/tools/aqua-feel/shelf.json index c6e9e7e..6d8297f 100644 --- a/tools/aqua-feel/shelf.json +++ b/tools/aqua-feel/shelf.json @@ -859,7 +859,7 @@ "kind": "start", "detail": "plays from the moment it is started; no gate, trigger or audio-feature input. Sliders, where listed, are live while it plays." }, - "behaviour": "sustained: still sounding at the end of the render window, runs until stopped", + "behaviour": "decays: dies away inside the render window", "duration_s": 3, "duration_note": "the render window, not the effect's length", "render": { @@ -1106,7 +1106,7 @@ "kind": "start", "detail": "plays from the moment it is started; no gate, trigger or audio-feature input. Sliders, where listed, are live while it plays." }, - "behaviour": "sustained: still sounding at the end of the render window, runs until stopped", + "behaviour": "decays: dies away inside the render window", "duration_s": 3, "duration_note": "the render window, not the effect's length", "render": { @@ -1265,7 +1265,7 @@ "kind": "start", "detail": "plays from the moment it is started; no gate, trigger or audio-feature input. Sliders, where listed, are live while it plays." }, - "behaviour": "sustained: still sounding at the end of the render window, runs until stopped", + "behaviour": "decays: dies away inside the render window", "duration_s": 3, "duration_note": "the render window, not the effect's length", "render": { @@ -1565,7 +1565,7 @@ "kind": "start", "detail": "plays from the moment it is started; no gate, trigger or audio-feature input. Sliders, where listed, are live while it plays." }, - "behaviour": "sustained: still sounding at the end of the render window, runs until stopped", + "behaviour": "decays: dies away inside the render window", "duration_s": 3, "duration_note": "the render window, not the effect's length", "render": { diff --git a/tools/aqua-playground/lowered/advanced-ritual-sequence.dsp b/tools/aqua-playground/lowered/advanced-ritual-sequence.dsp index b59626c..5c07841 100644 --- a/tools/aqua-playground/lowered/advanced-ritual-sequence.dsp +++ b/tools/aqua-playground/lowered/advanced-ritual-sequence.dsp @@ -3,7 +3,8 @@ declare name "advanced-ritual-sequence"; declare options "[nvoices:1]"; time = ba.time / ma.SR; -age = time - floor(time / 0.42) * 0.42; +repeat_interval = 0.42; +age = time; clip01(x) = min(1.0, max(0.0, x)); wrap01(x) = x - floor(x); slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR)); @@ -41,8 +42,9 @@ patch_mod_formant_mix = 0.0; patch_mod_fm_index = 0 + 0.75 * lfo_tri(2.38, 0); voice_0_age = age; -voice_0_freq = ((max(20, 196 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * select2(voice_0_age < 0.065, 1.4983, 1.0) * pow(2.0, patch_mod_pitch + 0.0); -voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 196 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * select2(voice_0_age < 0.065, 1.4983, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, 196 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * select2(voice_0_age < 0.065, 1.4983, 1.0) * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 2))) * (max(0.0, 1.8 + patch_mod_fm_index + 0.0) * exp(-voice_0_age / max(0.32, 0.0001))) / ma.PI)); +voice_0_pitch_age = voice_0_age - floor(voice_0_age / repeat_interval) * repeat_interval; +voice_0_freq = ((max(20, 196 * pow(2.0, 0 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age)))) * select2(voice_0_pitch_age < 0.065, 1.4983, 1.0) * pow(2.0, patch_mod_pitch + 0.0); +voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 196 * pow(2.0, 0 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age)))) * select2(voice_0_pitch_age < 0.065, 1.4983, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, 196 * pow(2.0, 0 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age)))) * select2(voice_0_pitch_age < 0.065, 1.4983, 1.0) * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 2))) * (max(0.0, 1.8 + patch_mod_fm_index + 0.0) * exp(-voice_0_age / max(0.32, 0.0001))) / ma.PI)); voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0); voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); @@ -52,8 +54,9 @@ voice_0_formants = voice_0_phased; voice_0 = ((voice_0_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0.001, 0, 0.8474576, 0.5, 0.045) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.1534); voice_1_age = age; -voice_1_freq = ((max(20, 293.66 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * select2(voice_1_age < 0.065, 1.3348, 1.0) * pow(2.0, patch_mod_pitch + 0.0); -voice_1_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 293.66 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * select2(voice_1_age < 0.065, 1.3348, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, 293.66 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * select2(voice_1_age < 0.065, 1.3348, 1.0) * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 2))) * (max(0.0, 1.8 + patch_mod_fm_index + 0.0) * exp(-voice_1_age / max(0.32, 0.0001))) / ma.PI)); +voice_1_pitch_age = voice_1_age - floor(voice_1_age / repeat_interval) * repeat_interval; +voice_1_freq = ((max(20, 293.66 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * select2(voice_1_pitch_age < 0.065, 1.3348, 1.0) * pow(2.0, patch_mod_pitch + 0.0); +voice_1_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 293.66 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * select2(voice_1_pitch_age < 0.065, 1.3348, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, 293.66 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * select2(voice_1_pitch_age < 0.065, 1.3348, 1.0) * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 2))) * (max(0.0, 1.8 + patch_mod_fm_index + 0.0) * exp(-voice_1_age / max(0.32, 0.0001))) / ma.PI)); voice_1_colored = (voice_1_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_1_driven = ma.tanh(voice_1_colored * (1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0); voice_1_folded = voice_1_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_1_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); @@ -63,8 +66,9 @@ voice_1_formants = voice_1_phased; voice_1 = ((voice_1_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_1_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_1_age, 0.001, 0, 0.8474576, 0.38, 0.035) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.09); voice_2_age = age; -voice_2_freq = ((max(20, 392 * pow(2.0, 0 * voice_2_age + 0.5 * 0 * voice_2_age * voice_2_age)))) * select2(voice_2_age < 0.065, 1.2599, 1.0) * pow(2.0, patch_mod_pitch + 0.0); -voice_2_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 392 * pow(2.0, 0 * voice_2_age + 0.5 * 0 * voice_2_age * voice_2_age)))) * select2(voice_2_age < 0.065, 1.2599, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, 392 * pow(2.0, 0 * voice_2_age + 0.5 * 0 * voice_2_age * voice_2_age)))) * select2(voice_2_age < 0.065, 1.2599, 1.0) * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 2))) * (max(0.0, 1.8 + patch_mod_fm_index + 0.0) * exp(-voice_2_age / max(0.32, 0.0001))) / ma.PI)); +voice_2_pitch_age = voice_2_age - floor(voice_2_age / repeat_interval) * repeat_interval; +voice_2_freq = ((max(20, 392 * pow(2.0, 0 * voice_2_pitch_age + 0.5 * 0 * voice_2_pitch_age * voice_2_pitch_age)))) * select2(voice_2_pitch_age < 0.065, 1.2599, 1.0) * pow(2.0, patch_mod_pitch + 0.0); +voice_2_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 392 * pow(2.0, 0 * voice_2_pitch_age + 0.5 * 0 * voice_2_pitch_age * voice_2_pitch_age)))) * select2(voice_2_pitch_age < 0.065, 1.2599, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0 + sin(2.0 * ma.PI * os.phasor(1.0, ((max(20, 392 * pow(2.0, 0 * voice_2_pitch_age + 0.5 * 0 * voice_2_pitch_age * voice_2_pitch_age)))) * select2(voice_2_pitch_age < 0.065, 1.2599, 1.0) * pow(2.0, patch_mod_pitch + 0.0) * max(0.0, 2))) * (max(0.0, 1.8 + patch_mod_fm_index + 0.0) * exp(-voice_2_age / max(0.32, 0.0001))) / ma.PI)); voice_2_colored = (voice_2_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_2_driven = ma.tanh(voice_2_colored * (1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0); voice_2_folded = voice_2_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_2_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); @@ -74,7 +78,8 @@ voice_2_formants = voice_2_phased; voice_2 = ((voice_2_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_2_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_2_age, 0.001, 0, 0.8474576, 0.32, 0.028) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.07); voice_3_age = age; -voice_3_freq = ((max(20, 3100 * pow(2.0, 0 * voice_3_age + 0.5 * 0 * voice_3_age * voice_3_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); +voice_3_pitch_age = voice_3_age - floor(voice_3_age / repeat_interval) * repeat_interval; +voice_3_freq = ((max(20, 3100 * pow(2.0, 0 * voice_3_pitch_age + 0.5 * 0 * voice_3_pitch_age * voice_3_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); voice_3_osc = no.noise; voice_3_colored = (voice_3_osc * (1.0 - clip01(1 + patch_mod_noise + 0.0)) + no.noise * clip01(1 + patch_mod_noise + 0.0)); voice_3_driven = ma.tanh(voice_3_colored * (1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.1 + patch_mod_drive + 0.0) * 12.0); diff --git a/tools/aqua-playground/lowered/bfxr-ui-bloom.dsp b/tools/aqua-playground/lowered/bfxr-ui-bloom.dsp index 2e48f40..c4236af 100644 --- a/tools/aqua-playground/lowered/bfxr-ui-bloom.dsp +++ b/tools/aqua-playground/lowered/bfxr-ui-bloom.dsp @@ -3,7 +3,8 @@ declare name "bfxr-ui-bloom"; declare options "[nvoices:1]"; time = ba.time / ma.SR; -age = time - floor(time / 0.18) * 0.18; +repeat_interval = 0.18; +age = time; clip01(x) = min(1.0, max(0.0, x)); wrap01(x) = x - floor(x); slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR)); @@ -41,8 +42,9 @@ patch_mod_formant_mix = 0.0; patch_mod_fm_index = 0.0; voice_0_age = age; -voice_0_freq = ((max(20, 392 * pow(2.0, 0.18 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * select2(voice_0_age < 0.08, 1.25, 1.0) * pow(2.0, patch_mod_pitch + 0.0); -voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 392 * pow(2.0, 0.18 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age)))) * select2(voice_0_age < 0.08, 1.25, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0)); +voice_0_pitch_age = voice_0_age - floor(voice_0_age / repeat_interval) * repeat_interval; +voice_0_freq = ((max(20, 392 * pow(2.0, 0.18 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age)))) * select2(voice_0_pitch_age < 0.08, 1.25, 1.0) * pow(2.0, patch_mod_pitch + 0.0); +voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 392 * pow(2.0, 0.18 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age)))) * select2(voice_0_pitch_age < 0.08, 1.25, 1.0) * pow(2.0, patch_mod_pitch + 0.0)) + 0)); voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0.06 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.06 + patch_mod_drive + 0.0) * 12.0); voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); @@ -52,8 +54,9 @@ voice_0_formants = voice_0_phased; voice_0 = ((voice_0_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0, 0, 0.8196721, 0.5, 0.05) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.1464); voice_1_age = age; -voice_1_freq = ((max(20, 588 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); -voice_1_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 588 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0)); +voice_1_pitch_age = voice_1_age - floor(voice_1_age / repeat_interval) * repeat_interval; +voice_1_freq = ((max(20, 588 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); +voice_1_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 588 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0)); voice_1_colored = (voice_1_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_1_driven = ma.tanh(voice_1_colored * (1.0 + clip01(0.06 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.06 + patch_mod_drive + 0.0) * 12.0); voice_1_folded = voice_1_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_1_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); diff --git a/tools/aqua-playground/lowered/examples-language-tour.dsp b/tools/aqua-playground/lowered/examples-language-tour.dsp index afced2b..f9f5a5f 100644 --- a/tools/aqua-playground/lowered/examples-language-tour.dsp +++ b/tools/aqua-playground/lowered/examples-language-tour.dsp @@ -3,7 +3,8 @@ declare name "examples-language-tour"; declare options "[nvoices:1]"; time = ba.time / ma.SR; -age = time - floor(time / 0.4739614) * 0.4739614; +repeat_interval = 0.4739614; +age = time; clip01(x) = min(1.0, max(0.0, x)); wrap01(x) = x - floor(x); slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR)); @@ -41,8 +42,9 @@ patch_mod_formant_mix = 0 + 0.08 * lfo_sin(5, 0) + 0 + 0.12 * lfo_tri(2, 0); patch_mod_fm_index = 0.0; voice_0_age = age; -voice_0_freq = ((max(20, 220 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 5) * 0.02))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); -voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 220 * pow(2.0, 0 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 5) * 0.02))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0)); +voice_0_pitch_age = voice_0_age - floor(voice_0_age / repeat_interval) * repeat_interval; +voice_0_freq = ((max(20, 220 * pow(2.0, 0 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 5) * 0.02))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); +voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 220 * pow(2.0, 0 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 5) * 0.02))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0)); voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0); voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); @@ -52,8 +54,9 @@ voice_0_formants = ((voice_0_phased : fi.resonbp(620, 6.888889, 1.0)) * 1 + (voi voice_0 = ((voice_0_phased * (1.0 - clip01(0.45 + patch_mod_formant_mix + 0.0)) + voice_0_formants * clip01(0.45 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_0_age, 0.002, 0, 1, 0.2, 0.03) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.12); voice_1_age = age; -voice_1_freq = ((max(20, 440 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); -voice_1_osc = 1.0 - 4.0 * abs(wrap01(os.phasor(1.0, ((max(20, 440 * pow(2.0, 0 * voice_1_age + 0.5 * 0 * voice_1_age * voice_1_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0) - 0.5); +voice_1_pitch_age = voice_1_age - floor(voice_1_age / repeat_interval) * repeat_interval; +voice_1_freq = ((max(20, 440 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); +voice_1_osc = 1.0 - 4.0 * abs(wrap01(os.phasor(1.0, ((max(20, 440 * pow(2.0, 0 * voice_1_pitch_age + 0.5 * 0 * voice_1_pitch_age * voice_1_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0) - 0.5); voice_1_colored = (voice_1_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_1_driven = ma.tanh(voice_1_colored * (1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0 + patch_mod_drive + 0.0) * 12.0); voice_1_folded = voice_1_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_1_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); @@ -63,8 +66,9 @@ voice_1_formants = voice_1_phased; voice_1 = ((voice_1_phased * (1.0 - clip01(0 + patch_mod_formant_mix + 0.0)) + voice_1_formants * clip01(0 + patch_mod_formant_mix + 0.0)) * oneshot_adsr_at(voice_1_age, 0, 0, 1, 0.18, 0.02) * max(0.0, 1.0 + patch_mod_gain + 0.0) * 0.04); voice_2_age = age; -voice_2_freq = ((max(20, 230.3403 * pow(2.0, 0.7460228 * voice_2_age + 0.5 * 0.00000008 * voice_2_age * voice_2_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); -voice_2_osc = 2.0 * wrap01(os.phasor(1.0, ((max(20, 230.3403 * pow(2.0, 0.7460228 * voice_2_age + 0.5 * 0.00000008 * voice_2_age * voice_2_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0) - 1.0; +voice_2_pitch_age = voice_2_age - floor(voice_2_age / repeat_interval) * repeat_interval; +voice_2_freq = ((max(20, 230.3403 * pow(2.0, 0.7460228 * voice_2_pitch_age + 0.5 * 0.00000008 * voice_2_pitch_age * voice_2_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); +voice_2_osc = 2.0 * wrap01(os.phasor(1.0, ((max(20, 230.3403 * pow(2.0, 0.7460228 * voice_2_pitch_age + 0.5 * 0.00000008 * voice_2_pitch_age * voice_2_pitch_age)))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0) - 1.0; voice_2_colored = (voice_2_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_2_driven = ma.tanh(voice_2_colored * (1.0 + clip01(0.1212819 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.1212819 + patch_mod_drive + 0.0) * 12.0); voice_2_folded = voice_2_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_2_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); diff --git a/tools/aqua-playground/lowered/sfxr-powerup.dsp b/tools/aqua-playground/lowered/sfxr-powerup.dsp index cf8bf37..0494b9a 100644 --- a/tools/aqua-playground/lowered/sfxr-powerup.dsp +++ b/tools/aqua-playground/lowered/sfxr-powerup.dsp @@ -3,7 +3,8 @@ declare name "sfxr-powerup"; declare options "[nvoices:1]"; time = ba.time / ma.SR; -age = time - floor(time / 0.11315) * 0.11315; +repeat_interval = 0.11315; +age = time; clip01(x) = min(1.0, max(0.0, x)); wrap01(x) = x - floor(x); slew(rate,x) = x : *(1.0 - exp((0.0 - max(0.0001, rate)) / ma.SR)) : + ~ *(exp((0.0 - max(0.0001, rate)) / ma.SR)); @@ -41,8 +42,9 @@ patch_mod_formant_mix = 0.0; patch_mod_fm_index = 0.0; voice_0_age = age; -voice_0_freq = ((max(20, 57.5946 * pow(2.0, -0.208544 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 3.9602) * 0.09))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); -voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 57.5946 * pow(2.0, -0.208544 * voice_0_age + 0.5 * 0 * voice_0_age * voice_0_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 3.9602) * 0.09))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0)); +voice_0_pitch_age = voice_0_age - floor(voice_0_age / repeat_interval) * repeat_interval; +voice_0_freq = ((max(20, 57.5946 * pow(2.0, -0.208544 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 3.9602) * 0.09))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0); +voice_0_osc = sin(2.0 * ma.PI * wrap01(os.phasor(1.0, ((max(20, 57.5946 * pow(2.0, -0.208544 * voice_0_pitch_age + 0.5 * 0 * voice_0_pitch_age * voice_0_pitch_age))) * (1.0 + select2(voice_0_age < 0, 0.0, sin(2.0 * ma.PI * (voice_0_age - 0) * 3.9602) * 0.09))) * 1.0 * pow(2.0, patch_mod_pitch + 0.0)) + 0)); voice_0_colored = (voice_0_osc * (1.0 - clip01(0 + patch_mod_noise + 0.0)) + no.noise * clip01(0 + patch_mod_noise + 0.0)); voice_0_driven = ma.tanh(voice_0_colored * (1.0 + clip01(0.12 + patch_mod_drive + 0.0) * 12.0)) / ma.tanh(1.0 + clip01(0.12 + patch_mod_drive + 0.0) * 12.0); voice_0_folded = voice_0_driven * (1.0 - clip01(0 + patch_mod_fold + 0.0)) + fold(voice_0_driven * (1.0 + clip01(0 + patch_mod_fold + 0.0) * 3.5)) * clip01(0 + patch_mod_fold + 0.0); -- 2.47.3 From d55407f93ac9c1eab14a905da2ed9d6da4327413 Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 11 Nov 2026 08:54:33 +0000 Subject: [PATCH 2/2] README: rollback note by store kind (single-file v3 vs directory v5), per CultLib #98 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc5422c..879b5ea 100644 --- a/README.md +++ b/README.md @@ -165,4 +165,9 @@ dotnet build -p:CultLibRoot= # or set CULTLIB_ROOT= Without an override the sibling `..\CultLib` checkout is used and must itself be at that revision. -**Stores are one-way, and rolling back is dangerous.** Since the pin move (#35) list elements carry ids. A store holding any element id has header v3 (v5 for a directory store); a store with none keeps its old header. CultLib 1.x pins (`cultlib-unity-v1.0.61`, `cultlib-v1.0.32`) do not refuse such a store: they read the header string without comparing it, so a rolled-back build silently drops the ids and can rewrite elements without them. Refusal by name starts only at `cultlib-unity-v2.0.0`. So: keep a copy of any store taken before the upgrade, upgrade every reader before any writer, and do not roll the pin back after writing. Stores written before the move still open; ids are minted on load. +**Stores are one-way, and rolling back can lose data.** Since the pin move (#35) list elements carry ids, and a store holding any element id is stamped with a newer header. What an older build does with it depends on the store kind: + +- **Single-file stores** (header v3 with ids): the patch catalog (`patches/aquasynth-patch-cultcache.cc`), the speech training store and the daemon store. Pins before `cultlib-unity-v2.0.0` / `caching-unity-v2.0.0`, including NuGet `cultlib-v1.0.32`, read these without complaint, silently drop the ids, and can rewrite elements without them. Only v2.0.0 and later refuse by name. +- **Directory stores** (header v5 with ids): the IPA trial results store (`ipa-trial-results.cc`, `UseDirectoryStore`). These are refused by name from `cultlib-unity-v1.0.57` / `caching-unity-v1.2.0`. + +So: take a copy of any store before the upgrade and restore from it if you roll back, never write with the new pin and then read with an old one, and upgrade every reader before any writer. Stores written before the move still open; ids are minted on load. -- 2.47.3