feat: added an option to allow user choose whether to trim leading silence when bouncing audio
This commit is contained in:
@@ -76,6 +76,7 @@
|
|||||||
"default_open": true
|
"default_open": true
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
|
"bounce_starts_from_beat_1": true,
|
||||||
"enable_audio_capture_for_screen_sharing": false,
|
"enable_audio_capture_for_screen_sharing": false,
|
||||||
"input_device_id": "default",
|
"input_device_id": "default",
|
||||||
"lookahead_time": 0.05,
|
"lookahead_time": 0.05,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState<number>(10);
|
const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState<number>(10);
|
||||||
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
||||||
const [recordingOffset, setRecordingOffset] = useState<string>('0');
|
const [recordingOffset, setRecordingOffset] = useState<string>('0');
|
||||||
|
const [bounceStartsFromBeat1, setBounceStartsFromBeat1] = useState<boolean>(true);
|
||||||
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
||||||
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
||||||
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
||||||
@@ -42,6 +43,7 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
|
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
|
||||||
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
|
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
|
||||||
setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0)));
|
setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0)));
|
||||||
|
setBounceStartsFromBeat1((configManager.get('audio.bounce_starts_from_beat_1') as boolean) ?? true);
|
||||||
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
|
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,6 +160,12 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
await configManager.set('audio.enable_audio_capture_for_screen_sharing', boolValue);
|
await configManager.set('audio.enable_audio_capture_for_screen_sharing', boolValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBounceStartsFromBeat1Change = async (value: string) => {
|
||||||
|
const boolValue = value === 'yes';
|
||||||
|
setBounceStartsFromBeat1(boolValue);
|
||||||
|
await configManager.set('audio.bounce_starts_from_beat_1', boolValue);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<div className="settings-section-header">
|
<div className="settings-section-header">
|
||||||
@@ -325,6 +333,23 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Bounce Starts From Beat 1
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={bounceStartsFromBeat1 ? 'yes' : 'no'}
|
||||||
|
onChange={(e) => handleBounceStartsFromBeat1Change(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Yes includes leading silence from the start of the song up to the first rendered region when bouncing WAV/MP3. No trims that leading silence and starts bounce at the first rendered note or audio region.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-item">
|
<div className="settings-item">
|
||||||
<label className="settings-label">
|
<label className="settings-label">
|
||||||
Capture Audio for Screen Sharing
|
Capture Audio for Screen Sharing
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const OPFS_CONSTANTS = {
|
|||||||
|
|
||||||
export const CONFIG_UPGRADER_CONSTANTS = {
|
export const CONFIG_UPGRADER_CONSTANTS = {
|
||||||
VERSION_KEY: '__config_version',
|
VERSION_KEY: '__config_version',
|
||||||
CURRENT_VERSION: 3,
|
CURRENT_VERSION: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const URL_CONSTANTS = {
|
export const URL_CONSTANTS = {
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data';
|
||||||
import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil';
|
import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil';
|
||||||
|
|
||||||
|
const { offlineMock, configGetMock } = vi.hoisted(() => ({
|
||||||
|
offlineMock: vi.fn(),
|
||||||
|
configGetMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('tone', () => ({
|
||||||
|
Offline: offlineMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./KGAudioInterface', () => ({
|
||||||
|
KGAudioInterface: {
|
||||||
|
instance: vi.fn(() => ({
|
||||||
|
getTrackVolume: vi.fn().mockReturnValue(0),
|
||||||
|
getTrackMuted: vi.fn().mockReturnValue(false),
|
||||||
|
getTrackSolo: vi.fn().mockReturnValue(false),
|
||||||
|
getAudioBuffer: vi.fn().mockReturnValue(null),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../config/ConfigManager', () => ({
|
||||||
|
ConfigManager: {
|
||||||
|
instance: vi.fn(() => ({
|
||||||
|
get: configGetMock,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { KGOfflineRenderer, applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a minimal AudioBuffer-like object for testing.
|
* Create a minimal AudioBuffer-like object for testing.
|
||||||
* In the jsdom test environment, AudioBuffer is not available,
|
* In the jsdom test environment, AudioBuffer is not available,
|
||||||
@@ -228,3 +258,77 @@ describe('offline pitch bend automation', () => {
|
|||||||
expect(calls[0][1]).toBe(0.26);
|
expect(calls[0][1]).toBe(0.26);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('renderToBuffer bounce range', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
configGetMock.mockImplementation((key: string) => {
|
||||||
|
if (key === 'audio.bounce_starts_from_beat_1') return true;
|
||||||
|
if (key === 'audio.midi_automation_interpolation_interval_ms') return 10;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
offlineMock.mockResolvedValue({
|
||||||
|
duration: 0,
|
||||||
|
numberOfChannels: 2,
|
||||||
|
get: vi.fn(),
|
||||||
|
});
|
||||||
|
;(KGOfflineRenderer as unknown as { _instance: KGOfflineRenderer | null })._instance = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts non-looping bounce at beat 0 when configured to include leading silence', async () => {
|
||||||
|
const region = createMockMidiRegion({
|
||||||
|
startFromBeat: 8,
|
||||||
|
notes: [createMockMidiNote({ startBeat: 0, endBeat: 4 })],
|
||||||
|
});
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [track] });
|
||||||
|
|
||||||
|
await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 });
|
||||||
|
|
||||||
|
expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 6, 2, 44100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts non-looping bounce at first content when the setting is disabled', async () => {
|
||||||
|
configGetMock.mockImplementation((key: string) => {
|
||||||
|
if (key === 'audio.bounce_starts_from_beat_1') return false;
|
||||||
|
if (key === 'audio.midi_automation_interpolation_interval_ms') return 10;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const region = createMockMidiRegion({
|
||||||
|
startFromBeat: 8,
|
||||||
|
notes: [createMockMidiNote({ startBeat: 0, endBeat: 4 })],
|
||||||
|
});
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [track] });
|
||||||
|
|
||||||
|
await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 });
|
||||||
|
|
||||||
|
expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 2, 2, 44100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps looping bounce bounds regardless of the beat-1 setting', async () => {
|
||||||
|
configGetMock.mockImplementation((key: string) => {
|
||||||
|
if (key === 'audio.bounce_starts_from_beat_1') return false;
|
||||||
|
if (key === 'audio.midi_automation_interpolation_interval_ms') return 10;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [] });
|
||||||
|
project.setIsLooping(true);
|
||||||
|
project.setLoopingRange([2, 5]);
|
||||||
|
|
||||||
|
await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 });
|
||||||
|
|
||||||
|
expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 8, 2, 44100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the full project length when there is no renderable content', async () => {
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [] });
|
||||||
|
project.setMaxBars(16);
|
||||||
|
|
||||||
|
await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 });
|
||||||
|
|
||||||
|
expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 32, 2, 44100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
let renderStartBeat = 0;
|
let renderStartBeat = 0;
|
||||||
let renderEndBeat: number;
|
let renderEndBeat: number;
|
||||||
|
const bounceStartsFromBeat1 = (ConfigManager.instance().get('audio.bounce_starts_from_beat_1') as boolean) ?? true;
|
||||||
|
|
||||||
const isLooping = project.getIsLooping();
|
const isLooping = project.getIsLooping();
|
||||||
// Looping range is determined up-front; non-looping range is computed
|
// Looping range is determined up-front; non-looping range is computed
|
||||||
@@ -272,7 +273,7 @@ export class KGOfflineRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contentEnd > 0) {
|
if (contentEnd > 0) {
|
||||||
renderStartBeat = contentStart;
|
renderStartBeat = bounceStartsFromBeat1 ? 0 : contentStart;
|
||||||
renderEndBeat = contentEnd;
|
renderEndBeat = contentEnd;
|
||||||
}
|
}
|
||||||
// else: no content found, keep the full project range as fallback
|
// else: no content found, keep the full project range as fallback
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants';
|
|||||||
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||||
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
||||||
import { upgradeConfigToV3 } from './upgradeConfigToV3';
|
import { upgradeConfigToV3 } from './upgradeConfigToV3';
|
||||||
|
import { upgradeConfigToV4 } from './upgradeConfigToV4';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
||||||
@@ -43,6 +44,10 @@ export class KGConfigUpgrader {
|
|||||||
await upgradeConfigToV3();
|
await upgradeConfigToV3();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 4: {
|
||||||
|
await upgradeConfigToV4();
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const getRawMock = vi.fn();
|
||||||
|
const saveRawMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../io/KGConfigStorage', () => ({
|
||||||
|
KGConfigStorage: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
|
getRaw: getRawMock,
|
||||||
|
saveRaw: saveRawMock,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { upgradeConfigToV4 } from './upgradeConfigToV4';
|
||||||
|
|
||||||
|
describe('upgradeConfigToV4', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the audio config and defaults bounce_starts_from_beat_1 to true when audio is missing', async () => {
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
general: {},
|
||||||
|
};
|
||||||
|
getRawMock.mockResolvedValue(config);
|
||||||
|
|
||||||
|
await upgradeConfigToV4();
|
||||||
|
|
||||||
|
expect(config.audio).toEqual({ bounce_starts_from_beat_1: true });
|
||||||
|
expect(saveRawMock).toHaveBeenCalledWith('userConfig', config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults bounce_starts_from_beat_1 to true when the key is missing', async () => {
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
audio: {
|
||||||
|
playback_delay: 0.2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
getRawMock.mockResolvedValue(config);
|
||||||
|
|
||||||
|
await upgradeConfigToV4();
|
||||||
|
|
||||||
|
expect(config.audio).toEqual({
|
||||||
|
playback_delay: 0.2,
|
||||||
|
bounce_starts_from_beat_1: true,
|
||||||
|
});
|
||||||
|
expect(saveRawMock).toHaveBeenCalledWith('userConfig', config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an explicit false value', async () => {
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
audio: {
|
||||||
|
bounce_starts_from_beat_1: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
getRawMock.mockResolvedValue(config);
|
||||||
|
|
||||||
|
await upgradeConfigToV4();
|
||||||
|
|
||||||
|
expect(saveRawMock).not.toHaveBeenCalled();
|
||||||
|
expect((config.audio as Record<string, unknown>).bounce_starts_from_beat_1).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'userConfig';
|
||||||
|
|
||||||
|
export async function upgradeConfigToV4(): Promise<void> {
|
||||||
|
const storage = KGConfigStorage.getInstance();
|
||||||
|
const rawConfig = await storage.getRaw(CONFIG_KEY);
|
||||||
|
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = rawConfig as Record<string, unknown>;
|
||||||
|
const audio = config.audio;
|
||||||
|
|
||||||
|
if (!audio || typeof audio !== 'object') {
|
||||||
|
config.audio = {
|
||||||
|
bounce_starts_from_beat_1: true,
|
||||||
|
};
|
||||||
|
await storage.saveRaw(CONFIG_KEY, config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioRecord = audio as Record<string, unknown>;
|
||||||
|
if ('bounce_starts_from_beat_1' in audioRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRecord.bounce_starts_from_beat_1 = true;
|
||||||
|
await storage.saveRaw(CONFIG_KEY, config);
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ interface AppConfig {
|
|||||||
default_open: boolean;
|
default_open: boolean;
|
||||||
};
|
};
|
||||||
audio: {
|
audio: {
|
||||||
|
bounce_starts_from_beat_1: boolean;
|
||||||
enable_audio_capture_for_screen_sharing: boolean;
|
enable_audio_capture_for_screen_sharing: boolean;
|
||||||
input_device_id: string;
|
input_device_id: string;
|
||||||
lookahead_time: number;
|
lookahead_time: number;
|
||||||
@@ -260,6 +261,7 @@ export class ConfigManager {
|
|||||||
default_open: true
|
default_open: true
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
|
bounce_starts_from_beat_1: true,
|
||||||
enable_audio_capture_for_screen_sharing: false,
|
enable_audio_capture_for_screen_sharing: false,
|
||||||
input_device_id: 'default',
|
input_device_id: 'default',
|
||||||
lookahead_time: 0.05,
|
lookahead_time: 0.05,
|
||||||
|
|||||||
Reference in New Issue
Block a user