diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index 1526026..8091236 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -54,7 +54,7 @@ const RegionItem: React.FC = ({ 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('pointer'); const [resizeEdge, setResizeEdge] = useState('none'); @@ -229,10 +229,28 @@ const RegionItem: React.FC = ({ // 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 = ({ 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 = ({ } 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 = ({ 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) => { @@ -325,13 +343,6 @@ const RegionItem: React.FC = ({ return; } - // Audio regions: move only, no resize - if (audioRegion) { - setCursor('grab'); - setResizeEdge('none'); - return; - } - const regionElement = e.currentTarget; const rect = regionElement.getBoundingClientRect(); diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index 7045516..97cc8c7 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -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 = ({ 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 = ({ 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 } + ); + } } }; diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 8c421a2..83f8151 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -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, { diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index dfda13a..17ccbbb 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -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); diff --git a/src/core/commands/region/ResizeRegionCommand.ts b/src/core/commands/region/ResizeRegionCommand.ts index ae9bf83..ccd02d4 100644 --- a/src/core/commands/region/ResizeRegionCommand.ts +++ b/src/core/commands/region/ResizeRegionCommand.ts @@ -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); } } \ No newline at end of file diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index e7cd9dc..1ab940d 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -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}`); diff --git a/src/core/project-upgrader/upgradeToV6.ts b/src/core/project-upgrader/upgradeToV6.ts new file mode 100644 index 0000000..0676fe6 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV6.ts @@ -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; +} diff --git a/src/core/region/KGAudioRegion.ts b/src/core/region/KGAudioRegion.ts index d5b49a9..853ab5a 100644 --- a/src/core/region/KGAudioRegion.ts +++ b/src/core/region/KGAudioRegion.ts @@ -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';