feat: add audio region trimming with non-destructive clip offset

This commit is contained in:
Xiaohan-Tian
2026-04-10 20:17:31 -07:00
parent 39401b39c6
commit 1670679c61
8 changed files with 192 additions and 60 deletions
+26 -15
View File
@@ -54,7 +54,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
audioBuffer
}) => {
// Get selection state and time signature from store
const { selectedRegionIds, timeSignature } = useProjectStore();
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
const isSelected = selectedRegionIds.includes(id);
const [cursor, setCursor] = useState<string>('pointer');
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
@@ -229,10 +229,28 @@ const RegionItem: React.FC<RegionItemProps> = ({
// Get channel data (use first channel)
const channelData = audioBuffer.getChannelData(0);
const samples = channelData.length;
const totalSamples = channelData.length;
const sampleRate = audioBuffer.sampleRate;
// Downsample to canvas width
const samplesPerPixel = Math.max(1, Math.floor(samples / width));
// Calculate visible portion based on clip offset
const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
// Calculate visible duration from region length in beats
const secondsPerBeat = 60 / bpm;
const regionLengthBeats = audioRegion ? audioRegion.getLength() : 0;
const visibleDurationSeconds = regionLengthBeats * secondsPerBeat;
const visibleSamples = Math.floor(visibleDurationSeconds * sampleRate);
// Clamp to buffer boundaries
const renderStartSample = Math.max(0, Math.min(clipStartSample, totalSamples));
const renderEndSample = Math.min(renderStartSample + visibleSamples, totalSamples);
const renderSampleCount = renderEndSample - renderStartSample;
if (renderSampleCount <= 0) return;
// Downsample visible portion to canvas width
const samplesPerPixel = Math.max(1, Math.floor(renderSampleCount / width));
const centerY = height / 2;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
@@ -240,8 +258,8 @@ const RegionItem: React.FC<RegionItemProps> = ({
ctx.beginPath();
for (let x = 0; x < width; x++) {
const startSample = Math.floor(x * samplesPerPixel);
const endSample = Math.min(startSample + samplesPerPixel, samples);
const startSample = renderStartSample + Math.floor(x * samplesPerPixel);
const endSample = Math.min(startSample + samplesPerPixel, renderEndSample);
let min = 0;
let max = 0;
@@ -289,7 +307,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
} else {
renderNotesOnCanvas();
}
}, [midiRegion, audioRegion, audioBuffer, timeSignature, id, noteUpdateTrigger]);
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger]);
// Re-render canvas when region content size changes
useEffect(() => {
@@ -310,7 +328,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
resizeObserver.unobserve(regionContentRef.current);
}
};
}, [midiRegion, audioRegion, audioBuffer, timeSignature]);
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -325,13 +343,6 @@ const RegionItem: React.FC<RegionItemProps> = ({
return;
}
// Audio regions: move only, no resize
if (audioRegion) {
setCursor('grab');
setResizeEdge('none');
return;
}
const regionElement = e.currentTarget;
const rect = regionElement.getBoundingClientRect();
+72 -28
View File
@@ -4,7 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import TrackGridItem from './TrackGridItem';
import { Playhead } from '../common';
import type { RegionUI } from '../interfaces';
import { DEBUG_MODE } from '../../constants';
import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
@@ -159,47 +159,91 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`);
}
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
// Calculate new start and length in beats
const beatsPerBar = timeSignature.numerator;
const newStartBeat = (finalBarNumber - 1) * beatsPerBar;
const newLengthInBeats = finalLength * beatsPerBar;
let clampedBarNumber = finalBarNumber;
let clampedLength = finalLength;
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
// Update the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
if (midiRegion) {
const oldStartBeat = midiRegion.getStartFromBeat();
const coreRegion = trackRegions.find(r => r.getId() === regionId);
if (coreRegion) {
const oldStartBeat = coreRegion.getStartFromBeat();
const oldBarNumber = region.barNumber;
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${midiRegion.getLength()}`);
console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${coreRegion.getLength()}`);
console.log(`Bar numbers - old: ${oldBarNumber}, new: ${finalBarNumber}`);
}
// Clamp audio region resize to audio file boundaries
let newClipStartOffsetSeconds: number | undefined;
if (coreRegion instanceof KGAudioRegion) {
const bpm = KGCore.instance().getCurrentProject().getBpm();
const secondsPerBeat = 60 / bpm;
const clipOffset = coreRegion.getClipStartOffsetSeconds();
const audioDuration = coreRegion.getAudioDurationSeconds();
// Left edge changed — calculate new clip offset
if (clampedBarNumber !== oldBarNumber) {
const newStartBeat = (clampedBarNumber - 1) * beatsPerBar;
const beatDelta = newStartBeat - oldStartBeat;
const secondsDelta = beatDelta * secondsPerBeat;
const unclampedClipOffset = clipOffset + secondsDelta;
if (unclampedClipOffset < 0) {
// Dragged past audio start — snap to earliest allowed position
const maxLeftExtensionBeats = clipOffset / secondsPerBeat;
const minStartBeat = oldStartBeat - maxLeftExtensionBeats;
clampedBarNumber = Math.ceil(minStartBeat / beatsPerBar) + 1;
const oldEndBarNumber = oldBarNumber + (coreRegion.getLength() / beatsPerBar);
clampedLength = oldEndBarNumber - clampedBarNumber;
newClipStartOffsetSeconds = 0;
} else {
newClipStartOffsetSeconds = Math.min(unclampedClipOffset, audioDuration);
}
}
// Right edge — clamp length so it doesn't exceed remaining audio
const effectiveClipOffset = newClipStartOffsetSeconds ?? clipOffset;
const maxDurationSeconds = audioDuration - effectiveClipOffset;
const maxLengthBars = (maxDurationSeconds / secondsPerBeat) / beatsPerBar;
if (clampedLength > maxLengthBars) {
clampedLength = Math.floor(maxLengthBars);
if (clampedLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
clampedLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
}
}
}
const newStartBeat = (clampedBarNumber - 1) * beatsPerBar;
const newLengthInBeats = clampedLength * beatsPerBar;
// Use command pattern to update the region position and length (note adjustments handled inside command)
try {
const command = ResizeRegionCommand.fromBarCoordinates(
regionId,
finalBarNumber,
finalLength,
timeSignature
clampedBarNumber,
clampedLength,
timeSignature,
newClipStartOffsetSeconds
);
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`);
// Verify the command worked
const updatedRegion = track.getRegions().find(r => r.getId() === regionId);
console.log(`Verified region in track: ${updatedRegion ? 'found' : 'not found'}, startBeat=${updatedRegion?.getStartFromBeat()}, length=${updatedRegion?.getLength()}`);
@@ -208,15 +252,15 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
console.error('Error resizing region:', error);
return;
}
}
// Update the region in the parent component with expected model values
if (onRegionUpdated) {
onRegionUpdated(
regionId,
{ barNumber: finalBarNumber, length: finalLength },
{ startBeat: newStartBeat, length: newLengthInBeats }
);
// Update the region in the parent component with expected model values
if (onRegionUpdated) {
onRegionUpdated(
regionId,
{ barNumber: clampedBarNumber, length: clampedLength },
{ startBeat: newStartBeat, length: newLengthInBeats }
);
}
}
};