From c3b6a3bd41ed7426ec4459c66e84ab941d17c6cf Mon Sep 17 00:00:00 2001 From: Bonni Date: Wed, 21 Oct 2026 11:35:33 -0300 Subject: [PATCH] Attack emphasis: each pipe begins louder or softer and comes to its own level --- manual/src/dev/control-protocol.md | 2 + manual/src/guide/organ-settings.md | 2 + protocol/control.yaml | 2 + resources/translations/cs.txt | 4 ++ resources/translations/de.txt | 4 ++ resources/translations/es.txt | 4 ++ resources/translations/fr.txt | 4 ++ resources/translations/it.txt | 4 ++ resources/translations/nl.txt | 4 ++ resources/translations/pl.txt | 4 ++ resources/translations/strings.txt | 4 ++ resources/web/openapi.json | 4 +- src/mp_api/ControlModel.cpp | 4 ++ src/mp_api/ControlProtocol.h | 6 ++- src/mp_audio/MasterpieceProcessor.cpp | 17 ++++++++ src/mp_audio/MasterpieceProcessor.h | 14 +++++++ src/mp_sampler/VoiceEngine.cpp | 48 +++++++++++++++++----- src/mp_sampler/VoiceEngine.h | 9 ++++ src/mp_ui/OrganSettings.cpp | 31 ++++++++++++++ src/mp_ui/OrganSettings.h | 5 +++ tests/test_live_levels.cpp | 59 +++++++++++++++++++++++++++ 21 files changed, 222 insertions(+), 13 deletions(-) diff --git a/manual/src/dev/control-protocol.md b/manual/src/dev/control-protocol.md index f1af673d..4a679546 100644 --- a/manual/src/dev/control-protocol.md +++ b/manual/src/dev/control-protocol.md @@ -77,6 +77,8 @@ A `press` acts like a thumb piston: it does what the object does, once. | 21 | Wind sway | tenths of a cent, 0 (off) to 100 | yes | | 22 | Sparse loading | 1 every note, 2 every second, 3 every third | yes | | 23 | Pedal combinations coupled | 0, 1 | yes | +| 24 | Attack emphasis | tenths of a dB, -120 to +120, 0 off | yes | +| 25 | Attack length | milliseconds, 5 to 300 | yes | New properties are added at the end; an index is never reused. diff --git a/manual/src/guide/organ-settings.md b/manual/src/guide/organ-settings.md index 12227309..028460e6 100644 --- a/manual/src/guide/organ-settings.md +++ b/manual/src/guide/organ-settings.md @@ -170,6 +170,7 @@ A correction for the room and the speakers, applied to the whole organ. The orga | **Save for this organ** | Keeps the EQ as it is now with this organ, whatever it is, the **On** box included, for its next load. Reads **Saved** or **Could not save** afterwards, and returns to its label at the next change. | | | At once | | Ten band sliders, labelled **31.5**, **63**, **125**, **250**, **500**, **1k**, **2k**, **4k**, **8k**, **16k** | One slider for each octave band, in Hz. The lowest and highest bands are shelves; the other eight are peaks (Q 1.4). A double-click returns a slider to 0. | 0 dB | -12 to +12 dB in steps of 0.5 | At once | | **Gain** | The output gain, to make up for what the bands add or take. | 0 dB | -12 to +12 dB in steps of 0.5 | At once | +| **Attack** and **over** | The attack's emphasis: each pipe begins this much louder, or softer when negative, and comes to its own level in a straight line over the time given. More or less chiff, on the pipes' own speech; noises and releases start as recorded. Applies whether the EQ is on or off. | 0 dB (off), 40 ms | -12 to +12 dB in steps of 0.5; 5 to 300 ms | From the next note | Every organ starts from the default EQ, which is off and flat until **Save as default** keeps another. An organ gets an EQ of its own only from **Save for this organ**; other saves of the organ file leave it on the default. Changes on the tab are live and are kept only by these buttons. A preset changes the live EQ the same way: **Save for this organ** or **Save as default** keeps it. @@ -229,6 +230,7 @@ The organ file is `organs/.mporgan` in the data folder (see [General | `buses`, `busir`, `groups` | The mixer's pairs, per-pair rooms and groups | The **Mixer** tab | | `route bus ` | The pair of one rank | The **Mixer** tab | | `voicingadj`, `voicingslot` | The voicing sets A and B and the live slot | The **Voicing** tab | +| `attack` | The attack's emphasis in dB and its length in ms, as `attack 4.0 60`; absent while it is off | Any write of the organ file after it is set | | `eq` | The organ's own equaliser, written whatever it is, off and flat included; absent while the organ plays the default EQ (`defaulteq` in the [global file](general-settings.md#the-global-file)) | **Save for this organ** on the **Voicing EQ** tab | | `skipstop`, `skipperspective`, `skiprank` | One item left out | The **Stops and perspectives** tab | | `portable` | `1` | **Keep this organ playable without its installation files** | diff --git a/protocol/control.yaml b/protocol/control.yaml index 64c7888f..f7e6fafd 100644 --- a/protocol/control.yaml +++ b/protocol/control.yaml @@ -244,6 +244,8 @@ program: - {index: 21, name: windSway, min: 0, max: 100, access: set, doc: The wind's slow pitch sway in tenths of a cent - 0 off} - {index: 22, name: sparseLoading, min: 1, max: 3, access: set, doc: "How many notes of a rank are loaded: 1 every note, 2 every second, 3 every third"} - {index: 23, name: pedalCombinationsCoupled, min: 0, max: 1, access: set, doc: The first manual's divisionals also recall the Pedal's of the same number} + - {index: 24, name: attackDb, min: -120, max: 120, access: set, doc: "The attack's emphasis in tenths of a dB: each pipe begins this much louder, or softer, and comes to its own level over attackMs. 0 is off"} + - {index: 25, name: attackMs, min: 5, max: 300, access: set, doc: How long the attack's emphasis lasts, in milliseconds} # Commands (type 9), pressed. commands: diff --git a/resources/translations/cs.txt b/resources/translations/cs.txt index 75e573e3..6185b574 100644 --- a/resources/translations/cs.txt +++ b/resources/translations/cs.txt @@ -992,3 +992,7 @@ countries: cz "Learn a knob or a pedal for this level" = "Naučit otočný ovladač nebo pedál pro tuto úroveň" "Learn: move the knob or pedal" = "Učení: pohněte ovladačem nebo pedálem" "Move it..." = "Pohněte jím..." +"Attack" = "Nasazení" +"over" = "během" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Každá píšťala začne o tolik hlasitěji nebo tišeji a pak dojde na svou vlastní úroveň: více nebo méně chiffu. 0 dB je vypnuto. Slyšet od další noty; ukládá se s varhanami." +"How long the attack's emphasis lasts" = "Jak dlouho trvá zdůraznění nasazení" diff --git a/resources/translations/de.txt b/resources/translations/de.txt index 705956c2..243beac3 100644 --- a/resources/translations/de.txt +++ b/resources/translations/de.txt @@ -992,3 +992,7 @@ countries: de at ch li lu "Learn a knob or a pedal for this level" = "Einen Drehregler oder ein Pedal für diesen Pegel anlernen" "Learn: move the knob or pedal" = "Anlernen: Drehregler oder Pedal bewegen" "Move it..." = "Jetzt bewegen..." +"Attack" = "Ansprache" +"over" = "über" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Jede Pfeife setzt um so viel lauter oder leiser ein und kommt dann auf ihren eigenen Pegel: mehr oder weniger Vorläuferton. 0 dB ist aus. Ab dem nächsten Ton zu hören; mit der Orgel gespeichert." +"How long the attack's emphasis lasts" = "Wie lange die Betonung der Ansprache dauert" diff --git a/resources/translations/es.txt b/resources/translations/es.txt index 4b88888c..a9434773 100644 --- a/resources/translations/es.txt +++ b/resources/translations/es.txt @@ -992,3 +992,7 @@ countries: es ar bo cl co cr cu do ec gt hn mx ni pa pe pr py sv uy ve "Learn a knob or a pedal for this level" = "Aprender una perilla o un pedal para este nivel" "Learn: move the knob or pedal" = "Aprender: mueva la perilla o el pedal" "Move it..." = "Muévalo..." +"Attack" = "Ataque" +"over" = "durante" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Cada tubo empieza así de más fuerte, o más suave, y llega a su propio nivel: más o menos chiff. 0 dB es apagado. Se oye desde la próxima nota; se guarda con el órgano." +"How long the attack's emphasis lasts" = "Cuánto dura el énfasis del ataque" diff --git a/resources/translations/fr.txt b/resources/translations/fr.txt index 87633aee..f9cfa2a4 100644 --- a/resources/translations/fr.txt +++ b/resources/translations/fr.txt @@ -992,3 +992,7 @@ countries: fr be ca ch lu mc "Learn a knob or a pedal for this level" = "Apprendre un bouton ou une pédale pour ce niveau" "Learn: move the knob or pedal" = "Apprendre : bougez le bouton ou la pédale" "Move it..." = "Bougez-le..." +"Attack" = "Attaque" +"over" = "sur" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Chaque tuyau commence d'autant plus fort, ou plus doux, puis rejoint son propre niveau : plus ou moins de chiff. 0 dB, c'est désactivé. Entendu dès la note suivante ; enregistré avec l'orgue." +"How long the attack's emphasis lasts" = "Durée de l'accentuation de l'attaque" diff --git a/resources/translations/it.txt b/resources/translations/it.txt index 6127567d..5db39d11 100644 --- a/resources/translations/it.txt +++ b/resources/translations/it.txt @@ -992,3 +992,7 @@ countries: it ch sm va "Learn a knob or a pedal for this level" = "Apprendere una manopola o un pedale per questo livello" "Learn: move the knob or pedal" = "Apprendi: muovi la manopola o il pedale" "Move it..." = "Muovilo..." +"Attack" = "Attacco" +"over" = "in" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Ogni canna inizia di tanto più forte, o più piano, e poi torna al suo livello: più o meno chiff. 0 dB è spento. Udito dalla nota successiva; salvato con l'organo." +"How long the attack's emphasis lasts" = "Quanto dura l'enfasi dell'attacco" diff --git a/resources/translations/nl.txt b/resources/translations/nl.txt index 1b710ee1..ce4bebba 100644 --- a/resources/translations/nl.txt +++ b/resources/translations/nl.txt @@ -992,3 +992,7 @@ countries: nl be sr "Learn a knob or a pedal for this level" = "Een draaiknop of pedaal voor dit niveau leren" "Learn: move the knob or pedal" = "Leren: beweeg de draaiknop of het pedaal" "Move it..." = "Beweeg hem..." +"Attack" = "Aanspraak" +"over" = "over" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Elke pijp begint zoveel luider of zachter en komt dan op zijn eigen niveau: meer of minder chiff. 0 dB is uit. Te horen vanaf de volgende noot; opgeslagen met het orgel." +"How long the attack's emphasis lasts" = "Hoe lang de nadruk op de aanspraak duurt" diff --git a/resources/translations/pl.txt b/resources/translations/pl.txt index bf0e54b8..13b1a978 100644 --- a/resources/translations/pl.txt +++ b/resources/translations/pl.txt @@ -992,3 +992,7 @@ countries: pl "Learn a knob or a pedal for this level" = "Naucz pokrętło lub pedał dla tego poziomu" "Learn: move the knob or pedal" = "Nauka: porusz pokrętłem lub pedałem" "Move it..." = "Porusz nim..." +"Attack" = "Atak" +"over" = "przez" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Każda piszczałka zaczyna o tyle głośniej lub ciszej i dochodzi do własnego poziomu: więcej lub mniej chiffu. 0 dB to wyłączone. Słychać od następnej nuty; zapisywane z organami." +"How long the attack's emphasis lasts" = "Jak długo trwa podkreślenie ataku" diff --git a/resources/translations/strings.txt b/resources/translations/strings.txt index a2bfa740..ccaa2e67 100644 --- a/resources/translations/strings.txt +++ b/resources/translations/strings.txt @@ -312,6 +312,10 @@ countries: gb us "Back to the default EQ, and forget the one saved for this organ" = "Back to the default EQ, and forget the one saved for this organ" "A correction for the room and the speakers, on the whole organ. Off unless turned on here; the organ as recorded is the reference." = "A correction for the room and the speakers, on the whole organ. Off unless turned on here; the organ as recorded is the reference." "Gain" = "Gain" +"Attack" = "Attack" +"over" = "over" +"Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." = "Each pipe begins this much louder, or softer, and comes to its own level: more or less chiff. 0 dB is off. Heard from the next note; saved with the organ." +"How long the attack's emphasis lasts" = "How long the attack's emphasis lasts" "Presets" = "Presets" "Your presets" = "Your presets" "Edited" = "Edited" diff --git a/resources/web/openapi.json b/resources/web/openapi.json index 5b31a861..535901e3 100644 --- a/resources/web/openapi.json +++ b/resources/web/openapi.json @@ -70,7 +70,9 @@ "pipeDetune", "windSway", "sparseLoading", - "pedalCombinationsCoupled" + "pedalCombinationsCoupled", + "attackDb", + "attackMs" ] } }, diff --git a/src/mp_api/ControlModel.cpp b/src/mp_api/ControlModel.cpp index 24ea0469..5227ea9e 100644 --- a/src/mp_api/ControlModel.cpp +++ b/src/mp_api/ControlModel.cpp @@ -350,6 +350,8 @@ int ControlModel::programValue(ProgramProp prop) { case ProgramProp::WindSway: return juce::roundToInt(proc_.windSwayCents() * 10.0f); case ProgramProp::SparseLoading: return proc_.sparseEvery(); case ProgramProp::PedalCombinationsCoupled: return proc_.pedalCombinationsCoupled() ? 1 : 0; + case ProgramProp::AttackDb: return juce::roundToInt(proc_.attackDb() * 10.0f); + case ProgramProp::AttackMs: return proc_.attackMs(); } return 0; } @@ -778,6 +780,8 @@ Error ControlModel::setProgram(ProgramProp prop, int value) { case ProgramProp::WindSway: proc_.setWindSwayCents(static_cast(value) / 10.0f); break; case ProgramProp::SparseLoading: proc_.setSparseEvery(value); break; case ProgramProp::PedalCombinationsCoupled: proc_.setPedalCombinationsCoupled(value == 1); break; + case ProgramProp::AttackDb: proc_.setAttack(static_cast(value) / 10.0f, proc_.attackMs()); break; + case ProgramProp::AttackMs: proc_.setAttack(proc_.attackDb(), value); break; default: return Error::ReadOnly; } return Error::None; diff --git a/src/mp_api/ControlProtocol.h b/src/mp_api/ControlProtocol.h index 679851a5..fcc6d393 100644 --- a/src/mp_api/ControlProtocol.h +++ b/src/mp_api/ControlProtocol.h @@ -144,6 +144,8 @@ enum class ProgramProp : uint8_t { WindSway = 21, SparseLoading = 22, PedalCombinationsCoupled = 23, + AttackDb = 24, + AttackMs = 25, }; struct ProgramPropInfo { @@ -178,8 +180,10 @@ constexpr ProgramPropInfo kProgram[] = { {ProgramProp::WindSway, "windSway", 0, 100, true, "The wind's slow pitch sway in tenths of a cent - 0 off"}, {ProgramProp::SparseLoading, "sparseLoading", 1, 3, true, "How many notes of a rank are loaded: 1 every note, 2 every second, 3 every third"}, {ProgramProp::PedalCombinationsCoupled, "pedalCombinationsCoupled", 0, 1, true, "The first manual's divisionals also recall the Pedal's of the same number"}, + {ProgramProp::AttackDb, "attackDb", -120, 120, true, "The attack's emphasis in tenths of a dB: each pipe begins this much louder, or softer, and comes to its own level over attackMs. 0 is off"}, + {ProgramProp::AttackMs, "attackMs", 5, 300, true, "How long the attack's emphasis lasts"}, }; -constexpr int kProgramCount = 24; +constexpr int kProgramCount = 26; enum class Command : uint8_t { Panic = 0, diff --git a/src/mp_audio/MasterpieceProcessor.cpp b/src/mp_audio/MasterpieceProcessor.cpp index 85c1b9b1..21e6557f 100644 --- a/src/mp_audio/MasterpieceProcessor.cpp +++ b/src/mp_audio/MasterpieceProcessor.cpp @@ -2096,6 +2096,7 @@ bool MasterpieceProcessor::saveSettings() const { // Written whatever it is, off and flat too: an organ with a line keeps // it, and one without plays the default EQ. if (organOwnsEq_) text << "eq " << juce::String(voicingEqSettings_.toLine()) << "\n"; + if (attackDb() != 0.0f) text << "attack " << juce::String(attackDb(), 1) << " " << attackMs() << "\n"; } return f.replaceWithText(text); @@ -2123,6 +2124,8 @@ bool MasterpieceProcessor::loadSettingsFor(const juce::File& odf) { // note, and this one's own file says which notes it keeps. sparseEvery_ = 1; samples_.setSparseEvery(1); + attackDb_.store(0.0f, std::memory_order_relaxed); + attackMs_.store(40, std::memory_order_relaxed); excludedStops_.clear(); excludedPerspectives_.clear(); excludedRanks_.clear(); @@ -2258,6 +2261,14 @@ bool MasterpieceProcessor::loadSettingsFor(const juce::File& odf) { windSwayCents_.store(juce::jlimit(0.0f, kMaxUnevennessCents, val.getFloatValue()), std::memory_order_relaxed); continue; } + if (key == "attack") { + const auto tok = juce::StringArray::fromTokens(val, " ", ""); + if (tok.size() >= 2) { + attackDb_.store(juce::jlimit(-12.0f, 12.0f, tok[0].getFloatValue()), std::memory_order_relaxed); + attackMs_.store(juce::jlimit(5, 300, tok[1].getIntValue()), std::memory_order_relaxed); + } + continue; + } if (key == "skipstop") { excludedStops_.insert(static_cast(val.getLargeIntValue())); continue; @@ -3792,6 +3803,12 @@ bool MasterpieceProcessor::startPipeLayers(const Pipe& pipe, Id rankId, } vs.mixBus = mixBusForPipe(rankId, midiNote); vs.rankIndex = liveRankIndexOf(rankId); + // The attack's emphasis, for a pipe's own speech only: a noise and a + // release start as they were recorded. + if (const float db = attackDb(); db != 0.0f) { + vs.attackBoost = juce::Decibels::decibelsToGain(db) - 1.0f; + vs.attackFrames = static_cast(attackMs() * 0.001 * sampleRate_); + } { const auto wIt = pipeWindIndex_.find(pipe.pipeId); vs.windIndex = wIt == pipeWindIndex_.end() ? -1 : wIt->second; diff --git a/src/mp_audio/MasterpieceProcessor.h b/src/mp_audio/MasterpieceProcessor.h index 6a1fef08..15888bab 100644 --- a/src/mp_audio/MasterpieceProcessor.h +++ b/src/mp_audio/MasterpieceProcessor.h @@ -1506,6 +1506,18 @@ class MasterpieceProcessor : public juce::AudioProcessor { const VoicingEqSettings& defaultVoicingEq() const { return defaultEq_; } bool organHasOwnVoicingEq() const { return organOwnsEq_; } + // The attack's emphasis (Organ settings, Voicing EQ): each pipe begins + // `db` louder, or softer when negative, and comes to its own level in a + // straight line over `ms`. 0 dB is off, and then no voice does anything + // for it. Saved with the organ; heard from the next note. + float attackDb() const { return attackDb_.load(std::memory_order_relaxed); } + int attackMs() const { return attackMs_.load(std::memory_order_relaxed); } + void setAttack(float db, int ms) { + attackDb_.store(juce::jlimit(-12.0f, 12.0f, db), std::memory_order_relaxed); + attackMs_.store(juce::jlimit(5, 300, ms), std::memory_order_relaxed); + markSettingsDirty(); + } + // The EQ's presets, program-wide: ten of the program's and the player's own // (VoicingEqPresets.h). Recalling one sets the EQ at once and turns it on, // as moving the sliders does; Save for this organ and Save as default keep @@ -2162,6 +2174,8 @@ class MasterpieceProcessor : public juce::AudioProcessor { int voicingEqChanges_ = 0; // message thread only std::atomic pendingEqPreset_{0}; bool organOwnsEq_ = false; // the organ's file has an `eq` line + std::atomic attackDb_{0.0f}; + std::atomic attackMs_{40}; VoicingEq voicingEq_; Favourites favourites_; // Empty means the organ's default set. diff --git a/src/mp_sampler/VoiceEngine.cpp b/src/mp_sampler/VoiceEngine.cpp index 50707f95..5ead77d5 100644 --- a/src/mp_sampler/VoiceEngine.cpp +++ b/src/mp_sampler/VoiceEngine.cpp @@ -466,6 +466,8 @@ int VoiceEngine::startVoice(const VoiceStart& start, uint64_t noteId) { v.tremPitchDepth = start.tremPitchDepth; v.swayIndex = start.swayIndex; v.rankIndex = start.rankIndex; + v.attackBoost = start.attackBoost; + v.attackTotal = v.attackLeft = start.attackBoost != 0.0f && start.attackFrames > 0 ? start.attackFrames : 0; v.startedAtBlock = blockCounter_; v.startedAtFrame = framesRendered_; // If only this sample's head is resident, tell the streamer to start @@ -779,7 +781,7 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, // no tremulant. Treating it as one keeps it on the straight runs and spares // a pow() per frame. Before this, every such voice took the per-frame path // for as long as it sounded. - const bool tremMoving = v.tremIndex >= 0 && + bool tremMoving = v.tremIndex >= 0 && (trem.ampStart != 0.0f || trem.ampStep != 0.0f || trem.pitchStart != 0.0 || trem.pitchStep != 0.0); // The pitch the tremulant gives this pipe, as a ratio that is multiplied on @@ -793,6 +795,30 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, tremRatio = std::pow(2.0, v.tremPitchDepth * trem.pitchStart / 12.0); tremRatioStep = std::pow(2.0, v.tremPitchDepth * trem.pitchStep / 12.0); } + // The level the tremulant swings this pipe by, frame by frame: 1 + depth x + // phase, the phase moving by a step a frame. + float ampDepth = v.tremAmpDepth; + float ampStep = trem.ampStep; + // The attack's emphasis, while it lasts: folded into that same ramp, so + // every path that follows a tremulant's level follows it too. Over the + // block the level goes in a straight line from what the tremulant and the + // emphasis make together at its start to what they make at its end. A + // pipe with none takes none of this, and sounds as before to the bit. + if (v.attackLeft > 0 && numFrames > 0) { + const float total = static_cast(v.attackTotal); + const int after = std::max(0, v.attackLeft - numFrames); + const float e0 = 1.0f + v.attackBoost * static_cast(v.attackLeft) / total; + const float e1 = 1.0f + v.attackBoost * static_cast(after) / total; + const float t0 = 1.0f + v.tremAmpDepth * tremPhase; + const float t1 = 1.0f + v.tremAmpDepth * (tremPhase + trem.ampStep * static_cast(numFrames)); + const float f0 = t0 * e0; + const float f1 = t1 * e1; + ampDepth = 1.0f; + tremPhase = f0 - 1.0f; + ampStep = (f1 - f0) / static_cast(numFrames); + tremMoving = true; + v.attackLeft = after; + } // Folded in once rather than applied per tap. For Float32 this is 1.0f and // the multiply below is the one the gain needed anyway. const float storeScale = storageScale(buf); @@ -951,14 +977,14 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, // Every frame of the run reads short of both limits. for (; n < want && p < newLimit && q < oldLimit; ++n) { float e = base; - e *= 1.0f + v.tremAmpDepth * phase; + e *= 1.0f + ampDepth * phase; if (e < 0.0f) e = 0.0f; levels[n] = e; ratios[n] = windRatio * swing; oldRatios[n] = ratios[n] * v.xfadeRatioScale; p += ratios[n]; q += oldRatios[n]; - phase += trem.ampStep; + phase += ampStep; swing *= tremRatioStep; } if (n >= kMinSimdRun) { @@ -1051,12 +1077,12 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, // Every frame of the run reads short of the limit. for (; n < want && p < limit; ++n) { float e = base; - e *= 1.0f + v.tremAmpDepth * phase; + e *= 1.0f + ampDepth * phase; if (e < 0.0f) e = 0.0f; envs[n] = e * storeScale; ratios[n] = windRatio * swing; p += ratios[n]; - phase += trem.ampStep; + phase += ampStep; swing *= tremRatioStep; } if (n >= kMinSimdRun) { @@ -1126,12 +1152,12 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, int n = 0; for (; n < want && p < limit; ++n) { float e = level; - e *= 1.0f + v.tremAmpDepth * phase; + e *= 1.0f + ampDepth * phase; if (e < 0.0f) e = 0.0f; envs[n] = e; ratios[n] = windRatio * swing; p += ratios[n]; - phase += trem.ampStep; + phase += ampStep; swing *= tremRatioStep; } if (n >= kMinSimdRun) { @@ -1195,7 +1221,7 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, for (; n < want && p < limit; ++n) { float e = level; if (tremMoving) { - e *= 1.0f + v.tremAmpDepth * phase; + e *= 1.0f + ampDepth * phase; if (e < 0.0f) e = 0.0f; } if (fading) { @@ -1216,7 +1242,7 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, envs[n] = e * scale; ratios[n] = tremMoving ? windRatio * swing : windRatio; p += ratios[n]; - phase += trem.ampStep; + phase += ampStep; swing *= tremRatioStep; } if (n >= kMinSimdRun || (ends && n > 0)) { @@ -1279,7 +1305,7 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, // it sound like wind rather than like a volume pedal. Shared by both // sides of a release crossfade: the wind does not jump at note-off. if (tremMoving) { - envReal *= 1.0f + v.tremAmpDepth * tremPhase; + envReal *= 1.0f + ampDepth * tremPhase; if (envReal < 0.0f) envReal = 0.0f; } // The attack side of a release crossfade sounds through this level while @@ -1454,7 +1480,7 @@ void VoiceEngine::renderVoiceFrom(Voice& v, size_t voiceIndex, } v.cursor += ratio; - tremPhase += trem.ampStep; + tremPhase += ampStep; tremRatio *= tremRatioStep; } } diff --git a/src/mp_sampler/VoiceEngine.h b/src/mp_sampler/VoiceEngine.h index 3d7f8ecf..9921a3f0 100644 --- a/src/mp_sampler/VoiceEngine.h +++ b/src/mp_sampler/VoiceEngine.h @@ -331,6 +331,12 @@ struct Voice { // Which rank's live level reaches this pipe, as an index into the table // setLiveGains() hands over; -1 for none (a noise). See LiveLevels. int rankIndex = -1; + // The attack's emphasis: a level that starts at 1 + attackBoost and falls + // in a straight line to 1 over attackTotal frames, attackLeft of them to + // go. 0 left is no emphasis, and then nothing is done for it. + float attackBoost = 0.0f; + int attackTotal = 0; + int attackLeft = 0; // Loop crossfade. On a wrap the outgoing stream keeps reading PAST the loop // end (that material is still in the file) while the incoming stream starts @@ -393,6 +399,9 @@ struct VoiceStart { int mixBus = 0; // mixer output int swayIndex = -1; // wind sway; see Voice::swayIndex int rankIndex = -1; // live level; see Voice::rankIndex + // The attack's emphasis; see Voice::attackBoost. 0 frames is none. + float attackBoost = 0.0f; + int attackFrames = 0; }; struct EngineStats { diff --git a/src/mp_ui/OrganSettings.cpp b/src/mp_ui/OrganSettings.cpp index da69163a..0870b68c 100644 --- a/src/mp_ui/OrganSettings.cpp +++ b/src/mp_ui/OrganSettings.cpp @@ -702,6 +702,27 @@ VoicingEqPanel::VoicingEqPanel(MasterpieceProcessor& p) : proc_(p) { f >= 1000.0 ? juce::String(f / 1000.0, 0) + "k" : juce::String(f, f < 40 ? 1 : 0).upToFirstOccurrenceOf(".0", false, false)); } style(gain_, gainName_, TRANS("Gain")); + for (auto* l : {&attackName_, &attackOver_}) { + l->setColour(juce::Label::textColourId, juce::Colour(0xffb9c2d0)); + addAndMakeVisible(*l); + } + attackName_.setText(TRANS("Attack"), juce::dontSendNotification); + attackOver_.setText(TRANS("over"), juce::dontSendNotification); + attack_.setRange(-12.0, 12.0, 0.5); + attack_.setTextValueSuffix(" dB"); + attack_.setDoubleClickReturnValue(true, 0.0); + attack_.setTooltip(TRANS("Each pipe begins this much louder, or softer, and comes to its own level: " + "more or less chiff. 0 dB is off. Heard from the next note; saved with the organ.")); + attackMs_.setRange(5.0, 300.0, 1.0); + attackMs_.setTextValueSuffix(" ms"); + attackMs_.setSkewFactorFromMidPoint(60.0); + attackMs_.setTooltip(TRANS("How long the attack's emphasis lasts")); + for (auto* sl : {&attack_, &attackMs_}) { + sl->onValueChange = [this] { + proc_.setAttack(static_cast(attack_.getValue()), juce::roundToInt(attackMs_.getValue())); + }; + addAndMakeVisible(*sl); + } showValues(); // A preset recalled from MIDI while the panel is open. startTimerHz(4); @@ -713,6 +734,8 @@ void VoicingEqPanel::showValues() { for (size_t i = 0; i < bands_.size(); ++i) bands_[i].setValue(eq.bandsDb[i], juce::dontSendNotification); gain_.setValue(eq.gainDb, juce::dontSendNotification); + attack_.setValue(proc_.attackDb(), juce::dontSendNotification); + attackMs_.setValue(proc_.attackMs(), juce::dontSendNotification); fillPresets(); seenChanges_ = proc_.voicingEqChanges(); } @@ -855,6 +878,14 @@ void VoicingEqPanel::resized() { note_.setBounds(r.removeFromTop(36)); curveArea_ = r.removeFromTop(130).reduced(0, 4); r.removeFromTop(6); + { + auto attackRow = r.removeFromBottom(28); + r.removeFromBottom(6); + attackName_.setBounds(attackRow.removeFromLeft(60)); + attack_.setBounds(attackRow.removeFromLeft(attackRow.getWidth() / 2).reduced(2, 2)); + attackOver_.setBounds(attackRow.removeFromLeft(44)); + attackMs_.setBounds(attackRow.reduced(2, 2)); + } const int columns = VoicingEqSettings::kBands + 1; const int w = r.getWidth() / columns; auto names = r.removeFromTop(20); diff --git a/src/mp_ui/OrganSettings.h b/src/mp_ui/OrganSettings.h index a07bd57a..cd57b258 100644 --- a/src/mp_ui/OrganSettings.h +++ b/src/mp_ui/OrganSettings.h @@ -161,6 +161,11 @@ class VoicingEqPanel : public juce::Component, private juce::Timer { juce::Slider gain_; juce::Label gainName_; juce::Rectangle curveArea_; + // The attack's emphasis: how much louder, or softer, each pipe begins, + // and over how long it comes to its own level. + juce::Label attackName_, attackOver_; + juce::Slider attack_{juce::Slider::LinearHorizontal, juce::Slider::TextBoxRight}; + juce::Slider attackMs_{juce::Slider::LinearHorizontal, juce::Slider::TextBoxRight}; }; class OrganSettingsWindow : public juce::Component { diff --git a/tests/test_live_levels.cpp b/tests/test_live_levels.cpp index 333a71c8..8d47ffd5 100644 --- a/tests/test_live_levels.cpp +++ b/tests/test_live_levels.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace { @@ -256,6 +257,64 @@ class LiveLevelsOrganTest final : public mp::test::Test { }; static LiveLevelsOrganTest g_organ; +// The attack's emphasis: each pipe begins louder, or softer, and comes to +// its own level in a straight line. Off, the organ sounds as without it. +class AttackEmphasisTest final : public mp::test::Test { +public: + AttackEmphasisTest() : Test("functional.voicing.attack", Category::Functional) {} + void run() override { + const juce::File root = + juce::File::getSpecialLocation(juce::File::tempDirectory).getChildFile("mp-attack-emphasis"); + const juce::File odf = writeOrgan(root); + juce::MidiBuffer on; + on.addEvent(juce::MidiMessage::noteOn(1, 36, 0.8f), 0); + const auto mean = [](const std::vector& v, size_t from, size_t to) { + double sum = 0.0; + for (size_t i = from; i < to; ++i) sum += static_cast(v[i]) * v[i]; + return static_cast(std::sqrt(sum / static_cast(to - from))); + }; + + Rig plain(odf), off(odf), louder(odf), softer(odf); + MP_CHECK(plain.loaded && off.loaded && louder.loaded && softer.loaded, "the organ loads"); + off.proc.setAttack(6.0f, 100); + off.proc.setAttack(0.0f, 100); + louder.proc.setAttack(6.0f, 100); + softer.proc.setAttack(-12.0f, 100); + MP_CHECK(louder.proc.attackDb() == 6.0f && louder.proc.attackMs() == 100, "set"); + + bool same = true; + std::vector ref, more, less; + for (int i = 0; i < 80; ++i) { + const juce::MidiBuffer m = i == 0 ? on : juce::MidiBuffer(); + ref.push_back(plain.block(m)); + off.block(m); + more.push_back(louder.block(m)); + less.push_back(softer.block(m)); + for (int ch = 0; ch < 2; ++ch) + for (int s = 0; s < 256; ++s) same = same && plain.buf.getSample(ch, s) == off.buf.getSample(ch, s); + } + MP_CHECK(same, "set and put back to 0 dB: bit-identical to an organ never touched"); + // The first 50 ms against the plain organ, and after the attack. + const float r0 = mean(ref, 1, 10), m0 = mean(more, 1, 10), l0 = mean(less, 1, 10); + MP_CHECK(m0 > r0 * 1.2f, "+6 dB: the start is louder, " + std::to_string(m0) + " against " + std::to_string(r0)); + MP_CHECK(l0 < r0 * 0.8f, "-12 dB: the start is softer, " + std::to_string(l0) + " against " + std::to_string(r0)); + const float r1 = mean(ref, 40, 80), m1 = mean(more, 40, 80), l1 = mean(less, 40, 80); + MP_CHECK(std::fabs(m1 - r1) < r1 * 0.01f && std::fabs(l1 - r1) < r1 * 0.01f, + "after the attack, the pipe at its own level"); + + // Saved with the organ and read back; a new organ without it starts off. + MP_CHECK(louder.proc.saveSettings(), "saved"); + Rig again(odf); + MP_CHECK(again.proc.attackDb() == 6.0f && again.proc.attackMs() == 100, "read back with the organ"); + again.proc.setAttack(0.0f, 40); + MP_CHECK(again.proc.saveSettings(), "saved off"); + + plain.proc.settingsFileFor(odf).deleteFile(); + root.deleteRecursively(); + } +}; +static AttackEmphasisTest g_attack; + } // namespace #endif // MP_TEST_HAS_AUDIO