@@ -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+
932992void PianoSynthV3Test::test_pickupSign_shouldChangePhasesWithoutChangingLevel ()
933993{
934994 // MIDI 24 is single-strung in both revisions, so neither the bichord bound nor the
0 commit comments