Synth using samples with short loops become distorted when playing high notes

,

Platform: macOS 13
SDK version: 2.5
(Running on Playdate Simulator)

I'm writing a music engine that uses looping samples for its instruments, and some of those samples have very short loops that only encompass a few hundred samples. I'm finding that when they're playing high notes, the output becomes distorted and garbled.

Here's a sample that you can use to demonstrate the issue: 7001_lead-c4.wav.zip (3.2 KB)
and some accompanying code:

The following plays the sample at its normal speed and sounds fine:

AudioSample *sample = pd->sound->sample->load("audio/7001_lead-c4");
PDSynth *synth = pd->sound->synth->newSynth();
pd->sound->synth->setSample(synth, sample, 520, 1368);
pd->sound->channel->addSource(pd->sound->getDefaultChannel(), (SoundSource *)synth);
pd->sound->synth->playMIDINote(synth, NOTE_C4, 0.5, 0.5, 0);

If however you play a C6, i.e.:

pd->sound->synth->playMIDINote(synth, NOTE_C4 + 24, 0.5, 0.5, 0);

...it sounds awful! It buzzes as though the loop start and end points have drifted. (My own speculation as to what could be going wrong.)

P.S. Hopefully the fact that I'm opening up individual threads in a short span of time for the bugs I'm finding isn't an issue. Writing a music engine is really putting the Playdate API through its paces, though!

...and it so happens that in the course of writing that sample code for the above bug report, I found another bug. :sweat: I'll go ahead and post it here:

It seems PDSynth ignores the release portion of its ADSR envelope. The following uses the same sample from above, and you can hear the attack, decay, and sustain phases. But when the note ends, it just stops abruptly rather than fading out for one second:

AudioSample *sample = pd->sound->sample->load("audio/7001_lead-c4");
PDSynth *synth = pd->sound->synth->newSynth();
pd->sound->synth->setSample(synth, sample, 520, 1368);
pd->sound->synth->setAttackTime(synth, 0.2f);
pd->sound->synth->setDecayTime(synth, 0.4f);
pd->sound->synth->setSustainLevel(synth, 0.5f);
pd->sound->synth->setReleaseTime(synth, 1.0);
pd->sound->channel->addSource(pd->sound->getDefaultChannel(), (SoundSource *)synth);
pd->sound->synth->playMIDINote(synth, NOTE_C4, 0.5, 2.0, 0);

This very well may be the same bug as this report I made earlier.

So many great bugs! Thank you so much for finding these and for your excellent reports. This part of the API doesn't get as much use as the more mainstream features, so unfortunately the bugs have a longer shelf life. But anyway..

The weird distortion was because the code wasn't expecting to have to loop more than once in a (256-sample) render cycle, leaving the rest of the data in the update frame blank. Not sure what I was thinking there.. This means with your sustain range of 848 samples it will start glitching above 848/256=3.3125x playback rate, or A5 (if I'm doing the math right). That was an easy fix, but the envelope release problem is a little trickier because it's not immediately obvious what the intended behavior is in different circumstances. Here's what I came up with:

  • If the synth has a sustain range then it's looped while the note is active (same as current, though with the above bug fixed)
  • If the sustain range goes all the way to the end of the sample then it continues to loop that while the release phase of the envelope plays out
  • But if the sample has data after the sustain range then I assume you want to play that instead, so when you release the note we let it play through to the end
  • One last complication here: If the sample has data after the sustain range and you've changed the envelope settings then we apply that--notably, if your release time is zero then the synth stops playing as soon as the note is released and you don't hear the end of the sample. If you don't touch the envelope it will play to the end. (I realize now that we need a "clearEnvelope" function so you can go back to that behavior after you've set an envelope. I'll add that, too..)

Let me know if that makes sense, or if you have any suggestions!

1 Like

Glad to hear you've managed to fix the sample looping issue! :partying_face:

Regarding the sustain range, I see your point. Since I've been inundating myself in old school tracker music formats like MOD and S3M, I sort of just assumed that an instrument should keep looping during its release phase. But that's largely because those old formats don't support playing out a sample past its looping range anyway!

What occurs to me is that we're dealing with essentially two different but overlapping behaviors: whether or not to exit the sample's loop on release (regardless of its ADSR envelope), and then the ADSR envelope itself which could be configured to have any duration of release phase or no release phase at all. I can see use cases for the four possible combinations of configuring those two behaviors. (That is, 1. loop and stop on release / no release phase, 2. keep looping and fade out, 3. exit the loop and don't fade out, and 4. exit the loop and fade out.)

With your idea and the case of there being sample data after the sustain / loop range, I'm not sure whether you were thinking it would fade out according to the release time, or just play through to the end without fading. But I think we'd want both to be possible.

One idea: there could be an additional function that simply sets what the looping behavior is on release: either keep looping, or play through to the end of the sample. That has the advantage of making everything explicit, and then the user can set up whatever ADSR envelope they want.

Or, going back to your idea, perhaps there could be a setting for the release time that indicates an indefinite release, in which case a note would either keep looping if there is no sample data after the sustain range, or if there is, it plays to the end of the sample without fading.

(Hopefully I explained that all clearly enough!)

this release behaviour you've described, where release basically becomes a fadeout envelope when the sound being played is longer than than the sustain time, is probably more common in DAWs or effect pedal modelling, but not very common in synths.

A more frequently seen behaviour is to use a very shallow reverb and delay effect to create the release based on the last few samples once the note is cutoff.

Any word on when the audio fixes you've already got will be available in an API update?

1 Like

2.6 is currently in beta, will be released soon if that goes well. Here are the audio-related changes from the release notes:

Added

  • Pitch bend info from midi files is now used
  • Added snd.instrument:setPitchBend() and setPitchBendRange() to Lua API
  • Added snd.channel.getDry/WetLevelSignal()

Fixed

  • delayline tap should be stereo if its delayline is
  • Fixed R/L levels for panned mono channels
  • Fileplayers without valid sources no longer crash in certain situations
  • Fixed playback issues in sample-based synths with short sustain ranges
  • Synth instrument notes scheduled for the future are no longer dropped
2 Likes

I do such panning in my existing games. Will their audio change? Anything I need to do?

There's a version check on the fix, so the behavior won't change until you recompile under 2.6. The bug was we were scaling the mono stream like

l *= MAX(1, 1-pan)
r *= MAX(1, 1+pan)

but that should be MIN, not MAX. In other words, if you've got a mono source and pan is set to -1=full left, instead of mixing into the left channel at vol=1 and right at vol=0, it's left=2 and right=1. If your source reaches full amplitude it will clip noticeably on the left channel in this case.

As a result of the fix your panned sounds will have lower overall volume (again, after you've recompiled, which updates the pdxversion entry in the game's pdxinfo file to 20600). If, say, you had a channel panned full right and you want it to sound the same in 2.6, you'd pan it to 0.5 instead and double the volume. And I think it's a linear scaling for other values--so if it was pan=0.5 on 2.5, then the equivalent is pan=0.25 and volume=1.5 on 2.6. Or something like that.. But the gist is the pan effect is now more pronounced and also quieter.

1 Like

Thanks Dave! Unlikely I will be recompiling the old games.

But good to know the difference for future games. Cheers

I thought I had noticed some odd behavior in regards to panning. I think this would explain it. Glad to hear it's fixed!

Does the Playdate C API already have a way of being able to check which version of the API is being compiling against at compile time? Would there be a way to use that in a library?

There's no API on the C side for that currently. I'll file that now, if it's not already in the system. You can manually parse the pdxinfo file to see what version SDK the game was built with:

static int getPDXVersion(PlaydateAPI* pd)
{
	// assumes pdxversion entry in pdxinfo file is w/in first 1024 bytes, which is a pretty safe bet (for now?)
	
	SDFile* file = pd->file->open("pdxinfo", kFileRead);
	char buf[1024];
	int n = pd->file->read(file, buf, sizeof(buf));
	char* tag = strnstr(buf, "pdxversion=", n);
	
	pd->file->close(file);
	
	if ( tag != NULL )
		return atoi(tag+strlen("pdxversion="));
	else
		return 0;
}

but there's no way to tell which version it's running under. Also note that we don't always increment pdxversion for minor (i.e. x.x.1) releases, only when we're using that for a version check to preserve existing behavior.

Was filed, ftr, last February: Testing SDK version from C or Lua - #4 by dave

That's good to know, but actually I was interested in a way to check at compile time, so that I can optionally include certain fixes in my code depending on the API version.

I found a way to do that in a CMake project, in case anyone else wants to use it:

if(EXISTS "${SDK}/VERSION.txt")
    file(READ "${SDK}/VERSION.txt" VERSION_CONTENTS)
    string(STRIP "${VERSION_CONTENTS}" VERSION_NUMBER)
    string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" _ ${VERSION_NUMBER})
    math(EXPR VERSION_INTEGER "${CMAKE_MATCH_1} * 10000 + ${CMAKE_MATCH_2} * 100 + ${CMAKE_MATCH_3}")
    add_compile_definitions(PLAYDATE_API_VERSION=${VERSION_INTEGER})
endif()
2 Likes