feat: add audio region trimming with non-destructive clip offset
This commit is contained in:
@@ -52,7 +52,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 5;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
|
||||
@@ -506,6 +506,10 @@ export class KGAudioInterface {
|
||||
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
|
||||
if (regionStartBeat < startPosition) {
|
||||
// Region starts before playhead — calculate offset into the audio file
|
||||
@@ -523,6 +527,12 @@ export class KGAudioInterface {
|
||||
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)) {
|
||||
// Resume slightly after the current transport boundary and
|
||||
// compensate the source offset/duration. Scheduling exactly
|
||||
@@ -533,7 +543,7 @@ export class KGAudioInterface {
|
||||
regionEndBeat
|
||||
);
|
||||
const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
|
||||
const adjustedOffsetSeconds = offsetSeconds + extraOffsetSeconds;
|
||||
const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
|
||||
const adjustedRemainingSeconds = Math.max(
|
||||
0,
|
||||
effectiveRemainingSeconds - extraOffsetSeconds
|
||||
@@ -562,7 +572,12 @@ export class KGAudioInterface {
|
||||
}
|
||||
|
||||
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)) {
|
||||
console.warn(`No audio buffer loaded for ${audioFileId}`);
|
||||
@@ -579,13 +594,13 @@ export class KGAudioInterface {
|
||||
const regionStartTime = this.beatsToToneTime(regionStartBeat);
|
||||
|
||||
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 hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
playerBus.schedulePlayback(time + playbackDelay, audioFileId, 0, effectiveDurationSeconds);
|
||||
playerBus.schedulePlayback(time + playbackDelay, audioFileId, clipStartOffsetSeconds, effectiveDurationSeconds);
|
||||
}
|
||||
}, regionStartTime);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
|
||||
/**
|
||||
@@ -15,7 +16,7 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
private originalStartFromBeat: number = 0;
|
||||
private originalLength: number = 0;
|
||||
private targetRegion: KGRegion | null = null;
|
||||
|
||||
|
||||
// Store note adjustments for undo
|
||||
private noteAdjustments: Array<{
|
||||
noteId: string;
|
||||
@@ -23,11 +24,16 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
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();
|
||||
this.regionId = regionId;
|
||||
this.newStartFromBeat = newStartFromBeat;
|
||||
this.newLength = newLength;
|
||||
this.newClipStartOffsetSeconds = newClipStartOffsetSeconds;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -57,10 +63,10 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
||||
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) {
|
||||
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
|
||||
|
||||
|
||||
// Store original note positions and adjust notes to maintain absolute positions
|
||||
const notes = targetRegion.getNotes();
|
||||
notes.forEach(note => {
|
||||
@@ -70,15 +76,24 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
originalStartBeat: note.getStartBeat(),
|
||||
originalEndBeat: note.getEndBeat()
|
||||
});
|
||||
|
||||
|
||||
// Adjust note positions to maintain absolute position
|
||||
note.setStartBeat(note.getStartBeat() - beatOffset);
|
||||
note.setEndBeat(note.getEndBeat() - beatOffset);
|
||||
});
|
||||
|
||||
|
||||
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
|
||||
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
||||
targetRegion.setLength(this.newLength);
|
||||
@@ -95,7 +110,7 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
// Restore note positions if they were adjusted
|
||||
if (this.noteAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
|
||||
const notes = this.targetRegion.getNotes();
|
||||
|
||||
|
||||
// Restore each note to its original position
|
||||
this.noteAdjustments.forEach(adjustment => {
|
||||
const note = notes.find(n => n.getId() === adjustment.noteId);
|
||||
@@ -104,10 +119,16 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
note.setEndBeat(adjustment.originalEndBeat);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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
|
||||
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
||||
this.targetRegion.setLength(this.originalLength);
|
||||
@@ -184,12 +205,13 @@ export class ResizeRegionCommand extends KGCommand {
|
||||
regionId: string,
|
||||
newBarNumber: number,
|
||||
newLengthInBars: number,
|
||||
timeSignature: { numerator: number; denominator: number }
|
||||
timeSignature: { numerator: number; denominator: number },
|
||||
newClipStartOffsetSeconds?: number
|
||||
): ResizeRegionCommand {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const newStartFromBeat = (newBarNumber - 1) * 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 { upgradeToV4 } from './upgradeToV4';
|
||||
import { upgradeToV5 } from './upgradeToV5';
|
||||
import { upgradeToV6 } from './upgradeToV6';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
break;
|
||||
}
|
||||
case 6: {
|
||||
workingProject = upgradeToV6(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
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)
|
||||
protected audioDurationSeconds: number = 0;
|
||||
|
||||
@Expose()
|
||||
@WithDefault(0)
|
||||
protected clipStartOffsetSeconds: number = 0;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
trackId: string,
|
||||
@@ -31,13 +35,15 @@ export class KGAudioRegion extends KGRegion {
|
||||
length: number = 0,
|
||||
audioFileId: string = '',
|
||||
audioFileName: string = '',
|
||||
audioDurationSeconds: number = 0
|
||||
audioDurationSeconds: number = 0,
|
||||
clipStartOffsetSeconds: number = 0
|
||||
) {
|
||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||
this.__type = 'KGAudioRegion';
|
||||
this.audioFileId = audioFileId;
|
||||
this.audioFileName = audioFileName;
|
||||
this.audioDurationSeconds = audioDurationSeconds;
|
||||
this.clipStartOffsetSeconds = clipStartOffsetSeconds;
|
||||
}
|
||||
|
||||
// Getters
|
||||
@@ -66,6 +72,14 @@ export class KGAudioRegion extends KGRegion {
|
||||
this.audioDurationSeconds = audioDurationSeconds;
|
||||
}
|
||||
|
||||
public getClipStartOffsetSeconds(): number {
|
||||
return this.clipStartOffsetSeconds;
|
||||
}
|
||||
|
||||
public setClipStartOffsetSeconds(clipStartOffsetSeconds: number): void {
|
||||
this.clipStartOffsetSeconds = clipStartOffsetSeconds;
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGAudioRegion';
|
||||
|
||||
Reference in New Issue
Block a user