Skip to content

Commit 1c7a11b

Browse files
committed
feat: quality-ranked chop-count slider + waveform tool UX refinements
Chop-count slider (Detect hits): - Replace Few/Medium/Many sensitivity with a live slider that previews the exact number of chops, backed by spectral-flux peaks ranked by strength (strongest hits first, greedy min-gap dedup). Analyze once, take top-N. - Slider drag updates chops live and collapses into a single undo entry. Loop audition: - Clicking a loop candidate now plays it on repeat; clicking it again stops; clicking another switches instantly (fixes the old loop hijacking playback). - Drop the separate preview button; the row play/pause icon shows which loops. Toolbar: - Tool selector is now discrete icon+label buttons instead of a segmented pill so it no longer competes visually with the main Chop/Library/Packs nav. - Unify undo/redo into the toolbar button language, keep play as the distinct primary, separate groups with dividers. - Move the scroll/pan hint to the footer; drop the redundant Playing/Paused text. Claude-Session: https://claude.ai/code/session_01YbzmDc7K16chivuiTa7Lfk
1 parent 95dcca6 commit 1c7a11b

5 files changed

Lines changed: 291 additions & 136 deletions

File tree

src/components/AudioWaveform.tsx

Lines changed: 158 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ import TrimOverlay from './TrimOverlay'
2121
import { ToolSelector, ToolContextBar } from './WaveformTools'
2222
import {
2323
LOOP_BAR_OPTIONS,
24+
MIN_HIT_CHOPS,
25+
DEFAULT_HIT_CHOPS,
2426
type ChopMethod,
25-
type HitSensitivity,
2627
type LoopBarCount,
2728
type SliceCount,
2829
type WaveformTool,
2930
} from './waveformTools.constants'
30-
import { detectTransientsFromUrl, findLoopCandidatesFromUrl } from '@/lib/audioAnalysis'
31+
import { rankTransientsFromUrl, findLoopCandidatesFromUrl } from '@/lib/audioAnalysis'
3132
import { remapRegionsForTrim } from '@/lib/remapRegions'
3233
import { cn } from '@/lib/utils'
3334
import { formatTime, toLocalFileUrl } from '@/utils'
@@ -43,12 +44,6 @@ interface AudioWaveformProps {
4344
initialRegions?: ProjectRegion[]
4445
}
4546

46-
const MIN_AUTO_CHOP_REGION_SECONDS: Record<HitSensitivity, number> = {
47-
coarse: 1.6,
48-
medium: 0.8,
49-
fine: 0.4,
50-
} as const
51-
5247
const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegions }: AudioWaveformProps) => {
5348
const { activeProject, autosaveActiveRegions, applyLocalTrim } = useProjectsStore()
5449
const { createPack, setSlot, hardwareProfileId } = usePacksStore()
@@ -94,9 +89,23 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
9489
if (region) wavesurfer?.setTime(region.start)
9590
}, [highlightCandidate, wavesurfer])
9691

92+
// Clicking a loop auditions it: select + start looping. Clicking the one already playing stops
93+
// it; clicking a different one switches playback (playLooping replaces the active loop bounds, so
94+
// the previous loop can't hijack playback).
95+
const handleCandidateClick = useCallback((id: string) => {
96+
const region = candidateRegionsRef.current.get(id)
97+
if (!region) return
98+
if (id === selectedCandidateId && wavesurfer?.isPlaying()) {
99+
wavesurfer.pause()
100+
return
101+
}
102+
selectCandidate(id)
103+
playLooping(region)
104+
}, [selectedCandidateId, wavesurfer, selectCandidate, playLooping])
105+
97106
const handleCandidateRegionClick = useCallback((region: Region) => {
98-
selectCandidate(region.id)
99-
}, [selectCandidate])
107+
handleCandidateClick(region.id)
108+
}, [handleCandidateClick])
100109

101110
const handleCandidateRegionDoubleClick = useCallback((region: Region) => {
102111
useLoopRef.current(region.id)
@@ -128,13 +137,6 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
128137
}, [setTrimIn, setTrimOut, wavesurfer, clearLoopCandidates, toast])
129138
useLoopRef.current = handleUseLoop
130139

131-
const handlePreviewCandidate = useCallback((id: string) => {
132-
const region = candidateRegionsRef.current.get(id)
133-
if (!region) return
134-
selectCandidate(id)
135-
playLooping(region)
136-
}, [selectCandidate, playLooping])
137-
138140
const handleClearAllRegions = useCallback(() => {
139141
clearAllRegions()
140142
candidateRegionsRef.current.clear()
@@ -146,7 +148,12 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
146148
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
147149
const [activeTool, setActiveTool] = useState<WaveformTool | null>(null)
148150
const [chopMethod, setChopMethod] = useState<ChopMethod>('hits')
149-
const [hitSensitivity, setHitSensitivity] = useState<HitSensitivity>('medium')
151+
// Quality-ranked onset peaks for the "Detect hits" slider; detected once per source, top-N taken.
152+
const [rankedPeaks, setRankedPeaks] = useState<Array<{ time: number; strength: number }> | null>(null)
153+
const [isDetectingHits, setIsDetectingHits] = useState(false)
154+
const [chopCount, setChopCount] = useState(DEFAULT_HIT_CHOPS)
155+
const isSlidingRef = useRef(false)
156+
const [historyCommitTick, setHistoryCommitTick] = useState(0)
150157
const [sliceCount, setSliceCount] = useState<SliceCount>('8')
151158
const [snapEnabled, setSnapEnabled] = useState(false)
152159
const [loopBarCount, setLoopBarCount] = useState<LoopBarCount>('4')
@@ -180,25 +187,35 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
180187
})
181188
}, [])
182189

190+
const pushHistory = useCallback((snapshot: ProjectRegion[]) => {
191+
const serialized = JSON.stringify(snapshot)
192+
if (serialized === lastHistorySnapshot.current) return
193+
lastHistorySnapshot.current = serialized
194+
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1)
195+
nextHistory.push(snapshot)
196+
historyRef.current = nextHistory.slice(-80)
197+
historyIndexRef.current = historyRef.current.length - 1
198+
syncHistoryState()
199+
}, [syncHistoryState])
200+
183201
useEffect(() => {
184202
if (!regions) return
185203
const snapshot = currentRegions()
186204
const serialized = JSON.stringify(snapshot)
187205
if (serialized === lastHistorySnapshot.current) return
206+
// While dragging the chop-count slider, skip per-tick snapshots without advancing the baseline,
207+
// so the whole drag collapses into one undo entry recorded on release (via historyCommitTick).
208+
if (isSlidingRef.current) return
188209

189-
lastHistorySnapshot.current = serialized
190210
if (isRestoringHistory.current) {
191211
isRestoringHistory.current = false
212+
lastHistorySnapshot.current = serialized
192213
syncHistoryState()
193214
return
194215
}
195216

196-
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1)
197-
nextHistory.push(snapshot)
198-
historyRef.current = nextHistory.slice(-80)
199-
historyIndexRef.current = historyRef.current.length - 1
200-
syncHistoryState()
201-
}, [currentRegions, regions, revision, syncHistoryState])
217+
pushHistory(snapshot)
218+
}, [currentRegions, regions, revision, historyCommitTick, syncHistoryState, pushHistory])
202219

203220
const restoreHistory = useCallback((index: number) => {
204221
const snapshot = historyRef.current[index]
@@ -347,55 +364,92 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
347364
}
348365
}, [audioUrl, bpm, beatPhase, loopBarCount, wavesurfer, addCandidateRegions, clearLoopCandidates, selectCandidate, toast])
349366

350-
const handleAutoChop = useCallback(async () => {
367+
const snapToGrid = useCallback((points: number[]) => {
368+
if (!snapEnabled || bpm === null || beatPhase === null) return points
369+
const sixteenth = (60 / bpm) / 4
370+
return points.map((t) => beatPhase + Math.round((t - beatPhase) / sixteenth) * sixteenth)
371+
}, [snapEnabled, bpm, beatPhase])
372+
373+
// "Equal slices" — divide the trim selection into N even pieces (button-triggered).
374+
const handleAutoChop = useCallback(() => {
351375
if (!wavesurfer) return
352376
clearLoopCandidates()
353377
setIsAutoChopping(true)
354378
try {
355-
const duration = wavesurfer.getDuration()
356-
if (chopMethod === 'slices') {
357-
const n = parseInt(sliceCount)
358-
const step = (trimOut - trimIn) / n
359-
let points = Array.from({ length: n - 1 }, (_, i) => trimIn + (i + 1) * step)
360-
if (snapEnabled && bpm !== null && beatPhase !== null) {
361-
const sixteenth = (60 / bpm) / 4
362-
points = points.map((t) => {
363-
const k = Math.round((t - beatPhase) / sixteenth)
364-
return beatPhase + k * sixteenth
365-
})
366-
}
367-
const minGap = snapEnabled && bpm !== null ? (60 / bpm) / 8 : 0
368-
autoChop(points, duration, { start: trimIn, end: trimOut }, minGap)
369-
toast(`${n} chops created`)
370-
} else {
371-
let transients = await detectTransientsFromUrl(audioUrl, hitSensitivity)
372-
if (transients.length === 0) {
373-
toast('No hits found — try the Many setting', 'info')
374-
return
375-
}
376-
if (snapEnabled && bpm !== null && beatPhase !== null) {
377-
const sixteenth = (60 / bpm) / 4
378-
transients = transients.map((t) => {
379-
const n = Math.round((t - beatPhase) / sixteenth)
380-
return beatPhase + n * sixteenth
381-
})
382-
}
383-
autoChop(
384-
transients,
385-
duration,
386-
{ start: trimIn, end: trimOut },
387-
MIN_AUTO_CHOP_REGION_SECONDS[hitSensitivity]
388-
)
389-
const inner = transients.filter((t) => t > trimIn && t < trimOut)
390-
const count = inner.length + 1
391-
toast(`${count} chop${count !== 1 ? 's' : ''} created`)
392-
}
379+
const n = parseInt(sliceCount)
380+
const step = (trimOut - trimIn) / n
381+
const points = snapToGrid(Array.from({ length: n - 1 }, (_, i) => trimIn + (i + 1) * step))
382+
const minGap = snapEnabled && bpm !== null ? (60 / bpm) / 8 : 0
383+
autoChop(points, wavesurfer.getDuration(), { start: trimIn, end: trimOut }, minGap)
384+
toast(`${n} chops created`)
393385
} catch {
394386
toast('Auto-chop failed', 'error')
395387
} finally {
396388
setIsAutoChopping(false)
397389
}
398-
}, [audioUrl, chopMethod, hitSensitivity, sliceCount, snapEnabled, bpm, beatPhase, wavesurfer, autoChop, clearLoopCandidates, trimIn, trimOut, toast])
390+
}, [wavesurfer, sliceCount, snapToGrid, snapEnabled, bpm, autoChop, clearLoopCandidates, trimIn, trimOut, toast])
391+
392+
// "Detect hits" — quality-ranked peaks within the current trim range, strongest first.
393+
const peaksInBounds = useMemo(
394+
() => (rankedPeaks ?? []).filter((p) => p.time > trimIn && p.time < trimOut),
395+
[rankedPeaks, trimIn, trimOut]
396+
)
397+
const maxChops = peaksInBounds.length + 1
398+
399+
// Detect peaks once when the hits tool is opened (per source; state resets on remount via key={path}).
400+
useEffect(() => {
401+
if (activeTool !== 'chop' || chopMethod !== 'hits') return
402+
if (rankedPeaks !== null || isDetectingHits) return
403+
let cancelled = false
404+
setIsDetectingHits(true)
405+
rankTransientsFromUrl(audioUrl)
406+
.then((peaks) => { if (!cancelled) setRankedPeaks(peaks) })
407+
.catch(() => { if (!cancelled) setRankedPeaks([]) })
408+
.finally(() => { if (!cancelled) setIsDetectingHits(false) })
409+
return () => { cancelled = true }
410+
}, [activeTool, chopMethod, audioUrl, rankedPeaks, isDetectingHits])
411+
412+
// Build N chops from the top (N-1) peaks by strength, ordered in time.
413+
const applyHitChop = useCallback((count: number) => {
414+
if (!wavesurfer) return
415+
clearLoopCandidates()
416+
const cuts = peaksInBounds.slice(0, Math.max(0, count - 1)).map((p) => p.time)
417+
const points = snapToGrid(cuts).sort((a, b) => a - b)
418+
autoChop(points, wavesurfer.getDuration(), { start: trimIn, end: trimOut }, 0.05)
419+
}, [wavesurfer, peaksInBounds, snapToGrid, autoChop, clearLoopCandidates, trimIn, trimOut])
420+
421+
const handleChopCountChange = useCallback((count: number) => {
422+
setChopCount(count)
423+
applyHitChop(count)
424+
}, [applyHitChop])
425+
426+
const handleChopSlideStart = useCallback(() => { isSlidingRef.current = true }, [])
427+
const handleChopSlideEnd = useCallback(() => {
428+
isSlidingRef.current = false
429+
setHistoryCommitTick((t) => t + 1) // force one undo entry for the whole drag
430+
}, [])
431+
432+
// Keep the slider value within the available peak count as the trim range changes (only once
433+
// peaks exist, so it doesn't snap to 1 while detection is still pending).
434+
useEffect(() => {
435+
if (rankedPeaks === null) return
436+
if (chopCount > maxChops) setChopCount(maxChops)
437+
else if (chopCount < MIN_HIT_CHOPS) setChopCount(MIN_HIT_CHOPS)
438+
}, [rankedPeaks, maxChops, chopCount])
439+
440+
// Once peaks are ready for an unchopped source, apply the default count so the slider and waveform
441+
// agree immediately. Runs once; never clobbers existing chops.
442+
const didInitialHitChop = useRef(false)
443+
useEffect(() => {
444+
if (didInitialHitChop.current) return
445+
if (activeTool !== 'chop' || chopMethod !== 'hits' || rankedPeaks === null) return
446+
if (maxChops <= MIN_HIT_CHOPS) return
447+
didInitialHitChop.current = true
448+
if (regions && regions.length > 0) return
449+
const count = Math.min(DEFAULT_HIT_CHOPS, maxChops)
450+
setChopCount(count)
451+
applyHitChop(count)
452+
}, [activeTool, chopMethod, rankedPeaks, maxChops, regions, applyHitChop])
399453

400454
const trimPreview = useMemo(() => {
401455
if (!regions?.length) return { kept: 0, dropped: 0 }
@@ -521,38 +575,52 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
521575

522576
{/* Transport */}
523577
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-surface shrink-0">
578+
{/* Transport — play is the distinct primary control (round, filled) */}
524579
<button
525580
onClick={() => wavesurfer?.playPause()}
581+
title={isPlaying ? 'Pause (Space)' : 'Play (Space)'}
526582
className="w-7 h-7 rounded-full flex items-center justify-center bg-raised border border-border hover:border-accent/40 hover:text-accent text-muted transition-colors cursor-pointer"
527583
>
528584
{isPlaying
529585
? <Pause size={11} fill="currentColor" />
530586
: <Play size={11} fill="currentColor" className="translate-x-px" />
531587
}
532588
</button>
533-
<div className="flex items-center gap-1">
534-
<Button
535-
variant="ghost"
536-
size="icon"
589+
590+
<div className="w-px h-5 bg-border" />
591+
592+
{/* History — shares the toolbar button language */}
593+
<div className="flex items-center gap-1.5">
594+
<button
537595
title="Undo region edit (⌘Z)"
538596
onClick={undoRegions}
539597
disabled={!historyState.canUndo}
598+
className={cn(
599+
'h-[28px] w-[28px] flex items-center justify-center rounded-[6px] border bg-transparent transition-colors',
600+
historyState.canUndo
601+
? 'text-muted border-border hover:text-ink hover:border-border-bright cursor-pointer'
602+
: 'text-faint/30 border-border/50 cursor-not-allowed'
603+
)}
540604
>
541-
<Undo2 size={12} />
542-
</Button>
543-
<Button
544-
variant="ghost"
545-
size="icon"
605+
<Undo2 size={13} />
606+
</button>
607+
<button
546608
title="Redo region edit (⇧⌘Z)"
547609
onClick={redoRegions}
548610
disabled={!historyState.canRedo}
611+
className={cn(
612+
'h-[28px] w-[28px] flex items-center justify-center rounded-[6px] border bg-transparent transition-colors',
613+
historyState.canRedo
614+
? 'text-muted border-border hover:text-ink hover:border-border-bright cursor-pointer'
615+
: 'text-faint/30 border-border/50 cursor-not-allowed'
616+
)}
549617
>
550-
<Redo2 size={12} />
551-
</Button>
618+
<Redo2 size={13} />
619+
</button>
552620
</div>
553-
<span className="text-[11px] text-faint/70 flex-1 select-none">
554-
{isPlaying ? 'Playing' : 'Paused'} — scroll to zoom, shift+scroll or swipe sideways to pan
555-
</span>
621+
<div className="flex-1" />
622+
623+
<div className="w-px h-5 bg-border" />
556624

557625
<ToolSelector activeTool={activeTool} onSelectTool={handleSelectTool} />
558626
</div>
@@ -570,8 +638,12 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
570638
chop={{
571639
method: chopMethod,
572640
setMethod: setChopMethod,
573-
sensitivity: hitSensitivity,
574-
setSensitivity: setHitSensitivity,
641+
chopCount,
642+
maxChops,
643+
setChopCount: handleChopCountChange,
644+
onChopSlideStart: handleChopSlideStart,
645+
onChopSlideEnd: handleChopSlideEnd,
646+
isDetecting: isDetectingHits,
575647
sliceCount,
576648
setSliceCount,
577649
snapEnabled,
@@ -595,8 +667,8 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
595667
<LoopCandidateList
596668
candidates={loopCandidates}
597669
selectedId={selectedCandidateId}
598-
onSelect={selectCandidate}
599-
onPreview={handlePreviewCandidate}
670+
playingId={isPlaying ? selectedCandidateId : null}
671+
onToggle={handleCandidateClick}
600672
onUse={handleUseLoop}
601673
onClear={clearLoopCandidates}
602674
/>
@@ -620,6 +692,9 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio
620692
<span className="text-[11px] text-faint/60 select-none">{label}</span>
621693
</span>
622694
))}
695+
<span className="ml-auto text-[11px] text-faint/50 select-none">
696+
Scroll to zoom · shift-scroll or swipe to pan
697+
</span>
623698
</div>
624699

625700
<Dialog open={showTrimDialog} onOpenChange={setShowTrimDialog}>

0 commit comments

Comments
 (0)