@@ -21,13 +21,14 @@ import TrimOverlay from './TrimOverlay'
2121import { ToolSelector , ToolContextBar } from './WaveformTools'
2222import {
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'
3132import { remapRegionsForTrim } from '@/lib/remapRegions'
3233import { cn } from '@/lib/utils'
3334import { 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-
5247const 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