Skip to content

Commit

Permalink
Correct Tuning Center; Add Tuning Tests (#1450)
Browse files Browse the repository at this point in the history
The tuning center note I had picked, inexplicably, was
Midi 48 / Pitch 16 / Frequency 130. This meant retuning put
notes at the wrong point vs an expected Midi60 constant.

So fix this (which was a 2 line fix) but also add a collection
of unit tests which exercise the tuning way more intelligently,
including doing some rudimentary pitch detection on a running surge.

Closes #1445
  • Loading branch information
baconpaul authored Jan 5, 2020
1 parent cae6504 commit 005e7f9
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 16 deletions.
6 changes: 3 additions & 3 deletions src/common/SurgeStorage.h
Original file line number Diff line number Diff line change
Expand Up @@ -608,9 +608,9 @@ class alignas(16) SurgeStorage
void note_to_omega(float, float&, float&);

bool retuneToScale(const Surge::Storage::Scale& s);
inline int scaleConstantNote() { return 48; }
inline float scaleConstantPitch() { return 16.0; }
inline float scaleConstantPitchInv() { return 0.0625; } // Obviously that's the inverse of the above
inline int scaleConstantNote() { return 60; }
inline float scaleConstantPitch() { return 32.0; }
inline float scaleConstantPitchInv() { return 0.03125; } // Obviously that's the inverse of the above

Surge::Storage::Scale currentScale;
bool isStandardTuning;
Expand Down
34 changes: 33 additions & 1 deletion src/headless/Player.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ namespace Headless
{

playerEvents_t makeHoldMiddleC(int forSamples, int withTail)
{
return makeHoldNoteFor(60, forSamples, withTail );
}

playerEvents_t makeHoldNoteFor( int note, int forSamples, int withTail )
{
playerEvents_t result;

Event on;
on.type = Event::NOTE_ON;
on.channel = 0;
on.data1 = 60;
on.data1 = note;
on.data2 = 100;
on.atSample = 0;

Expand Down Expand Up @@ -160,6 +165,33 @@ void playOnEveryPatch(
}
}

void playOnNRandomPatches(
SurgeSynthesizer* surge,
const playerEvents_t& events,
int nPlays,
std::function<void(
const Patch& p, const PatchCategory& c, const float* data, int nSamples, int nChannels)> cb)
{
int nPresets = surge->storage.patch_list.size();
int nCats = surge->storage.patch_category.size();

for (auto i = 0; i < nPlays; ++i)
{
int rp = (int)( 1.0 * rand() / RAND_MAX * ( surge->storage.patch_list.size() - 1 ) );
Patch p = surge->storage.patch_list[rp];
PatchCategory pc = surge->storage.patch_category[p.category];

float* data = NULL;
int nSamples, nChannels;

playOnPatch(surge, i, events, &data, &nSamples, &nChannels);
cb(p, pc, data, nSamples, nChannels);

if (data)
delete[] data;
}
}

void playMidiFile(SurgeSynthesizer* synth,
std::string midiFileName,
long callBackEvery,
Expand Down
16 changes: 16 additions & 0 deletions src/headless/Player.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ typedef std::vector<Event> playerEvents_t; // We assume these are monotonic in E
*/
playerEvents_t makeHoldMiddleC(int forSamples, int withTail = 0);

playerEvents_t makeHoldNoteFor(int note, int forSamples, int withTail = 0);

playerEvents_t make120BPMCMajorQuarterNoteScale(long sample0 = 0, int sr = 44100);

/**
Expand Down Expand Up @@ -87,6 +89,20 @@ void playOnEveryPatch(
const Patch& p, const PatchCategory& c, const float* data, int nSamples, int nChannels)>
completedCallback);

/**
* playOnEveryNRandomPatches
*
* Play the events on every patch Surge knows callign the callback for each one with
* the result.
*/
void playOnNRandomPatches(
SurgeSynthesizer* synth,
const playerEvents_t& events,
int nPlays,
std::function<void(
const Patch& p, const PatchCategory& c, const float* data, int nSamples, int nChannels)>
completedCallback);

/**
* playMidiFile
*
Expand Down
226 changes: 214 additions & 12 deletions src/headless/UnitTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,173 @@ TEST_CASE( "Retune Surge to .scl files", "[tun]" )
{
Surge::Storage::Scale s = Surge::Storage::readSCLFile("test-data/scl/12-intune.scl" );
surge->storage.retuneToScale(s);
REQUIRE( n2f(48) == 16 );
REQUIRE( n2f(48+12) == 16*2 );
REQUIRE( n2f(48+12+12) == 16*4 );
REQUIRE( n2f(48-12) == 16/2 );
REQUIRE( n2f(surge->storage.scaleConstantNote()) == surge->storage.scaleConstantPitch() );
REQUIRE( n2f(surge->storage.scaleConstantNote()+12) == surge->storage.scaleConstantPitch()*2 );
REQUIRE( n2f(surge->storage.scaleConstantNote()+12+12) == surge->storage.scaleConstantPitch()*4 );
REQUIRE( n2f(surge->storage.scaleConstantNote()-12) == surge->storage.scaleConstantPitch()/2 );
}

SECTION( "Zeus 22" )
{
Surge::Storage::Scale s = Surge::Storage::readSCLFile("test-data/scl/zeus22.scl" );
surge->storage.retuneToScale(s);
REQUIRE( n2f(48) == 16 );
REQUIRE( n2f(48+s.count) == 16*2 );
REQUIRE( n2f(48+2*s.count) == 16*4 );
REQUIRE( n2f(48-s.count) == 16/2 );
REQUIRE( n2f(surge->storage.scaleConstantNote()) == surge->storage.scaleConstantPitch() );
REQUIRE( n2f(surge->storage.scaleConstantNote()+s.count) == surge->storage.scaleConstantPitch()*2 );
REQUIRE( n2f(surge->storage.scaleConstantNote()+2*s.count) == surge->storage.scaleConstantPitch()*4 );
REQUIRE( n2f(surge->storage.scaleConstantNote()-s.count) == surge->storage.scaleConstantPitch()/2 );
}

SECTION( "6 exact" )
{
Surge::Storage::Scale s = Surge::Storage::readSCLFile("test-data/scl/6-exact.scl" );
surge->storage.retuneToScale(s);
REQUIRE( n2f(surge->storage.scaleConstantNote()) == surge->storage.scaleConstantPitch() );
REQUIRE( n2f(surge->storage.scaleConstantNote()+s.count) == surge->storage.scaleConstantPitch()*2 );
REQUIRE( n2f(surge->storage.scaleConstantNote()+2*s.count) == surge->storage.scaleConstantPitch()*4 );
REQUIRE( n2f(surge->storage.scaleConstantNote()-s.count) == surge->storage.scaleConstantPitch()/2 );
}
}

/*
** Create a surge pointer on init sine
*/
std::shared_ptr<SurgeSynthesizer> surgeOnSine()
{
SurgeSynthesizer* surge = Surge::Headless::createSurge(44100);

std::string otp = "Init Sine";
bool foundInitSine = false;
for (int i = 0; i < surge->storage.patch_list.size(); ++i)
{
Patch p = surge->storage.patch_list[i];
if (p.name == otp)
{
surge->loadPatch(i);
foundInitSine = true;
break;
}
}
if( ! foundInitSine )
return nullptr;
else
return std::shared_ptr<SurgeSynthesizer>(surge);
}

/*
** An approximation of the frequency of a signal using a simple zero crossing
** frequency measure (which works great for the sine patch and poorly for others
** At one day we could do this with autocorrelation instead but no need now.
*/
double frequencyForNote( std::shared_ptr<SurgeSynthesizer> surge, int note )
{
auto events = Surge::Headless::makeHoldNoteFor( note, 44100 * 2, 64 );
float *buffer;
int nS, nC;
Surge::Headless::playAsConfigured( surge.get(), events, &buffer, &nS, &nC );

REQUIRE( nC == 2 );
REQUIRE( nS >= 44100 * 2 );
REQUIRE( nS <= 44100 * 2 + 4 * BLOCK_SIZE );

// Trim off the leading and trailing
int nSTrim = (int)(nS / 2 * 0.8);
int start = (int)( nS / 2 * 0.05 );
float *leftTrimmed = new float[nSTrim];

for( int i=0; i<nSTrim; ++i )
leftTrimmed[i] = buffer[ (i + start) * 2 ];

// OK so now look for sample times between positive/negative crosses
int v = -1;
uint64_t dSample = 0, crosses = 0;
for( int i=0; i<nSTrim -1; ++i )
if( leftTrimmed[i] < 0 && leftTrimmed[i+1] > 0 )
{
if( v > 0 )
{
dSample += ( i - v );
crosses ++;
}
v = i;
}

float aSample = 1.f * dSample / crosses;

float time = aSample / 44100.0;
float freq = 1.0 / time;


delete[] leftTrimmed;
delete[] buffer;

return freq;
}

TEST_CASE( "Notes at Appropriate Frequencies", "[tun]" )
{
auto surge = surgeOnSine();
REQUIRE( surge.get() );

SECTION( "Untuned - so regular tuning" )
{
auto f60 = frequencyForNote( surge, 60 );
auto f72 = frequencyForNote( surge, 72 );
auto f69 = frequencyForNote( surge, 69 );

REQUIRE( f60 == Approx( 261.63 ).margin( .1 ) );
REQUIRE( f72 == Approx( 261.63 * 2 ).margin( .1 ) );
REQUIRE( f69 == Approx( 440.0 ).margin( .1 ) );
}

SECTION( "Straight tuning scl file" )
{
Surge::Storage::Scale s = Surge::Storage::readSCLFile("test-data/scl/12-intune.scl" );
surge->storage.retuneToScale(s);
auto f60 = frequencyForNote( surge, 60 );
auto f72 = frequencyForNote( surge, 72 );
auto f69 = frequencyForNote( surge, 69 );

REQUIRE( f60 == Approx( 261.63 ).margin( .1 ) );
REQUIRE( f72 == Approx( 261.63 * 2 ).margin( .1 ) );
REQUIRE( f69 == Approx( 440.0 ).margin( .1 ) );

auto fPrior = f60;
auto twoToTwelth = pow( 2.0f, 1.0/12.0 );
for( int i=61; i<72; ++i )
{
auto fNow = frequencyForNote( surge, i );
REQUIRE( fNow / fPrior == Approx( twoToTwelth ).margin( .0001 ) );
fPrior = fNow;
}
}

SECTION( "Zeus 22" )
{
Surge::Storage::Scale s = Surge::Storage::readSCLFile("test-data/scl/zeus22.scl" );
surge->storage.retuneToScale(s);
auto f60 = frequencyForNote( surge, 60 );
auto fDouble = frequencyForNote( surge, 60 + s.count );
auto fHalf = frequencyForNote( surge, 60 - s.count );

REQUIRE( f60 == Approx( 261.63 ).margin( .1 ) );
REQUIRE( fDouble == Approx( 261.63 * 2 ).margin( .1 ) );
REQUIRE( fHalf == Approx( 261.63 / 2 ).margin( .1 ) );
}

SECTION( "6 exact" )
{
Surge::Storage::Scale s = Surge::Storage::readSCLFile("test-data/scl/6-exact.scl" );
surge->storage.retuneToScale(s);

auto f60 = frequencyForNote( surge, 60 );
auto fDouble = frequencyForNote( surge, 60 + s.count );
auto fHalf = frequencyForNote( surge, 60 - s.count );

REQUIRE( f60 == Approx( 261.63 ).margin( .1 ) );
REQUIRE( fDouble == Approx( 261.63 * 2 ).margin( .1 ) );
REQUIRE( fHalf == Approx( 261.63 / 2 ).margin( .1 ) );
}

}

TEST_CASE( "Simple Single Oscillator is Constant", "[dsp]" )
Expand Down Expand Up @@ -143,22 +295,70 @@ TEST_CASE( "Simple Single Oscillator is Constant", "[dsp]" )

}

TEST_CASE( "All Patches have Bounded Output", "[dsp]" )
{
SurgeSynthesizer* surge = Surge::Headless::createSurge(44100);
REQUIRE( surge );

Surge::Headless::playerEvents_t scale =
Surge::Headless::make120BPMCMajorQuarterNoteScale(0, 44100);

auto callBack = [](const Patch& p, const PatchCategory& pc, const float* data, int nSamples,
int nChannels) -> void {
bool writeWav = false; // toggle this to true to write each sample to a wav file
REQUIRE( nSamples * nChannels > 0 );

if (nSamples * nChannels > 0)
{
const auto minmaxres = std::minmax_element(data, data + nSamples * nChannels);
auto mind = minmaxres.first;
auto maxd = minmaxres.second;

float rms=0, L1=0;
for( int i=0; i<nSamples*nChannels; ++i)
{
rms += data[i]*data[i];
L1 += fabs(data[i]);
}
L1 = L1 / (nChannels*nSamples);
rms = sqrt(rms / nChannels / nSamples );

REQUIRE( L1 < 1 );
REQUIRE( rms < 1 );
REQUIRE( *maxd < 6 );
REQUIRE( *maxd >= 0 );
REQUIRE( *mind > -6 );
REQUIRE( *mind <= 0 );

/*
std::cout << "cat/patch = " << pc.name << " / " << std::left << std::setw(30) << p.name;
std::cout << " range = [" << std::setw(10)
<< std::fixed << *mind << ", " << std::setw(10) << std::fixed << *maxd << "]"
<< " L1=" << L1
<< " rms=" << rms
<< " samp=" << nSamples << " chan=" << nChannels << std::endl;
*/

}
};

Surge::Headless::playOnNRandomPatches(surge, scale, 100, callBack);
delete surge;
}

TEST_CASE( "All Patches are Loadable", "[patch]" )
{
#if 0
// FIXME - why doesn't this work?
SurgeSynthesizer* surge = Surge::Headless::createSurge(44100);
REQUIRE( surge );
int i=0;
for( auto p : surge->storage.patch_list )
{
std::cout << i << " " << p.name << " " << p.path.generic_string() << std::endl;
// std::cout << i << " " << p.name << " " << p.path.generic_string() << std::endl;
surge->loadPatch(i);
++i;
}

delete surge;
#endif
}

TEST_CASE( "lipol_ps class", "[dsp]" )
Expand Down Expand Up @@ -198,6 +398,8 @@ TEST_CASE( "lipol_ps class", "[dsp]" )

}



int runAllTests(int argc, char **argv)
{
int result = Catch::Session().run( argc, argv );
Expand Down
11 changes: 11 additions & 0 deletions test-data/scl/6-exact.scl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
! 6 exact
!
HD2 06-12 - Harmonic division of 2: Harmonics 06-12
6
!
7/6
4/3
3/2
5/3
11/6
2/1

0 comments on commit 005e7f9

Please sign in to comment.