Sample based synth sustain region produces clicks

If you create a synth using a sample and set the sustain frames there's an audible click when the sustain region loops:

local path = "Samples/pcm/sandman_pad", 
self.sample = playdate.sound.sample.new(path)
self.sampleSynth = playdate.sound.synth.new(self.sample, 40804, 125326)

Then play a long note.

To test I've exageratted the sustain boundary - fading in from 40804, and out to 125326 - so it should be looping at a silent point back to a silent point.

Looping this selection in Audacity is smooth, and the frame/sample values come from those loop points, but Playdate produces an audible click at most notes - some lower notes sometimes sustain smoothly (and sound great!).

I may be missig something here, but I think this is an issue in the synth implementation.

Cheers,
Orllewin

1 Like

Actually, it turns out any sample based synth produces clicks when playing notes... there's something very broken here.

By "any sample based synth" do you mean regardless of looping? Where is the clicking happening?

Regarding the looping, is the file encoded with ADPCM, by chance? For some reason I don't remember now I commented out the code that sets the position inside of the ADPCM block when it loops, so it's always jumps to the beginning of the block instead. I'll take a look at that now, see if I can figure out if that was intentional or not and get it working correctly again.

No, I was sure to use PCM for these. You can hear the clicks and pops in this video: https://www.youtube.com/watch?v=KvH9JL0IWqw

It doesn't always happen, but if you retrigger a lot you get lots of pops. This is all manual so it's repeat calls to playMidiNote. The underlying audio implementation is really simple, just your basic Lua API wrapped in a fancy UI!

So the pops are either happening at the start of playing a new note, or when terminating a currently playing note.

The sustain region too, same deal when it loops (I've turned that off for now)

I've managed to mitigate this (in a quite expensive manner) by fading out the playing note before triggering the new note:

self.releaseTimer = playdate.timer.new(100, 1.0, 0.0)
self.releaseTimer.updateCallback = function(timer)
	self.sampleSynth:setVolume(timer.value)
end
self.releaseTimer.timerEndedCallback = function(timer)
	self.sampleSynth:setVolume(1)
	self.sampleSynth:playMIDINote(math.floor(event:getValue()), 1)
end	

So the pops and clicks are happening when terminating the currently playing note.

(This is all unrelated to the sustain region issue of course!)

I'm not totally clear on how this is put together, but could you use the envelope to fade it out instead?

self.sampleSynth:setRelease(0.1)
self.sampleSynth:setFinishCallback(function(s) s:playMIDINote(math.floor(event:getValue()) end)

About the clicking: I've got a fix for the looping, wasn't chopping the frame up correctly. This looks like another place where I roughed in the code and forgot to go back and finish it. :confused: Explains the lack of docs, too. The click when triggering a note while it's already playing is a bit trickier, since it jumps immediately back to the start of the data.

Screen Shot 2023-10-24 at 4.38.49 PM

A crossfade would be ideal but a bit of work. The simplest workaround I can think of is to use two synths there and ping-pong between them so you can play the note for the next event while letting the previous finish on its own.

1 Like

Aaah brilliant, thank you for looking into it, glad it wasn't me doing something silly. I did notice some improvement with sustain when I turned off dithering when exporting hits from Ableton but it still had noticeable artefacts.

I'll find the lightest way of working around things when retriggering a note, this timer solution is far too heavy (sounds great though: https://www.youtube.com/watch?v=KKrzh5pRDTo ), having a release with callback would be better if I can get it to work - only one synth per 'synth' then... but two synths better than spawning a timer every note!

if self.sampleSynthA:isPlaying() then
	self.sampleSynthA:noteOff()
	self.sampleSynthB:playMIDINote(math.floor(event:getValue()))
elseif self.sampleSynthB:isPlaying() then
	self.sampleSynthB:noteOff()
	self.sampleSynthA:playMIDINote(math.floor(event:getValue()))
else
	self.sampleSynthA:playMIDINote(math.floor(event:getValue()))
end

This works beautifully.

2 Likes