fix: missing count-in when recording with loop mode on

This commit is contained in:
Xiaohan-Tian
2026-05-01 14:17:29 -07:00
parent adb2345693
commit 043e90b1d7
5 changed files with 85 additions and 13 deletions
+13 -5
View File
@@ -10,6 +10,10 @@ import { KGRegion } from './region/KGRegion';
import { generateUniqueId } from '../util/miscUtil'; import { generateUniqueId } from '../util/miscUtil';
import { KGCommand, KGCommandHistory } from './commands'; import { KGCommand, KGCommandHistory } from './commands';
interface PlaybackStartOptions {
preserveLoopPreroll?: boolean;
}
/** /**
* KGCore - Main application class for the DAW * KGCore - Main application class for the DAW
* Implements the singleton pattern for global access * Implements the singleton pattern for global access
@@ -231,7 +235,7 @@ export class KGCore {
return this.isPlaying; return this.isPlaying;
} }
public async preparePlay(): Promise<void> { public async preparePlay(options?: PlaybackStartOptions): Promise<void> {
try { try {
const audioInterface = KGAudioInterface.instance(); const audioInterface = KGAudioInterface.instance();
@@ -239,7 +243,9 @@ export class KGCore {
await audioInterface.startAudioContext(); await audioInterface.startAudioContext();
// Prepare playback with current project and playhead position // Prepare playback with current project and playhead position
audioInterface.preparePlayback(this.currentProject, this.playheadPosition); audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
});
// Sync BPM and transport settings // Sync BPM and transport settings
audioInterface.setBpm(this.currentProject.getBpm()); audioInterface.setBpm(this.currentProject.getBpm());
@@ -301,7 +307,7 @@ export class KGCore {
} }
// High-level playback control methods // High-level playback control methods
public async startPlaying(): Promise<void> { public async startPlaying(options?: PlaybackStartOptions): Promise<void> {
// Handle loop mode initialization // Handle loop mode initialization
if (this.currentProject.getIsLooping()) { if (this.currentProject.getIsLooping()) {
const [startBar, endBar] = this.currentProject.getLoopingRange(); const [startBar, endBar] = this.currentProject.getLoopingRange();
@@ -319,11 +325,13 @@ export class KGCore {
const updatedRange = this.currentProject.getLoopingRange(); const updatedRange = this.currentProject.getLoopingRange();
const beatsPerBar = this.currentProject.getTimeSignature().numerator; const beatsPerBar = this.currentProject.getTimeSignature().numerator;
const loopStartBeats = updatedRange[0] * beatsPerBar; const loopStartBeats = updatedRange[0] * beatsPerBar;
this.setPlayheadPosition(loopStartBeats); if (!options?.preserveLoopPreroll) {
this.setPlayheadPosition(loopStartBeats);
}
} }
// Prepare playback first // Prepare playback first
await this.preparePlay(); await this.preparePlay(options);
// Start playing (non-blocking) // Start playing (non-blocking)
this.play(); // Don't await this this.play(); // Don't await this
@@ -97,4 +97,19 @@ describe('KGAudioInterface preroll playback', () => {
expect(MockTransport.stop).toHaveBeenCalledTimes(1) expect(MockTransport.stop).toHaveBeenCalledTimes(1)
expect(audio.getTransportPosition()).toBe(0) expect(audio.getTransportPosition()).toBe(0)
}) })
it('allows a first-pass start before the loop start when explicitly requested', () => {
const project = createMockProject({
bpm: 120,
timeSignature: { numerator: 4, denominator: 4 },
tracks: [],
})
project.setIsLooping(true)
project.setLoopingRange([4, 7])
const audio = KGAudioInterface.instance()
audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true })
expect(MockTransport.position).toBe(6)
})
}) })
+6 -2
View File
@@ -12,6 +12,10 @@ import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager'; import { ConfigManager } from '../config/ConfigManager';
import { KGMetronome } from './KGMetronome'; import { KGMetronome } from './KGMetronome';
interface PreparePlaybackOptions {
allowStartBeforeLoopStart?: boolean;
}
/** /**
* KGAudioInterface - Audio engine interface for the DAW * KGAudioInterface - Audio engine interface for the DAW
* Implements the singleton pattern for global audio management * Implements the singleton pattern for global audio management
@@ -391,7 +395,7 @@ export class KGAudioInterface {
/** /**
* Prepare playback by scheduling all MIDI events * Prepare playback by scheduling all MIDI events
*/ */
public preparePlayback(project: KGProject, startPosition: number): void { public preparePlayback(project: KGProject, startPosition: number, options?: PreparePlaybackOptions): void {
// Clear any existing scheduled events // Clear any existing scheduled events
this.clearScheduledEvents(); this.clearScheduledEvents();
this.clearDelayedTransportStart(); this.clearDelayedTransportStart();
@@ -437,7 +441,7 @@ export class KGAudioInterface {
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`); console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
// Adjust start position to loop start if before loop range // Adjust start position to loop start if before loop range
if (startPosition < scheduleStartBeat) { if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
startPosition = scheduleStartBeat; startPosition = scheduleStartBeat;
} }
} else { } else {
+13 -4
View File
@@ -797,7 +797,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}, },
startRecording: async () => { startRecording: async () => {
const { activeRegionId, timeSignature, playheadPosition, startPlaying, setPlayheadPosition } = get(); const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
const project = KGCore.instance().getCurrentProject(); const project = KGCore.instance().getCurrentProject();
let targetRegion: KGMidiRegion | null = null; let targetRegion: KGMidiRegion | null = null;
@@ -842,8 +842,18 @@ export const useProjectStore = create<ProjectState>((set, get) => {
} }
); );
setPlayheadPosition(playheadPosition - timeSignature.numerator); const projectLooping = project.getIsLooping();
await startPlaying(); const [loopStartBar] = project.getLoopingRange();
const loopStartBeat = loopStartBar * timeSignature.numerator;
const recordingStartBeat = projectLooping
? loopStartBeat - timeSignature.numerator
: playheadPosition - timeSignature.numerator;
setPlayheadPosition(recordingStartBeat);
await KGCore.instance().startPlaying({
preserveLoopPreroll: projectLooping,
});
set({ isPlaying: true, autoScrollEnabled: true });
}, },
stopRecording: async () => { stopRecording: async () => {
@@ -1196,4 +1206,3 @@ export const useProjectStore = create<ProjectState>((set, get) => {
@@ -11,6 +11,7 @@ import { KGProject } from '../../../core/KGProject'
import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack' import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack'
import { KGMidiRegion } from '../../../core/region/KGMidiRegion' import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
import { KGMidiNote } from '../../../core/midi/KGMidiNote' import { KGMidiNote } from '../../../core/midi/KGMidiNote'
import { KGMidiInput } from '../../../core/midi-input/KGMidiInput'
// Import store // Import store
import { useProjectStore } from '../../../stores/projectStore' import { useProjectStore } from '../../../stores/projectStore'
@@ -279,6 +280,41 @@ describe('Project Store Synchronization Integration Tests', () => {
// Verify audio interface was called // Verify audio interface was called
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled() expect(mockAudioInterface.stopPlayback).toHaveBeenCalled()
}) })
it('should start looped recording one bar before the loop start on the first pass', async () => {
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano')
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16)
testTrack.addRegion(testRegion)
testProject.setTracks([testTrack])
testProject.setIsLooping(true)
testProject.setLoopingRange([4, 7])
await act(async () => {
await useProjectStore.getState().loadProject(testProject)
})
const core = KGCore.instance()
const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined)
const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks')
const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState()
act(() => {
setActiveRegionId(testRegion.getId())
setPlayheadPosition(18)
})
await act(async () => {
await startRecording()
})
const storeState = useProjectStore.getState()
expect(storeState.playheadPosition).toBe(12)
expect(storeState.recordingOriginalPlayhead).toBe(18)
expect(storeState.isRecording).toBe(true)
expect(storeState.isPlaying).toBe(true)
expect(recordingCallbacksSpy).toHaveBeenCalled()
expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true })
})
}) })
describe('Selection State Synchronization', () => { describe('Selection State Synchronization', () => {