Skip to content

Commit 0c51fb1

Browse files
committed
Add velocity sensitivity to Piano Synth V3
1 parent ac4b928 commit 0c51fb1

11 files changed

Lines changed: 127 additions & 3 deletions

CHANGELOG

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ New features:
1414
- The strings come in twos, never threes, and single stringing reaches to F#1:
1515
a CP80 is bichord over its whole compass
1616
- A separate device, so songs written against V2 keep playing V2
17+
- A Velocity Sensitivity control: full is the reference's own velocity-squared
18+
law, lower lifts the softer strikes toward the loud ones without changing
19+
their tone, so the top of the keyboard is playable without hammering it
1720

1821
* Add the Gain rack effect: a plain +/-24 dB trim with a latching clip indicator,
1922
for staging level without the colour a limiter or a drive brings

src/domain/devices/piano_synth_v3_device.cpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ PianoSynthV3Device::PianoSynthV3Device(std::string name)
5656
addParameter(Parameter(Constants::NahdXml::xmlKeyRichness().toStdString(), 0.7f, 0, 10000, 7000, 100));
5757
addParameter(Parameter(Constants::NahdXml::xmlKeyDoubleDecay().toStdString(), 0.5f, 0, 10000, 5000, 100));
5858
addParameter(Parameter(Constants::NahdXml::xmlKeyAttack().toStdString(), 0.5f, 0, 10000, 5000, 100));
59+
addParameter(Parameter(Constants::NahdXml::xmlKeyAmpVelocitySensitivity().toStdString(), 1.0f, 0, 10000, 10000, 100));
5960
addParameter(Parameter(Constants::NahdXml::xmlKeyLpfCutoff().toStdString(), 1.0f, 0, 10000, 10000, 100));
6061
addParameter(Parameter(Constants::NahdXml::xmlKeyHpfCutoff().toStdString(), 0.0f, 0, 10000, 0, 100));
6162
addParameter(Parameter(Constants::NahdXml::xmlKeyReleaseTime().toStdString(), 0.15f, 0, 10000, 1500, 100));
@@ -319,7 +320,8 @@ ModalPianoStringV3::Settings PianoSynthV3Device::stringSettings() const
319320
m_stretch,
320321
m_richness,
321322
m_doubleDecay,
322-
m_attack
323+
m_attack,
324+
m_velocitySensitivity
323325
};
324326
}
325327

@@ -417,6 +419,9 @@ void PianoSynthV3Device::syncParameters()
417419
if (const auto p = parameter(Constants::NahdXml::xmlKeyAttack().toStdString()); p) {
418420
m_attack = p->get().value();
419421
}
422+
if (const auto p = parameter(Constants::NahdXml::xmlKeyAmpVelocitySensitivity().toStdString()); p) {
423+
m_velocitySensitivity = p->get().value();
424+
}
420425
if (const auto p = parameter(Constants::NahdXml::xmlKeyLpfCutoff().toStdString()); p) {
421426
m_lpfCutoff = p->get().value();
422427
}
@@ -521,6 +526,16 @@ void PianoSynthV3Device::setAttack(float attack)
521526
setContinuousParameterValue(Constants::NahdXml::xmlKeyAttack().toStdString(), attack);
522527
}
523528

529+
float PianoSynthV3Device::velocitySensitivity() const
530+
{
531+
return m_velocitySensitivity;
532+
}
533+
534+
void PianoSynthV3Device::setVelocitySensitivity(float sensitivity)
535+
{
536+
setContinuousParameterValue(Constants::NahdXml::xmlKeyAmpVelocitySensitivity().toStdString(), sensitivity);
537+
}
538+
524539
float PianoSynthV3Device::lpfCutoff() const
525540
{
526541
return m_lpfCutoff;

src/domain/devices/piano_synth_v3_device.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ class PianoSynthV3Device : public Device
8282
//! Scales the ramp the strike opens with, half being the one the model was fitted with.
8383
float attack() const;
8484
void setAttack(float attack);
85+
86+
//! How much of the strike's level follows velocity, one being the reference's own law.
87+
float velocitySensitivity() const;
88+
void setVelocitySensitivity(float sensitivity);
8589
float lpfCutoff() const;
8690
void setLpfCutoff(float cutoff);
8791
float hpfCutoff() const;
@@ -131,6 +135,7 @@ class PianoSynthV3Device : public Device
131135
float m_richness { 0.7f };
132136
float m_doubleDecay { 0.5f };
133137
float m_attack { 0.5f };
138+
float m_velocitySensitivity { 1.0f };
134139
float m_lpfCutoff { 1.0f };
135140
float m_hpfCutoff { 0.0f };
136141
float m_releaseTime { 0.15f };

src/domain/dsp/modal_piano_string_v3.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,12 @@ void ModalPianoStringV3::trigger(uint8_t note, float velocity, const Settings &
395395
// Velocity squares, matching the eight and a half decibels the reference gains
396396
// between velocity 64 and 100.
397397
const double norm = sumOfSquares > 0.0 ? 1.0 / std::sqrt(sumOfSquares) : 0.0;
398-
m_gain = norm * vel * vel * keyLevel(note);
398+
// Velocity is squared because the reference gains eight and a half decibels between 64 and
399+
// 100, and it is blended toward unity first so the player can flatten that. The blend is on
400+
// the level alone: hammerCorner above still reads the velocity as struck.
401+
const double sensitivity = std::clamp(static_cast<double>(settings.velocitySensitivity), 0.0, 1.0);
402+
const double levelVel = (1.0 - sensitivity) + sensitivity * vel;
403+
m_gain = norm * levelVel * levelVel * keyLevel(note);
399404

400405
// A short ramp over the strike, long in the bass and brief at the top, so that the
401406
// modes do not all land on one sample. It is what an attack time is measured as.

src/domain/dsp/modal_piano_string_v3.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ class ModalPianoStringV3 : public DspComponent
7474
// patch that leaves it alone is unchanged; below that the note arrives more abruptly, above
7575
// it the hammer is felt rather than heard.
7676
float attack { 0.5f };
77+
// How much of the strike's level follows velocity. One is the whole range, which is what
78+
// the reference was measured at; nothing holds every note at its loudest. Only the level
79+
// is blended -- the hammer still hardens with velocity, so a note played softly stays
80+
// soft in tone while coming up in level, which is the point of the control.
81+
float velocitySensitivity { 1.0f };
7782
};
7883

7984
// Partials of the note itself, before the unison pairs are added. The reference has

src/unit_tests/piano_synth_v3_test/piano_synth_v3_test.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,66 @@ double partialMagnitude(const std::vector<double> & buffer, uint8_t note, int pa
929929

930930
} // namespace
931931

932+
void PianoSynthV3Test::test_velocitySensitivity_shouldFlattenTheLevelWithoutTouchingTone()
933+
{
934+
constexpr uint8_t note = 84;
935+
constexpr int sampleCount = 24000;
936+
937+
const auto peakAt = [](float velocity, float sensitivity) {
938+
ModalPianoStringV3::Settings settings;
939+
settings.velocitySensitivity = sensitivity;
940+
ModalPianoStringV3 string;
941+
string.setSampleRate(TestSampleRate);
942+
string.trigger(note, velocity, settings);
943+
double peak = 0.0;
944+
for (int i = 0; i < sampleCount; i++) {
945+
peak = std::max(peak, std::abs(string.nextSample()));
946+
}
947+
return peak;
948+
};
949+
950+
// Full sensitivity is the reference's own law: half velocity is a quarter of the level, the
951+
// eight and a half decibels the survey measured between velocity 64 and 100.
952+
const double fullSoft = peakAt(0.5f, 1.0f);
953+
const double fullHard = peakAt(1.0f, 1.0f);
954+
QVERIFY2(std::abs(fullHard / fullSoft - 4.0) < 0.4,
955+
qPrintable(QString { "Expected a factor of four, got %1" }.arg(fullHard / fullSoft)));
956+
957+
// Turning it down lifts the soft strike toward the loud one.
958+
const double halfSoft = peakAt(0.5f, 0.5f);
959+
QVERIFY2(halfSoft > fullSoft * 1.5,
960+
qPrintable(QString { "Soft strike only moved from %1 to %2" }.arg(fullSoft).arg(halfSoft)));
961+
962+
// At nothing, velocity stops setting the level. Not to the last decimal: the hammer still
963+
// hardens with velocity, and a different spectrum peaks a little differently even at the same
964+
// gain -- under a decibel here, against the twelve that full sensitivity spans.
965+
const double flatRatio = peakAt(0.2f, 0.0f) / peakAt(1.0f, 0.0f);
966+
QVERIFY2(std::abs(flatRatio - 1.0) < 0.15,
967+
qPrintable(QString { "Level still followed velocity: %1" }.arg(flatRatio)));
968+
969+
// And the loud end is left where it was, so the control cannot make a patch louder than the
970+
// instrument's own top.
971+
QVERIFY2(std::abs(peakAt(1.0f, 0.0f) / fullHard - 1.0) < 0.02,
972+
qPrintable(QString { "Full velocity moved by %1" }.arg(peakAt(1.0f, 0.0f) / fullHard)));
973+
974+
// The tone still follows the hammer, not the blend: a soft strike stays soft in colour even
975+
// when it has been brought up in level. Measured as the ninth partial against the first.
976+
const auto brightness = [](float velocity, float sensitivity) {
977+
ModalPianoStringV3::Settings settings;
978+
settings.velocitySensitivity = sensitivity;
979+
ModalPianoStringV3 string;
980+
string.setSampleRate(TestSampleRate);
981+
string.trigger(note, velocity, settings);
982+
std::vector<double> buffer(static_cast<size_t>(sampleCount), 0.0);
983+
for (int i = 0; i < sampleCount; i++) {
984+
buffer[static_cast<size_t>(i)] = string.nextSample();
985+
}
986+
return partialMagnitude(buffer, note, 9) / partialMagnitude(buffer, note, 1);
987+
};
988+
QVERIFY2(std::abs(brightness(0.5f, 0.0f) / brightness(0.5f, 1.0f) - 1.0) < 0.05,
989+
qPrintable(QString { "Tone moved with the blend: %1" }.arg(brightness(0.5f, 0.0f) / brightness(0.5f, 1.0f))));
990+
}
991+
932992
void PianoSynthV3Test::test_pickupSign_shouldChangePhasesWithoutChangingLevel()
933993
{
934994
// MIDI 24 is single-strung in both revisions, so neither the bichord bound nor the

src/unit_tests/piano_synth_v3_test/piano_synth_v3_test.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ private slots:
4040

4141
// The three corrections V3 exists for. Each is written against V2's behaviour, so each
4242
// fails if the V3 string is ever made to behave like the older one again.
43+
void test_velocitySensitivity_shouldFlattenTheLevelWithoutTouchingTone();
4344
void test_pickupSign_shouldChangePhasesWithoutChangingLevel();
4445
void test_bichord_shouldLeaveTheLowBassSingleStrung();
4546
void test_unisonStandIn_shouldNotStepTheSpectrum();

src/view/controllers/piano_synth_v3_controller.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ void PianoSynthV3Controller::setAttack(int value)
153153
}
154154
}
155155

156+
int PianoSynthV3Controller::velocitySensitivity() const
157+
{
158+
return m_device ? static_cast<int>(std::round(m_device->velocitySensitivity() * Constants::uiInternalScaling())) : 0;
159+
}
160+
161+
void PianoSynthV3Controller::setVelocitySensitivity(int value)
162+
{
163+
if (m_device) {
164+
m_device->setVelocitySensitivity(static_cast<float>(value) / Constants::uiInternalScaling());
165+
}
166+
}
167+
156168
int PianoSynthV3Controller::lpfCutoff() const
157169
{
158170
return m_device ? static_cast<int>(std::round(m_device->lpfCutoff() * Constants::uiInternalScaling())) : 0;
@@ -212,6 +224,7 @@ void PianoSynthV3Controller::requestSettings()
212224
emit richnessChanged();
213225
emit doubleDecayChanged();
214226
emit attackChanged();
227+
emit velocitySensitivityChanged();
215228
emit lpfCutoffChanged();
216229
emit hpfCutoffChanged();
217230
emit releaseTimeChanged();

src/view/controllers/piano_synth_v3_controller.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class PianoSynthV3Controller : public DeviceController
3636
Q_PROPERTY(int richness READ richness WRITE setRichness NOTIFY richnessChanged)
3737
Q_PROPERTY(int doubleDecay READ doubleDecay WRITE setDoubleDecay NOTIFY doubleDecayChanged)
3838
Q_PROPERTY(int attack READ attack WRITE setAttack NOTIFY attackChanged)
39+
Q_PROPERTY(int velocitySensitivity READ velocitySensitivity WRITE setVelocitySensitivity NOTIFY velocitySensitivityChanged)
3940
Q_PROPERTY(int lpfCutoff READ lpfCutoff WRITE setLpfCutoff NOTIFY lpfCutoffChanged)
4041
Q_PROPERTY(int hpfCutoff READ hpfCutoff WRITE setHpfCutoff NOTIFY hpfCutoffChanged)
4142
Q_PROPERTY(int releaseTime READ releaseTime WRITE setReleaseTime NOTIFY releaseTimeChanged)
@@ -64,6 +65,8 @@ class PianoSynthV3Controller : public DeviceController
6465
void setRichness(int value);
6566
int doubleDecay() const;
6667
int attack() const;
68+
int velocitySensitivity() const;
69+
void setVelocitySensitivity(int value);
6770
void setDoubleDecay(int value);
6871
void setAttack(int value);
6972
int lpfCutoff() const;
@@ -88,6 +91,7 @@ class PianoSynthV3Controller : public DeviceController
8891
void richnessChanged();
8992
void doubleDecayChanged();
9093
void attackChanged();
94+
void velocitySensitivityChanged();
9195
void lpfCutoffChanged();
9296
void hpfCutoffChanged();
9397
void releaseTimeChanged();

src/view/qml/Dialogs/PianoSynthV3Dialog_Hammer.qml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ ColumnLayout {
4949
onMoved: v => pianoSynthV3Controller.attack = v
5050
Layout.fillWidth: true
5151
}
52+
Knob {
53+
// Full is the reference's own law, where the level follows velocity squared. Turning it
54+
// down lifts the softer strikes toward the loud ones without touching their tone, which
55+
// is what makes the top of the keyboard playable without hammering it.
56+
label: qsTr("Velocity Sensitivity")
57+
value: pianoSynthV3Controller.velocitySensitivity
58+
onMoved: v => pianoSynthV3Controller.velocitySensitivity = v
59+
Layout.fillWidth: true
60+
}
5261
Knob {
5362
label: qsTr("Richness")
5463
value: pianoSynthV3Controller.richness

0 commit comments

Comments
 (0)