Skip to content

Commit e167c9b

Browse files
tucktuckg00seclaude
andcommitted
v0.4.0: Gain param, release tail, ping pong all modes, UI polish, relink fix
- Volume replaced with Gain parameter (-100 to +24 dB) - Release tail (TAIL toggle): audio continues past slice end during release phase - Ping pong now works in Stretch and Bungee modes (was Repitch only) - BPM displays up to 2 decimal places with finer drag precision - M button moved from slicer bar to action panel - All action/header buttons styled consistently with theme colors - LOAD/UI buttons taller (28px) to match action row - Slice info styled like SAMPLE/filename (12pt label + 14pt value) - AlertWindow for AUTO split themed dark and centered in plugin - Popup menus centered within plugin window - Relink (CmdRelinkFile) preserves slices when relinking missing sample - Serialization v10 with v9 backward compat (linear volume migrated to dB) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 409c715 commit e167c9b

15 files changed

Lines changed: 373 additions & 117 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ A nondestructive, time-stretching, and intersecting sample slicer plugin with in
2020
- **Duplicate slice** — clone a slice with all its locked parameters
2121
- **Hi-DPI scaling** — adjustable UI scale factor (0.5x to 3x)
2222
- **Full state recall** — all parameters, slices, and audio data saved/restored with the DAW session
23-
- **Dark industrial theme** — styled popup menus and controls
24-
23+
- **Theming** — Dark and light themes
2524
## Usage
2625

2726
### Getting Started
@@ -53,10 +52,11 @@ These are the defaults inherited by all slices:
5352
- **PITCH** — pitch shift in semitones (-24 to +24)
5453
- **ALGO** — Repitch (speed=pitch), Stretch (independent pitch/time), or Bungee (granular)
5554
- **ATK / DEC / SUS / REL** — ADSR amplitude envelope
56-
- **PP** — ping-pong (reverse) playback
55+
- **PP** — ping-pong (bounce) playback — works in all algorithm modes (Repitch, Stretch, Bungee)
5756
- **MUTE** — mute group (voices in the same group cut each other off)
5857
- **STRETCH** — enable time-stretching (syncs playback to DAW tempo)
59-
- **VOL** — master volume
58+
- **GAIN** — master gain in dB (-100 to +24 dB)
59+
- **TAIL** — release tail — when enabled, audio continues reading past the slice boundary during the release phase
6060

6161
### Slice Controls (second bar)
6262

project/src/PluginProcessor.cpp

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ IntersectProcessor::IntersectProcessor()
2222
formantParam = apvts.getRawParameterValue (ParamIds::defaultFormant);
2323
formantCompParam = apvts.getRawParameterValue (ParamIds::defaultFormantComp);
2424
grainModeParam = apvts.getRawParameterValue (ParamIds::defaultGrainMode);
25+
releaseTailParam = apvts.getRawParameterValue (ParamIds::defaultReleaseTail);
2526
}
2627

2728
void IntersectProcessor::prepareToPlay (double sampleRate, int /*samplesPerBlock*/)
@@ -151,6 +152,7 @@ void IntersectProcessor::handleCommand (const Command& cmd)
151152
case FieldFormantComp: s.formantComp = val > 0.5f; s.lockMask |= kLockFormantComp; break;
152153
case FieldGrainMode: s.grainMode = (int) val; s.lockMask |= kLockGrainMode; break;
153154
case FieldVolume: s.volume = val; s.lockMask |= kLockVolume; break;
155+
case FieldReleaseTail: s.releaseTail = val > 0.5f; s.lockMask |= kLockReleaseTail; break;
154156
case FieldMidiNote:
155157
s.midiNote = juce::jlimit (0, 127, (int) val);
156158
sliceManager.rebuildMidiMap();
@@ -185,6 +187,7 @@ void IntersectProcessor::handleCommand (const Command& cmd)
185187
dst.formantComp = src.formantComp;
186188
dst.grainMode = src.grainMode;
187189
dst.volume = src.volume;
190+
dst.releaseTail = src.releaseTail;
188191
dst.lockMask = src.lockMask;
189192
dst.colour = src.colour;
190193
// midiNote is already assigned by createSlice
@@ -223,6 +226,15 @@ void IntersectProcessor::handleCommand (const Command& cmd)
223226
break;
224227
}
225228

229+
case CmdRelinkFile:
230+
if (sampleData.loadFromFile (cmd.fileParam, currentSampleRate))
231+
{
232+
sampleMissing.store (false);
233+
missingFilePath.clear();
234+
sliceManager.rebuildMidiMap();
235+
}
236+
break;
237+
226238
case CmdNone:
227239
break;
228240
}
@@ -279,6 +291,7 @@ void IntersectProcessor::processMidi (juce::MidiBuffer& midi)
279291
formantCompParam->load() > 0.5f,
280292
(int) grainModeParam->load(),
281293
masterVolParam->load(),
294+
releaseTailParam->load() > 0.5f,
282295
sampleData);
283296
}
284297
}
@@ -339,7 +352,7 @@ void IntersectProcessor::getStateInformation (juce::MemoryBlock& destData)
339352
juce::MemoryOutputStream stream (destData, false);
340353

341354
// Version
342-
stream.writeInt (9);
355+
stream.writeInt (10);
343356

344357
// APVTS state
345358
auto state = apvts.copyState();
@@ -384,6 +397,8 @@ void IntersectProcessor::getStateInformation (juce::MemoryBlock& destData)
384397
stream.writeInt (s.grainMode);
385398
// v7 fields
386399
stream.writeFloat (s.volume);
400+
// v10 fields
401+
stream.writeBool (s.releaseTail);
387402
}
388403

389404
// v9: store file path only (no PCM)
@@ -396,14 +411,26 @@ void IntersectProcessor::setStateInformation (const void* data, int sizeInBytes)
396411
juce::MemoryInputStream stream (data, (size_t) sizeInBytes, false);
397412

398413
int version = stream.readInt();
399-
if (version < 9)
414+
if (version < 9 || version > 10)
400415
return;
401416

402417
// APVTS state
403418
auto xmlString = stream.readString();
404419
if (auto xml = juce::parseXML (xmlString))
405420
apvts.replaceState (juce::ValueTree::fromXml (*xml));
406421

422+
// v9 -> v10 migration: convert APVTS masterVolume from linear (0-1) to dB
423+
if (version == 9)
424+
{
425+
float oldLinVol = masterVolParam->load();
426+
// Old range was 0-1, but APVTS loaded it as-is into new -100..+24 range
427+
// So the stored value will be the raw 0-1 value. Convert to dB.
428+
float dbVal = (oldLinVol <= 0.0f) ? -100.0f : 20.0f * std::log10 (oldLinVol);
429+
dbVal = juce::jlimit (-100.0f, 24.0f, dbVal);
430+
if (auto* p = apvts.getParameter (ParamIds::masterVolume))
431+
p->setValueNotifyingHost (p->convertTo0to1 (dbVal));
432+
}
433+
407434
// UI state
408435
zoom.store (stream.readFloat());
409436
scroll.store (stream.readFloat());
@@ -439,6 +466,21 @@ void IntersectProcessor::setStateInformation (const void* data, int sizeInBytes)
439466
s.formantComp = stream.readBool();
440467
s.grainMode = stream.readInt();
441468
s.volume = stream.readFloat();
469+
470+
if (version >= 10)
471+
{
472+
s.releaseTail = stream.readBool();
473+
}
474+
475+
// v9 -> v10 migration: convert linear volume (0-1) to dB
476+
if (version == 9)
477+
{
478+
float linVol = s.volume;
479+
if (linVol <= 0.0f)
480+
s.volume = -100.0f;
481+
else
482+
s.volume = 20.0f * std::log10 (linVol);
483+
}
442484
}
443485

444486
// Path-based sample restore

project/src/PluginProcessor.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class IntersectProcessor : public juce::AudioProcessor
4848
CmdSetSliceParam,
4949
CmdDuplicateSlice,
5050
CmdSplitSlice,
51+
CmdRelinkFile,
5152
};
5253

5354
// Param field identifiers for CmdSetSliceParam
@@ -69,6 +70,7 @@ class IntersectProcessor : public juce::AudioProcessor
6970
FieldFormantComp,
7071
FieldGrainMode,
7172
FieldVolume,
73+
FieldReleaseTail,
7274
};
7375

7476
struct Command
@@ -132,6 +134,7 @@ class IntersectProcessor : public juce::AudioProcessor
132134
std::atomic<float>* formantParam = nullptr;
133135
std::atomic<float>* formantCompParam = nullptr;
134136
std::atomic<float>* grainModeParam = nullptr;
137+
std::atomic<float>* releaseTailParam = nullptr;
135138

136139
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (IntersectProcessor)
137140
};

project/src/audio/Slice.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ enum LockBit : uint32_t
1818
kLockFormant = 2048,
1919
kLockFormantComp = 4096,
2020
kLockGrainMode = 8192,
21-
kLockVolume = 16384
21+
kLockVolume = 16384,
22+
kLockReleaseTail = 32768
2223
};
2324

2425
struct Slice
@@ -41,7 +42,8 @@ struct Slice
4142
float formantSemitones = 0.0f;
4243
bool formantComp = false;
4344
int grainMode = 0; // Bungee: -1=Fast, 0=Normal, +1=Smooth
44-
float volume = 1.0f;
45+
float volume = 0.0f;
46+
bool releaseTail = false;
4547
uint32_t lockMask = 0;
4648
juce::Colour colour { 0.4f, 0.7f, 0.95f, 1.0f };
4749
};

project/src/audio/Voice.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ struct Voice
3030
int age = 0;
3131
bool looping = false;
3232
float volume = 1.0f;
33+
bool releaseTail = false;
34+
int sampleEnd = 0; // actual end of sample buffer (for release tail)
3335

3436
// WSOLA fields (legacy, still used for basic WSOLA fallback)
3537
bool wsolaActive = false;

project/src/audio/VoicePool.cpp

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111

1212
static constexpr int kStretchBlockSize = 128;
1313

14+
static inline float dbToLinear (float dB)
15+
{
16+
if (dB <= -100.0f) return 0.0f;
17+
return std::pow (10.0f, dB / 20.0f);
18+
}
19+
1420
VoicePool::VoicePool()
1521
{
1622
for (auto& p : voicePositions)
@@ -112,6 +118,7 @@ void VoicePool::startVoice (int voiceIdx, int sliceIdx, float velocity, int note
112118
bool globalStretchEnabled, float dawBpmVal,
113119
float globalTonality, float globalFormant, bool globalFormantComp,
114120
int globalGrainMode, float globalVolume,
121+
bool globalReleaseTail,
115122
const SampleData& sample)
116123
{
117124
auto& v = voices[voiceIdx];
@@ -161,7 +168,12 @@ void VoicePool::startVoice (int voiceIdx, int sliceIdx, float velocity, int note
161168
// Convert grainMode choice index (0=Fast, 1=Normal, 2=Smooth) to log2 hop adjust (-1, 0, +1)
162169
int hopAdj = grainMode - 1;
163170

164-
v.volume = sm.resolveParam (sliceIdx, kLockVolume, s.volume, globalVolume);
171+
v.volume = dbToLinear (sm.resolveParam (sliceIdx, kLockVolume, s.volume, globalVolume));
172+
173+
v.releaseTail = sm.resolveParam (sliceIdx, kLockReleaseTail,
174+
s.releaseTail ? 1.0f : 0.0f,
175+
globalReleaseTail ? 1.0f : 0.0f) > 0.5f;
176+
v.sampleEnd = sample.getNumFrames();
165177

166178
// Reset stretch state
167179
v.stretchActive = false;
@@ -260,17 +272,23 @@ static void fillStretchBlock (Voice& v, const SampleData& sample)
260272
for (int i = 0; i < inputSamples; ++i)
261273
{
262274
double pos = v.stretchSrcPos;
275+
// Clamp to sample buffer bounds for safety
276+
pos = juce::jlimit (0.0, (double) v.sampleEnd - 1, pos);
263277
v.stretchInBufL[(size_t) i] = sample.getInterpolatedSample (pos, 0);
264278
v.stretchInBufR[(size_t) i] = sample.getInterpolatedSample (pos, 1);
265-
v.stretchSrcPos += 1.0;
279+
v.stretchSrcPos += (double) v.direction;
266280

267281
// Check bounds
268-
if (v.stretchSrcPos >= v.endSample)
282+
if (v.direction > 0 && v.stretchSrcPos >= v.endSample)
269283
{
270284
if (v.pingPong)
271285
{
272286
v.stretchSrcPos = v.endSample - 1;
273-
// For simplicity, just clamp — ping-pong with stretch is edge case
287+
v.direction = -1;
288+
}
289+
else if (v.releaseTail && v.stretchSrcPos < v.sampleEnd)
290+
{
291+
// Continue reading beyond slice end during release tail
274292
}
275293
else
276294
{
@@ -286,6 +304,14 @@ static void fillStretchBlock (Voice& v, const SampleData& sample)
286304
break;
287305
}
288306
}
307+
else if (v.direction < 0 && v.stretchSrcPos <= v.startSample)
308+
{
309+
if (v.pingPong)
310+
{
311+
v.stretchSrcPos = v.startSample;
312+
v.direction = 1;
313+
}
314+
}
289315
}
290316

291317
v.stretchOutBufL.resize (kStretchBlockSize);
@@ -305,6 +331,21 @@ static void fillBungeeBlock (Voice& v, const SampleData& sample)
305331
if (! v.bungeeStretcher)
306332
return;
307333

334+
// Ping pong boundary checks before requesting next grain
335+
if (v.pingPong)
336+
{
337+
if (v.bungeeSpeed > 0.0 && v.bungeeSrcPos >= v.endSample)
338+
{
339+
v.bungeeSrcPos = v.endSample - 1;
340+
v.bungeeSpeed = -std::abs (v.bungeeSpeed);
341+
}
342+
else if (v.bungeeSpeed < 0.0 && v.bungeeSrcPos <= v.startSample)
343+
{
344+
v.bungeeSrcPos = v.startSample;
345+
v.bungeeSpeed = std::abs (v.bungeeSpeed);
346+
}
347+
}
348+
308349
auto& stretcher = *v.bungeeStretcher;
309350

310351
// Set up the request for this grain
@@ -327,13 +368,16 @@ static void fillBungeeBlock (Voice& v, const SampleData& sample)
327368
int maxIn = stretcher.maxInputFrameCount();
328369
v.bungeeInputBuf.resize ((size_t) maxIn * 2);
329370

371+
// Determine effective end for reading (release tail allows reading past slice end)
372+
int effectiveEnd = v.releaseTail && !v.pingPong ? v.sampleEnd : v.endSample;
373+
330374
// Fill input buffer (non-interleaved: ch0 then ch1)
331375
for (int i = 0; i < numFrames; ++i)
332376
{
333377
double pos = inputChunk.begin + i;
334378

335379
float sL = 0.0f, sR = 0.0f;
336-
if (pos >= v.startSample && pos < v.endSample)
380+
if (pos >= v.startSample && pos < effectiveEnd)
337381
{
338382
sL = sample.getInterpolatedSample (pos, 0);
339383
sR = sample.getInterpolatedSample (pos, 1);
@@ -346,8 +390,8 @@ static void fillBungeeBlock (Voice& v, const SampleData& sample)
346390
int muteHead = 0, muteTail = 0;
347391
if (inputChunk.begin < v.startSample)
348392
muteHead = v.startSample - inputChunk.begin;
349-
if (inputChunk.end > v.endSample)
350-
muteTail = inputChunk.end - v.endSample;
393+
if (inputChunk.end > effectiveEnd)
394+
muteTail = inputChunk.end - effectiveEnd;
351395

352396
stretcher.analyseGrain (v.bungeeInputBuf.data(), (intptr_t) maxIn, muteHead, muteTail);
353397

@@ -407,10 +451,24 @@ void VoicePool::processSample (const SampleData& sample, double sampleRate,
407451
// Fill output buffer if empty
408452
if (v.stretchOutReadPos >= v.stretchOutAvail)
409453
{
410-
if (v.stretchSrcPos >= v.endSample && !v.pingPong)
454+
bool pastEnd = (v.direction > 0)
455+
? (v.stretchSrcPos >= v.endSample)
456+
: (v.stretchSrcPos <= v.startSample);
457+
458+
if (pastEnd && !v.pingPong)
411459
{
412-
if (v.envelope.getState() != AdsrEnvelope::Release)
413-
v.envelope.noteOff();
460+
if (v.releaseTail && v.stretchSrcPos < v.sampleEnd && v.stretchSrcPos >= 0)
461+
{
462+
// Release tail: trigger noteOff but keep filling
463+
if (v.envelope.getState() != AdsrEnvelope::Release)
464+
v.envelope.noteOff();
465+
fillStretchBlock (v, sample);
466+
}
467+
else
468+
{
469+
if (v.envelope.getState() != AdsrEnvelope::Release)
470+
v.envelope.noteOff();
471+
}
414472
}
415473
else
416474
{
@@ -444,10 +502,23 @@ void VoicePool::processSample (const SampleData& sample, double sampleRate,
444502
// Fill output buffer if empty
445503
if (v.bungeeOutReadPos >= v.bungeeOutAvail)
446504
{
447-
if (v.bungeeSrcPos >= v.endSample && !v.pingPong)
505+
bool pastEnd = (v.bungeeSpeed > 0.0)
506+
? (v.bungeeSrcPos >= v.endSample)
507+
: (v.bungeeSrcPos <= v.startSample);
508+
509+
if (pastEnd && !v.pingPong)
448510
{
449-
if (v.envelope.getState() != AdsrEnvelope::Release)
450-
v.envelope.noteOff();
511+
if (v.releaseTail && v.bungeeSrcPos < v.sampleEnd && v.bungeeSrcPos >= 0)
512+
{
513+
if (v.envelope.getState() != AdsrEnvelope::Release)
514+
v.envelope.noteOff();
515+
fillBungeeBlock (v, sample);
516+
}
517+
else
518+
{
519+
if (v.envelope.getState() != AdsrEnvelope::Release)
520+
v.envelope.noteOff();
521+
}
451522
}
452523
else
453524
{
@@ -517,6 +588,12 @@ void VoicePool::processSample (const SampleData& sample, double sampleRate,
517588
if (newPos < v.startSample)
518589
newPos += len;
519590
}
591+
else if (v.releaseTail && newPos >= v.endSample && newPos < v.sampleEnd)
592+
{
593+
// Release tail: trigger noteOff but keep reading beyond slice end
594+
if (v.envelope.getState() != AdsrEnvelope::Release)
595+
v.envelope.noteOff();
596+
}
520597
else
521598
{
522599
if (v.envelope.getState() != AdsrEnvelope::Release)

project/src/audio/VoicePool.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class VoicePool
2121
bool globalStretchEnabled, float dawBpm,
2222
float globalTonality, float globalFormant, bool globalFormantComp,
2323
int globalGrainMode, float globalVolume,
24+
bool globalReleaseTail,
2425
const SampleData& sample);
2526

2627
void releaseNote (int note);

0 commit comments

Comments
 (0)