Skip to content

Commit a020870

Browse files
tucktuckg00seclaude
andcommitted
v0.6.0: Auto-chop with transient detection, undo/redo, UI polish
- Transient detection auto-chop with adjustable sensitivity and live preview - AutoChopPanel overlay: sensitivity slider, equal divisions, detect transients - Log-ratio onset detection with percentile threshold and peak-picking - Snapshot-based undo/redo (UNDO/REDO buttons in header bar) - Rename S -> ZX (snap-to-zero-crossing), M -> FM (follow MIDI) - Remove unused SliceListPanel - Move screenshot to .github/assets/, update README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ace310 commit a020870

19 files changed

Lines changed: 705 additions & 144 deletions

README.md

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A nondestructive, time-stretching, and intersecting sample slicer plugin with independent per-slice parameter control.
44

5-
![INTERSECT screenshot](docs/screenshot.png)
5+
![INTERSECT screenshot](.github/assets/screenshot.png)
66

77
## Features
88

@@ -14,13 +14,17 @@ A nondestructive, time-stretching, and intersecting sample slicer plugin with in
1414
- **Stretch** — independent pitch and time control via [Signalsmith Stretch](https://github.com/Signalsmith-Audio/signalsmith-stretch), with tonality, formant shift, and formant compensation controls
1515
- **Bungee** — grain-based time-stretch via [Bungee](https://github.com/bungee-audio-stretch/bungee), with adjustable grain mode (Fast / Normal / Smooth)
1616
- **Lazy chop** — play the sample continuously and place slice boundaries in real time by pressing MIDI keys
17+
- **Auto chop** — split slices equally (2-128 divisions) or detect transients with adjustable sensitivity and live preview
18+
- **Snap-to-zero-crossing** — click-free slice boundaries (ZX button)
19+
- **Undo/redo** — snapshot-based undo/redo for all slice and parameter changes
1720
- **SET BPM** — calculate BPM from a slice length and a musical time unit (4 bars down to 1/32 bar)
1821
- **Mute groups** — voices in the same group cut each other off
19-
- **MIDI-selects-slice** — optionally auto-select a slice in the UI when its MIDI note is played
22+
- **Follow MIDI** — optionally auto-select a slice in the UI when its MIDI note is played (FM button)
2023
- **Duplicate slice** — clone a slice with all its locked parameters
2124
- **Hi-DPI scaling** — adjustable UI scale factor (0.5x to 3x)
2225
- **Full state recall** — all parameters, slices, and audio data saved/restored with the DAW session
23-
- **Theming** — Dark and light themes
26+
- **Theming** — dark and light themes
27+
2428
## Usage
2529

2630
### Getting Started
@@ -33,16 +37,26 @@ A nondestructive, time-stretching, and intersecting sample slicer plugin with in
3337

3438
- **ADD** — click the button, then click and drag on the waveform to draw a slice region
3539
- **LAZY** — starts continuous playback; press any MIDI key to place a slice boundary at the current playhead. Click **STOP** when done. The final slice closes at the end of the sample
36-
- **AUTO** — splits the selected slice into equal parts (2-128 divisions)
40+
- **AUTO** — opens the Auto Chop panel over the waveform:
41+
- **Sensitivity slider** (0-100%) — adjusts transient detection sensitivity with live preview lines on the waveform
42+
- **Divisions field** — number of equal parts for time-based splitting (2-128)
43+
- **Split Equal** — splits the selected slice into equal divisions
44+
- **Detect Transients** — splits at detected transient positions
3745
- **COPY** — duplicates the selected slice with all its parameters
3846
- **DEL** — deletes the selected slice
3947

4048
### Editing Slices
4149

4250
- Drag the **S** handle (left edge) or **E** handle (right edge) to resize a slice
4351
- Drag the middle of a slice to move it
44-
- Click a slice in the waveform, slice lane, or slice list to select it
45-
- Scroll wheel to zoom, shift+scroll or middle-drag to scroll
52+
- Click a slice in the waveform or slice lane to select it
53+
- Toggle **ZX** to snap slice edges to zero-crossings (eliminates clicks)
54+
55+
### Keyboard / Mouse Shortcuts
56+
57+
- **Scroll wheel** — zoom in/out (anchored to cursor position)
58+
- **Shift + scroll wheel** — scroll horizontally
59+
- **Middle-click drag** — simultaneous scroll (horizontal) and zoom (vertical)
4660

4761
### Sample Controls (top bar)
4862

@@ -57,6 +71,7 @@ These are the defaults inherited by all slices:
5771
- **STRETCH** — enable time-stretching (syncs playback to DAW tempo)
5872
- **GAIN** — master gain in dB (-100 to +24 dB)
5973
- **TAIL** — release tail — when enabled, audio continues reading past the slice boundary during the release phase
74+
- **<** / **>** — undo / redo
6075

6176
### Slice Controls (second bar)
6277

@@ -66,9 +81,9 @@ Per-slice overrides. Each parameter has a **lock icon** — click it to override
6681

6782
Calculates BPM from a slice's length. Select a slice, click **SET BPM**, and choose a time division (4 bars, 1 bar, 1/4 note, etc.). INTERSECT sets the BPM so that slice equals the chosen duration at your DAW's tempo.
6883

69-
### MIDI-Selects-Slice
84+
### Follow MIDI
7085

71-
Click the **M** button to toggle. When active, playing a MIDI note automatically selects that slice in the UI.
86+
Click the **FM** button to toggle. When active, playing a MIDI note automatically selects that slice in the UI.
7287

7388
## Install
7489

project/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ target_sources(Intersect PRIVATE
8282
src/ui/ScrollZoomBar.cpp
8383
src/ui/SliceLane.cpp
8484
src/ui/ActionPanel.cpp
85+
src/ui/AutoChopPanel.cpp
8586
)
8687

8788
target_include_directories(Intersect PRIVATE

project/src/PluginProcessor.cpp

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "PluginProcessor.h"
22
#include "PluginEditor.h"
33
#include "audio/GrainEngine.h"
4+
#include "audio/AudioAnalysis.h"
45

56
IntersectProcessor::IntersectProcessor()
67
: AudioProcessor (BusesProperties()
@@ -90,8 +91,51 @@ void IntersectProcessor::drainCommands()
9091
updateHostDisplay (ChangeDetails().withNonParameterStateChanged (true));
9192
}
9293

94+
void IntersectProcessor::captureSnapshot()
95+
{
96+
UndoManager::Snapshot snap;
97+
for (int i = 0; i < SliceManager::kMaxSlices; ++i)
98+
snap.slices[(size_t) i] = sliceManager.getSlice (i);
99+
snap.numSlices = sliceManager.getNumSlices();
100+
snap.selectedSlice = sliceManager.selectedSlice;
101+
snap.rootNote = sliceManager.rootNote.load();
102+
snap.apvtsState = apvts.copyState();
103+
snap.midiSelectsSlice = midiSelectsSlice.load();
104+
snap.snapToZeroCrossing = snapToZeroCrossing.load();
105+
undoMgr.push (snap);
106+
}
107+
108+
void IntersectProcessor::restoreSnapshot (const UndoManager::Snapshot& snap)
109+
{
110+
for (int i = 0; i < SliceManager::kMaxSlices; ++i)
111+
sliceManager.getSlice (i) = snap.slices[(size_t) i];
112+
sliceManager.setNumSlices (snap.numSlices);
113+
sliceManager.selectedSlice = snap.selectedSlice;
114+
sliceManager.rootNote.store (snap.rootNote);
115+
apvts.replaceState (snap.apvtsState);
116+
midiSelectsSlice.store (snap.midiSelectsSlice);
117+
snapToZeroCrossing.store (snap.snapToZeroCrossing);
118+
sliceManager.rebuildMidiMap();
119+
}
120+
93121
void IntersectProcessor::handleCommand (const Command& cmd)
94122
{
123+
// Capture snapshot before modifying commands (not for load/relink/lazychop/undo/redo/none)
124+
switch (cmd.type)
125+
{
126+
case CmdNone:
127+
case CmdLoadFile:
128+
case CmdRelinkFile:
129+
case CmdLazyChopStart:
130+
case CmdLazyChopStop:
131+
case CmdUndo:
132+
case CmdRedo:
133+
break;
134+
default:
135+
captureSnapshot();
136+
break;
137+
}
138+
95139
switch (cmd.type)
96140
{
97141
case CmdLoadFile:
@@ -136,7 +180,8 @@ void IntersectProcessor::handleCommand (const Command& cmd)
136180
psp.grainMode = (int) grainModeParam->load();
137181
psp.sampleRate = currentSampleRate;
138182
psp.sample = &sampleData;
139-
lazyChop.start (sampleData.getNumFrames(), sliceManager, psp);
183+
lazyChop.start (sampleData.getNumFrames(), sliceManager, psp,
184+
snapToZeroCrossing.load(), &sampleData.getBuffer());
140185
}
141186
break;
142187

@@ -259,11 +304,21 @@ void IntersectProcessor::handleCommand (const Command& cmd)
259304

260305
sliceManager.deleteSlice (sel);
261306

307+
bool doSnap = snapToZeroCrossing.load() && sampleData.isLoaded();
262308
int firstNew = -1;
263309
for (int i = 0; i < count; ++i)
264310
{
265311
int s = startS + i * len / count;
266312
int e = startS + (i + 1) * len / count;
313+
// Snap interior boundaries (not first start / last end)
314+
if (doSnap)
315+
{
316+
if (i > 0)
317+
s = AudioAnalysis::findNearestZeroCrossing (sampleData.getBuffer(), s);
318+
if (i < count - 1)
319+
e = AudioAnalysis::findNearestZeroCrossing (sampleData.getBuffer(), e);
320+
}
321+
if (e - s < 64) e = s + 64;
267322
int idx = sliceManager.createSlice (s, e);
268323
if (i == 0) firstNew = idx;
269324
}
@@ -275,6 +330,41 @@ void IntersectProcessor::handleCommand (const Command& cmd)
275330
break;
276331
}
277332

333+
case CmdTransientChop:
334+
{
335+
int sel = sliceManager.selectedSlice;
336+
if (sel >= 0 && sel < sliceManager.getNumSlices() && ! cmd.positions.empty())
337+
{
338+
const auto& src = sliceManager.getSlice (sel);
339+
int startS = src.startSample;
340+
int endS = src.endSample;
341+
342+
sliceManager.deleteSlice (sel);
343+
344+
// Build boundary list: [startS, ...positions..., endS]
345+
std::vector<int> bounds;
346+
bounds.push_back (startS);
347+
for (int p : cmd.positions)
348+
bounds.push_back (p);
349+
bounds.push_back (endS);
350+
351+
int firstNew = -1;
352+
for (size_t i = 0; i + 1 < bounds.size(); ++i)
353+
{
354+
int s = bounds[i];
355+
int e = bounds[i + 1];
356+
if (e - s < 64) continue;
357+
int idx = sliceManager.createSlice (s, e);
358+
if (firstNew < 0) firstNew = idx;
359+
}
360+
361+
sliceManager.rebuildMidiMap();
362+
if (firstNew >= 0)
363+
sliceManager.selectedSlice = firstNew;
364+
}
365+
break;
366+
}
367+
278368
case CmdRelinkFile:
279369
if (sampleData.loadFromFile (cmd.fileParam, currentSampleRate))
280370
{
@@ -284,6 +374,16 @@ void IntersectProcessor::handleCommand (const Command& cmd)
284374
}
285375
break;
286376

377+
case CmdUndo:
378+
if (undoMgr.canUndo())
379+
restoreSnapshot (undoMgr.undo());
380+
break;
381+
382+
case CmdRedo:
383+
if (undoMgr.canRedo())
384+
restoreSnapshot (undoMgr.redo());
385+
break;
386+
287387
case CmdNone:
288388
break;
289389
}
@@ -454,7 +554,7 @@ void IntersectProcessor::getStateInformation (juce::MemoryBlock& destData)
454554
juce::MemoryOutputStream stream (destData, false);
455555

456556
// Version
457-
stream.writeInt (11);
557+
stream.writeInt (12);
458558

459559
// APVTS state
460560
auto state = apvts.copyState();
@@ -509,14 +609,17 @@ void IntersectProcessor::getStateInformation (juce::MemoryBlock& destData)
509609
// v9: store file path only (no PCM)
510610
stream.writeString (sampleData.getFilePath());
511611
stream.writeString (sampleData.getFileName());
612+
613+
// v12: snap-to-zero-crossing toggle
614+
stream.writeBool (snapToZeroCrossing.load());
512615
}
513616

514617
void IntersectProcessor::setStateInformation (const void* data, int sizeInBytes)
515618
{
516619
juce::MemoryInputStream stream (data, (size_t) sizeInBytes, false);
517620

518621
int version = stream.readInt();
519-
if (version < 9 || version > 11)
622+
if (version < 9 || version > 12)
520623
return;
521624

522625
// APVTS state
@@ -621,6 +724,12 @@ void IntersectProcessor::setStateInformation (const void* data, int sizeInBytes)
621724
}
622725

623726
sliceManager.rebuildMidiMap();
727+
728+
// v12: snap-to-zero-crossing
729+
if (version >= 12)
730+
snapToZeroCrossing.store (stream.readBool());
731+
else
732+
snapToZeroCrossing.store (false);
624733
}
625734

626735
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()

project/src/PluginProcessor.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
#pragma once
22
#include <juce_audio_processors/juce_audio_processors.h>
3+
#include <vector>
34
#include "audio/SampleData.h"
45
#include "audio/SliceManager.h"
56
#include "audio/VoicePool.h"
67
#include "audio/LazyChopEngine.h"
8+
#include "UndoManager.h"
79
#include "params/ParamIds.h"
810
#include "params/ParamLayout.h"
911

@@ -49,7 +51,10 @@ class IntersectProcessor : public juce::AudioProcessor
4951
CmdSetSliceParam,
5052
CmdDuplicateSlice,
5153
CmdSplitSlice,
54+
CmdTransientChop,
5255
CmdRelinkFile,
56+
CmdUndo,
57+
CmdRedo,
5358
};
5459

5560
// Param field identifiers for CmdSetSliceParam
@@ -83,6 +88,7 @@ class IntersectProcessor : public juce::AudioProcessor
8388
int intParam2 = 0;
8489
float floatParam1 = 0.0f;
8590
juce::File fileParam;
91+
std::vector<int> positions;
8692
};
8793

8894
void pushCommand (Command cmd);
@@ -105,6 +111,12 @@ class IntersectProcessor : public juce::AudioProcessor
105111
// MIDI-selects-slice toggle
106112
std::atomic<bool> midiSelectsSlice { false };
107113

114+
// Snap-to-zero-crossing toggle
115+
std::atomic<bool> snapToZeroCrossing { false };
116+
117+
// Undo/redo
118+
UndoManager undoMgr;
119+
108120
// Missing sample state (for relink UI)
109121
std::atomic<bool> sampleMissing { false };
110122
juce::String missingFilePath;
@@ -113,6 +125,8 @@ class IntersectProcessor : public juce::AudioProcessor
113125
void drainCommands();
114126
void handleCommand (const Command& cmd);
115127
void processMidi (juce::MidiBuffer& midi);
128+
void captureSnapshot();
129+
void restoreSnapshot (const UndoManager::Snapshot& snap);
116130

117131
// Command FIFO
118132
static constexpr int kFifoSize = 64;

project/src/UndoManager.h

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#pragma once
2+
#include <deque>
3+
#include <juce_data_structures/juce_data_structures.h>
4+
#include "audio/Slice.h"
5+
#include "audio/SliceManager.h"
6+
7+
class UndoManager
8+
{
9+
public:
10+
static constexpr int kMaxSnapshots = 32;
11+
12+
struct Snapshot
13+
{
14+
std::array<Slice, SliceManager::kMaxSlices> slices;
15+
int numSlices = 0;
16+
int selectedSlice = -1;
17+
int rootNote = 36;
18+
juce::ValueTree apvtsState;
19+
bool midiSelectsSlice = false;
20+
bool snapToZeroCrossing = false;
21+
};
22+
23+
void push (const Snapshot& snap)
24+
{
25+
// Discard any redo history
26+
while (undoStack.size() > pos)
27+
undoStack.pop_back();
28+
29+
undoStack.push_back (snap);
30+
31+
// Enforce max size
32+
while ((int) undoStack.size() > kMaxSnapshots)
33+
undoStack.pop_front();
34+
35+
pos = (int) undoStack.size();
36+
}
37+
38+
bool canUndo() const { return pos > 0; }
39+
bool canRedo() const { return pos < (int) undoStack.size(); }
40+
41+
// Returns the snapshot to restore (the state before the last change)
42+
Snapshot undo()
43+
{
44+
if (pos > 0)
45+
--pos;
46+
return undoStack[(size_t) pos];
47+
}
48+
49+
Snapshot redo()
50+
{
51+
if (pos < (int) undoStack.size())
52+
++pos;
53+
return undoStack[(size_t) pos - 1];
54+
}
55+
56+
void clear()
57+
{
58+
undoStack.clear();
59+
pos = 0;
60+
}
61+
62+
private:
63+
std::deque<Snapshot> undoStack;
64+
int pos = 0; // position after the last pushed snapshot
65+
};

0 commit comments

Comments
 (0)