@@ -232,6 +232,8 @@ void StringVoiceTest::test_serialization_shouldRestoreParameters()
232232 {
233233 NahdXmlWriter writer { buffer };
234234 StringVoiceDevice dev { " Test StringVoice" };
235+ dev.setStringsBalance (0 .35f );
236+ dev.setVoiceBalance (0 .65f );
235237 dev.setStringsLevel8 (0 .45f );
236238 dev.setStringsLevel4 (0 .6f );
237239 dev.setVoiceMale4 (0 .3f );
@@ -258,6 +260,8 @@ void StringVoiceTest::test_serialization_shouldRestoreParameters()
258260
259261 dev.deserializeFromXml (reader);
260262
263+ QCOMPARE (dev.stringsBalance (), 0 .35f );
264+ QCOMPARE (dev.voiceBalance (), 0 .65f );
261265 QCOMPARE (dev.stringsLevel8 (), 0 .45f );
262266 QCOMPARE (dev.stringsLevel4 (), 0 .6f );
263267 QCOMPARE (dev.voiceMale4 (), 0 .3f );
@@ -412,6 +416,48 @@ void StringVoiceTest::test_voiceRegisters_shouldRouteMale4AndFemale8Independentl
412416 QCOMPARE (renderPeak (silentDev), 0.0 );
413417}
414418
419+ void StringVoiceTest::test_balance_shouldScaleEachSectionIndependently ()
420+ {
421+ // One level per section, the hardware's Balance sliders. Turning one down must not touch the
422+ // other, which is the whole point of not having to reach for each register's footage.
423+ const auto render = [](float stringsBalance, float voiceBalance) {
424+ StringVoiceDevice dev { " Test StringVoice" };
425+ dev.setSampleRate (44100 );
426+ dev.setStringsLevel8 (1 .0f );
427+ dev.setVoiceMale8 (1 .0f );
428+ dev.setStringsAttack (0 .0f );
429+ dev.setVoiceAttack (0 .0f );
430+ dev.setEnsembleEnabled (false );
431+ dev.setStringsBalance (stringsBalance);
432+ dev.setVoiceBalance (voiceBalance);
433+ dev.processMidiNoteOn (48 , 100 );
434+
435+ constexpr uint32_t frameCount { 8192 };
436+ std::vector<double > buffer (static_cast <size_t >(frameCount) * 2 , 0.0 );
437+ auto ctx = makeContext (buffer, frameCount);
438+ dev.processAudio (ctx);
439+ return buffer;
440+ };
441+
442+ const double both { rmsLevel (render (1 .0f , 1 .0f )) };
443+ const double stringsOnly { rmsLevel (render (1 .0f , 0 .0f )) };
444+ const double voiceOnly { rmsLevel (render (0 .0f , 1 .0f )) };
445+ const double neither { rmsLevel (render (0 .0f , 0 .0f )) };
446+
447+ QVERIFY (both > 0.0 );
448+ QVERIFY2 (stringsOnly > 0.0 && voiceOnly > 0.0 ,
449+ QString (" A section fell silent with its own balance up: strings %1, voice %2" ).arg (stringsOnly).arg (voiceOnly).toUtf8 ().constData ());
450+ QVERIFY2 (neither < both * 0.001 ,
451+ QString (" Both balances down still gave %1 against %2" ).arg (neither).arg (both).toUtf8 ().constData ());
452+ QVERIFY (stringsOnly < both);
453+ QVERIFY (voiceOnly < both);
454+
455+ // Half of the strings section must land halfway in level, not somewhere the voice section moved.
456+ const double halfStrings { rmsLevel (render (0 .5f , 0 .0f )) };
457+ QVERIFY2 (std::abs (halfStrings - stringsOnly * 0.5 ) < stringsOnly * 0.05 ,
458+ QString (" Half balance gave %1, expected about %2" ).arg (halfStrings).arg (stringsOnly * 0.5 ).toUtf8 ().constData ());
459+ }
460+
415461void StringVoiceTest::test_formants_male_shouldPeakAtOohFrequencies ()
416462{
417463 // The male register sings an /u/: F1 325, F2 700, F3 2530 Hz. F3 is the one that decides whether
0 commit comments