feat: add audio region trimming with non-destructive clip offset
This commit is contained in:
@@ -54,7 +54,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
audioBuffer
|
audioBuffer
|
||||||
}) => {
|
}) => {
|
||||||
// Get selection state and time signature from store
|
// Get selection state and time signature from store
|
||||||
const { selectedRegionIds, timeSignature } = useProjectStore();
|
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||||
const isSelected = selectedRegionIds.includes(id);
|
const isSelected = selectedRegionIds.includes(id);
|
||||||
const [cursor, setCursor] = useState<string>('pointer');
|
const [cursor, setCursor] = useState<string>('pointer');
|
||||||
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
|
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
|
||||||
@@ -229,10 +229,28 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
|
|
||||||
// Get channel data (use first channel)
|
// Get channel data (use first channel)
|
||||||
const channelData = audioBuffer.getChannelData(0);
|
const channelData = audioBuffer.getChannelData(0);
|
||||||
const samples = channelData.length;
|
const totalSamples = channelData.length;
|
||||||
|
const sampleRate = audioBuffer.sampleRate;
|
||||||
|
|
||||||
// Downsample to canvas width
|
// Calculate visible portion based on clip offset
|
||||||
const samplesPerPixel = Math.max(1, Math.floor(samples / width));
|
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;
|
const centerY = height / 2;
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
@@ -240,8 +258,8 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const startSample = Math.floor(x * samplesPerPixel);
|
const startSample = renderStartSample + Math.floor(x * samplesPerPixel);
|
||||||
const endSample = Math.min(startSample + samplesPerPixel, samples);
|
const endSample = Math.min(startSample + samplesPerPixel, renderEndSample);
|
||||||
|
|
||||||
let min = 0;
|
let min = 0;
|
||||||
let max = 0;
|
let max = 0;
|
||||||
@@ -289,7 +307,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
renderNotesOnCanvas();
|
renderNotesOnCanvas();
|
||||||
}
|
}
|
||||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature, id, noteUpdateTrigger]);
|
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger]);
|
||||||
|
|
||||||
// Re-render canvas when region content size changes
|
// Re-render canvas when region content size changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -310,7 +328,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
resizeObserver.unobserve(regionContentRef.current);
|
resizeObserver.unobserve(regionContentRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature]);
|
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
|
||||||
|
|
||||||
// Handle mouse movement to detect edge proximity
|
// Handle mouse movement to detect edge proximity
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -325,13 +343,6 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio regions: move only, no resize
|
|
||||||
if (audioRegion) {
|
|
||||||
setCursor('grab');
|
|
||||||
setResizeEdge('none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const regionElement = e.currentTarget;
|
const regionElement = e.currentTarget;
|
||||||
const rect = regionElement.getBoundingClientRect();
|
const rect = regionElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|||||||
import TrackGridItem from './TrackGridItem';
|
import TrackGridItem from './TrackGridItem';
|
||||||
import { Playhead } from '../common';
|
import { Playhead } from '../common';
|
||||||
import type { RegionUI } from '../interfaces';
|
import type { RegionUI } from '../interfaces';
|
||||||
import { DEBUG_MODE } from '../../constants';
|
import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
||||||
@@ -159,47 +159,91 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`);
|
console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the region
|
// Find the region
|
||||||
const region = regions.find(r => r.id === regionId);
|
const region = regions.find(r => r.id === regionId);
|
||||||
if (!region) return;
|
if (!region) return;
|
||||||
|
|
||||||
// Calculate new start and length in beats
|
// Calculate new start and length in beats
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const newStartBeat = (finalBarNumber - 1) * beatsPerBar;
|
let clampedBarNumber = finalBarNumber;
|
||||||
const newLengthInBeats = finalLength * beatsPerBar;
|
let clampedLength = finalLength;
|
||||||
|
|
||||||
// Find the track that contains this region
|
// Find the track that contains this region
|
||||||
const track = tracks.find(t => t.getId().toString() === region.trackId);
|
const track = tracks.find(t => t.getId().toString() === region.trackId);
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
// Update the region in the track's model
|
// Update the region in the track's model
|
||||||
const trackRegions = track.getRegions();
|
const trackRegions = track.getRegions();
|
||||||
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
const coreRegion = trackRegions.find(r => r.getId() === regionId);
|
||||||
|
|
||||||
if (midiRegion) {
|
if (coreRegion) {
|
||||||
const oldStartBeat = midiRegion.getStartFromBeat();
|
const oldStartBeat = coreRegion.getStartFromBeat();
|
||||||
const oldBarNumber = region.barNumber;
|
const oldBarNumber = region.barNumber;
|
||||||
|
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
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}`);
|
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)
|
// Use command pattern to update the region position and length (note adjustments handled inside command)
|
||||||
try {
|
try {
|
||||||
const command = ResizeRegionCommand.fromBarCoordinates(
|
const command = ResizeRegionCommand.fromBarCoordinates(
|
||||||
regionId,
|
regionId,
|
||||||
finalBarNumber,
|
clampedBarNumber,
|
||||||
finalLength,
|
clampedLength,
|
||||||
timeSignature
|
timeSignature,
|
||||||
|
newClipStartOffsetSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
|
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`);
|
console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`);
|
||||||
|
|
||||||
// Verify the command worked
|
// Verify the command worked
|
||||||
const updatedRegion = track.getRegions().find(r => r.getId() === regionId);
|
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()}`);
|
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);
|
console.error('Error resizing region:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Update the region in the parent component with expected model values
|
||||||
// Update the region in the parent component with expected model values
|
if (onRegionUpdated) {
|
||||||
if (onRegionUpdated) {
|
onRegionUpdated(
|
||||||
onRegionUpdated(
|
regionId,
|
||||||
regionId,
|
{ barNumber: clampedBarNumber, length: clampedLength },
|
||||||
{ barNumber: finalBarNumber, length: finalLength },
|
{ startBeat: newStartBeat, length: newLengthInBeats }
|
||||||
{ startBeat: newStartBeat, length: newLengthInBeats }
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class KGProject {
|
|||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 5;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
|
|||||||
@@ -506,6 +506,10 @@ export class KGAudioInterface {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clip offset: where playback starts within the audio file
|
||||||
|
const clipStartOffsetSeconds = audioRegion.getClipStartOffsetSeconds();
|
||||||
|
const audioDurationSeconds = audioRegion.getAudioDurationSeconds();
|
||||||
|
|
||||||
// Skip regions that start before playback start position
|
// Skip regions that start before playback start position
|
||||||
if (regionStartBeat < startPosition) {
|
if (regionStartBeat < startPosition) {
|
||||||
// Region starts before playhead — calculate offset into the audio file
|
// Region starts before playhead — calculate offset into the audio file
|
||||||
@@ -523,6 +527,12 @@ export class KGAudioInterface {
|
|||||||
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
|
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap at available audio after clip offset
|
||||||
|
effectiveRemainingSeconds = Math.min(
|
||||||
|
effectiveRemainingSeconds,
|
||||||
|
audioDurationSeconds - clipStartOffsetSeconds - offsetSeconds
|
||||||
|
);
|
||||||
|
|
||||||
if (effectiveRemainingSeconds > 0 && playerBus.hasBuffer(audioFileId)) {
|
if (effectiveRemainingSeconds > 0 && playerBus.hasBuffer(audioFileId)) {
|
||||||
// Resume slightly after the current transport boundary and
|
// Resume slightly after the current transport boundary and
|
||||||
// compensate the source offset/duration. Scheduling exactly
|
// compensate the source offset/duration. Scheduling exactly
|
||||||
@@ -533,7 +543,7 @@ export class KGAudioInterface {
|
|||||||
regionEndBeat
|
regionEndBeat
|
||||||
);
|
);
|
||||||
const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
|
const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
|
||||||
const adjustedOffsetSeconds = offsetSeconds + extraOffsetSeconds;
|
const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
|
||||||
const adjustedRemainingSeconds = Math.max(
|
const adjustedRemainingSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
effectiveRemainingSeconds - extraOffsetSeconds
|
effectiveRemainingSeconds - extraOffsetSeconds
|
||||||
@@ -562,7 +572,12 @@ export class KGAudioInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const audioFileId = audioRegion.getAudioFileId();
|
const audioFileId = audioRegion.getAudioFileId();
|
||||||
let effectiveDurationSeconds = audioRegion.getAudioDurationSeconds();
|
// Effective duration: region length in seconds, capped at available audio after clip offset
|
||||||
|
const regionLengthSeconds = region.getLength() * secondsPerBeat;
|
||||||
|
let effectiveDurationSeconds = Math.min(
|
||||||
|
regionLengthSeconds,
|
||||||
|
audioDurationSeconds - clipStartOffsetSeconds
|
||||||
|
);
|
||||||
|
|
||||||
if (!playerBus.hasBuffer(audioFileId)) {
|
if (!playerBus.hasBuffer(audioFileId)) {
|
||||||
console.warn(`No audio buffer loaded for ${audioFileId}`);
|
console.warn(`No audio buffer loaded for ${audioFileId}`);
|
||||||
@@ -579,13 +594,13 @@ export class KGAudioInterface {
|
|||||||
const regionStartTime = this.beatsToToneTime(regionStartBeat);
|
const regionStartTime = this.beatsToToneTime(regionStartBeat);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, duration: ${effectiveDurationSeconds}s`
|
`Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, clipOffset: ${clipStartOffsetSeconds}s, duration: ${effectiveDurationSeconds}s`
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventId = Tone.Transport.schedule((time) => {
|
const eventId = Tone.Transport.schedule((time) => {
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
playerBus.schedulePlayback(time + playbackDelay, audioFileId, 0, effectiveDurationSeconds);
|
playerBus.schedulePlayback(time + playbackDelay, audioFileId, clipStartOffsetSeconds, effectiveDurationSeconds);
|
||||||
}
|
}
|
||||||
}, regionStartTime);
|
}, regionStartTime);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGRegion } from '../../region/KGRegion';
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +16,7 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
private originalStartFromBeat: number = 0;
|
private originalStartFromBeat: number = 0;
|
||||||
private originalLength: number = 0;
|
private originalLength: number = 0;
|
||||||
private targetRegion: KGRegion | null = null;
|
private targetRegion: KGRegion | null = null;
|
||||||
|
|
||||||
// Store note adjustments for undo
|
// Store note adjustments for undo
|
||||||
private noteAdjustments: Array<{
|
private noteAdjustments: Array<{
|
||||||
noteId: string;
|
noteId: string;
|
||||||
@@ -23,11 +24,16 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
originalEndBeat: number;
|
originalEndBeat: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
constructor(regionId: string, newStartFromBeat: number, newLength: number) {
|
// Audio region clip offset support
|
||||||
|
private newClipStartOffsetSeconds?: number;
|
||||||
|
private originalClipStartOffsetSeconds: number = 0;
|
||||||
|
|
||||||
|
constructor(regionId: string, newStartFromBeat: number, newLength: number, newClipStartOffsetSeconds?: number) {
|
||||||
super();
|
super();
|
||||||
this.regionId = regionId;
|
this.regionId = regionId;
|
||||||
this.newStartFromBeat = newStartFromBeat;
|
this.newStartFromBeat = newStartFromBeat;
|
||||||
this.newLength = newLength;
|
this.newLength = newLength;
|
||||||
|
this.newClipStartOffsetSeconds = newClipStartOffsetSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): void {
|
||||||
@@ -57,10 +63,10 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
||||||
this.originalLength = targetRegion.getLength();
|
this.originalLength = targetRegion.getLength();
|
||||||
|
|
||||||
// Handle note adjustments if start position changes (left-edge resize)
|
// Handle note adjustments if start position changes (left-edge resize) for MIDI regions
|
||||||
if (this.newStartFromBeat !== this.originalStartFromBeat && targetRegion instanceof KGMidiRegion) {
|
if (this.newStartFromBeat !== this.originalStartFromBeat && targetRegion instanceof KGMidiRegion) {
|
||||||
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
|
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
|
||||||
|
|
||||||
// Store original note positions and adjust notes to maintain absolute positions
|
// Store original note positions and adjust notes to maintain absolute positions
|
||||||
const notes = targetRegion.getNotes();
|
const notes = targetRegion.getNotes();
|
||||||
notes.forEach(note => {
|
notes.forEach(note => {
|
||||||
@@ -70,15 +76,24 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
originalStartBeat: note.getStartBeat(),
|
originalStartBeat: note.getStartBeat(),
|
||||||
originalEndBeat: note.getEndBeat()
|
originalEndBeat: note.getEndBeat()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Adjust note positions to maintain absolute position
|
// Adjust note positions to maintain absolute position
|
||||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle clip offset for audio regions
|
||||||
|
if (targetRegion instanceof KGAudioRegion) {
|
||||||
|
this.originalClipStartOffsetSeconds = targetRegion.getClipStartOffsetSeconds();
|
||||||
|
if (this.newClipStartOffsetSeconds !== undefined) {
|
||||||
|
targetRegion.setClipStartOffsetSeconds(this.newClipStartOffsetSeconds);
|
||||||
|
console.log(`Updated audio clip offset: ${this.originalClipStartOffsetSeconds} → ${this.newClipStartOffsetSeconds}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply the resize
|
// Apply the resize
|
||||||
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
||||||
targetRegion.setLength(this.newLength);
|
targetRegion.setLength(this.newLength);
|
||||||
@@ -95,7 +110,7 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
// Restore note positions if they were adjusted
|
// Restore note positions if they were adjusted
|
||||||
if (this.noteAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
if (this.noteAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
||||||
const notes = this.targetRegion.getNotes();
|
const notes = this.targetRegion.getNotes();
|
||||||
|
|
||||||
// Restore each note to its original position
|
// Restore each note to its original position
|
||||||
this.noteAdjustments.forEach(adjustment => {
|
this.noteAdjustments.forEach(adjustment => {
|
||||||
const note = notes.find(n => n.getId() === adjustment.noteId);
|
const note = notes.find(n => n.getId() === adjustment.noteId);
|
||||||
@@ -104,10 +119,16 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
note.setEndBeat(adjustment.originalEndBeat);
|
note.setEndBeat(adjustment.originalEndBeat);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
|
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore clip offset for audio regions
|
||||||
|
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
|
||||||
|
this.targetRegion.setClipStartOffsetSeconds(this.originalClipStartOffsetSeconds);
|
||||||
|
console.log(`Restored audio clip offset: ${this.newClipStartOffsetSeconds} → ${this.originalClipStartOffsetSeconds}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Restore original region values
|
// Restore original region values
|
||||||
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
||||||
this.targetRegion.setLength(this.originalLength);
|
this.targetRegion.setLength(this.originalLength);
|
||||||
@@ -184,12 +205,13 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
regionId: string,
|
regionId: string,
|
||||||
newBarNumber: number,
|
newBarNumber: number,
|
||||||
newLengthInBars: number,
|
newLengthInBars: number,
|
||||||
timeSignature: { numerator: number; denominator: number }
|
timeSignature: { numerator: number; denominator: number },
|
||||||
|
newClipStartOffsetSeconds?: number
|
||||||
): ResizeRegionCommand {
|
): ResizeRegionCommand {
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
|
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
|
||||||
const newLength = newLengthInBars * beatsPerBar;
|
const newLength = newLengthInBars * beatsPerBar;
|
||||||
|
|
||||||
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength);
|
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength, newClipStartOffsetSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { upgradeToV2 } from './upgradeToV2';
|
|||||||
import { upgradeToV3 } from './upgradeToV3';
|
import { upgradeToV3 } from './upgradeToV3';
|
||||||
import { upgradeToV4 } from './upgradeToV4';
|
import { upgradeToV4 } from './upgradeToV4';
|
||||||
import { upgradeToV5 } from './upgradeToV5';
|
import { upgradeToV5 } from './upgradeToV5';
|
||||||
|
import { upgradeToV6 } from './upgradeToV6';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* Upgrade the given project to the latest structure version, one version at a time.
|
||||||
@@ -43,6 +44,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV5(workingProject);
|
workingProject = upgradeToV5(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 6: {
|
||||||
|
workingProject = upgradeToV6(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
|
|
||||||
|
export function upgradeToV6(project: KGProject): KGProject {
|
||||||
|
try {
|
||||||
|
// Ensure all audio regions have clipStartOffsetSeconds initialized
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (region instanceof KGAudioRegion) {
|
||||||
|
const current = region.getClipStartOffsetSeconds?.();
|
||||||
|
if (current === undefined || current === null) {
|
||||||
|
region.setClipStartOffsetSeconds(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
project.setProjectStructureVersion(6);
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ export class KGAudioRegion extends KGRegion {
|
|||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
protected audioDurationSeconds: number = 0;
|
protected audioDurationSeconds: number = 0;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(0)
|
||||||
|
protected clipStartOffsetSeconds: number = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: string,
|
id: string,
|
||||||
trackId: string,
|
trackId: string,
|
||||||
@@ -31,13 +35,15 @@ export class KGAudioRegion extends KGRegion {
|
|||||||
length: number = 0,
|
length: number = 0,
|
||||||
audioFileId: string = '',
|
audioFileId: string = '',
|
||||||
audioFileName: string = '',
|
audioFileName: string = '',
|
||||||
audioDurationSeconds: number = 0
|
audioDurationSeconds: number = 0,
|
||||||
|
clipStartOffsetSeconds: number = 0
|
||||||
) {
|
) {
|
||||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||||
this.__type = 'KGAudioRegion';
|
this.__type = 'KGAudioRegion';
|
||||||
this.audioFileId = audioFileId;
|
this.audioFileId = audioFileId;
|
||||||
this.audioFileName = audioFileName;
|
this.audioFileName = audioFileName;
|
||||||
this.audioDurationSeconds = audioDurationSeconds;
|
this.audioDurationSeconds = audioDurationSeconds;
|
||||||
|
this.clipStartOffsetSeconds = clipStartOffsetSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -66,6 +72,14 @@ export class KGAudioRegion extends KGRegion {
|
|||||||
this.audioDurationSeconds = audioDurationSeconds;
|
this.audioDurationSeconds = audioDurationSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getClipStartOffsetSeconds(): number {
|
||||||
|
return this.clipStartOffsetSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setClipStartOffsetSeconds(clipStartOffsetSeconds: number): void {
|
||||||
|
this.clipStartOffsetSeconds = clipStartOffsetSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
// Override getCurrentType to return specific subclass type
|
// Override getCurrentType to return specific subclass type
|
||||||
public override getCurrentType(): string {
|
public override getCurrentType(): string {
|
||||||
return 'KGAudioRegion';
|
return 'KGAudioRegion';
|
||||||
|
|||||||
Reference in New Issue
Block a user