Merge pull request #55 from KGAudioLab/feat/2026-06-30-misc
Feat/2026 06 30 misc
This commit is contained in:
@@ -12,6 +12,8 @@ const { mockLocalSeparatorDownload } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
let kgoneEnabled = false;
|
let kgoneEnabled = false;
|
||||||
let selectedRegionIds: string[] = [];
|
let selectedRegionIds: string[] = [];
|
||||||
|
let projectName = 'Test Project';
|
||||||
|
let savedProjectName = 'Test Project';
|
||||||
let localModelCached: Record<string, boolean> = {};
|
let localModelCached: Record<string, boolean> = {};
|
||||||
let localSeparationResult: Array<{ name: string; blob: Blob }> = [];
|
let localSeparationResult: Array<{ name: string; blob: Blob }> = [];
|
||||||
|
|
||||||
@@ -21,7 +23,8 @@ const mockExecuteCommand = vi.fn();
|
|||||||
vi.mock('../stores/projectStore', () => ({
|
vi.mock('../stores/projectStore', () => ({
|
||||||
useProjectStore: () => ({
|
useProjectStore: () => ({
|
||||||
selectedRegionIds,
|
selectedRegionIds,
|
||||||
projectName: 'Test Project',
|
projectName,
|
||||||
|
savedProjectName,
|
||||||
bpm: 120,
|
bpm: 120,
|
||||||
keySignature: 'C major',
|
keySignature: 'C major',
|
||||||
timeSignature: { numerator: 4, denominator: 4 },
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
@@ -127,6 +130,8 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
kgoneEnabled = false;
|
kgoneEnabled = false;
|
||||||
selectedRegionIds = [];
|
selectedRegionIds = [];
|
||||||
|
projectName = 'Test Project';
|
||||||
|
savedProjectName = 'Test Project';
|
||||||
localModelCached = {};
|
localModelCached = {};
|
||||||
localSeparationResult = [
|
localSeparationResult = [
|
||||||
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
||||||
@@ -185,6 +190,7 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
render(<KGOnePanel isVisible={true} />);
|
render(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
expect(await screen.findByText(/Select an audio region on the timeline/)).toBeInTheDocument();
|
expect(await screen.findByText(/Select an audio region on the timeline/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Separate Stems' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders local separation outputs after processing completes', async () => {
|
it('renders local separation outputs after processing completes', async () => {
|
||||||
@@ -202,6 +208,79 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps stem results visible and disables separation after the selection is cleared', async () => {
|
||||||
|
localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true;
|
||||||
|
selectedRegionIds = ['audio-region-1'];
|
||||||
|
|
||||||
|
const { rerender } = render(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Instrumental')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vocals')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedRegionIds = [];
|
||||||
|
rerender(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Instrumental')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vocals')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Select an audio region on the timeline/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Separate Stems' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stem results when the saved project changes', async () => {
|
||||||
|
localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true;
|
||||||
|
selectedRegionIds = ['audio-region-1'];
|
||||||
|
|
||||||
|
const { rerender } = render(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Instrumental')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('Separation complete.').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
savedProjectName = 'Loaded Project';
|
||||||
|
projectName = 'Loaded Project';
|
||||||
|
selectedRegionIds = [];
|
||||||
|
rerender(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Instrumental')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Vocals')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryAllByText('Separation complete.')).toHaveLength(0);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Import All Stems to Timeline' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Select an audio region on the timeline/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves stem results when only the project name changes', async () => {
|
||||||
|
localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true;
|
||||||
|
selectedRegionIds = ['audio-region-1'];
|
||||||
|
|
||||||
|
const { rerender } = render(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Instrumental')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
projectName = 'Renamed Project';
|
||||||
|
selectedRegionIds = [];
|
||||||
|
rerender(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Instrumental')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vocals')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Select an audio region on the timeline/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses Demucs defaults and renders four local stem players', async () => {
|
it('uses Demucs defaults and renders four local stem players', async () => {
|
||||||
localModelCached[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s] = true;
|
localModelCached[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s] = true;
|
||||||
localSeparationResult = [
|
localSeparationResult = [
|
||||||
|
|||||||
+112
-107
@@ -856,7 +856,7 @@ function countRepaintTracks(sourceTrackName: string): number {
|
|||||||
|
|
||||||
const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore();
|
const { selectedRegionIds, projectName, savedProjectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore();
|
||||||
const localOnlyMode = mode === 'local-separator';
|
const localOnlyMode = mode === 'local-separator';
|
||||||
const availableSeparatorModels = localOnlyMode ? LOCAL_SEPARATOR_MODEL_OPTIONS : SERVER_SEPARATOR_MODELS;
|
const availableSeparatorModels = localOnlyMode ? LOCAL_SEPARATOR_MODEL_OPTIONS : SERVER_SEPARATOR_MODELS;
|
||||||
const [model, setModel] = useState<string>(availableSeparatorModels[0].value);
|
const [model, setModel] = useState<string>(availableSeparatorModels[0].value);
|
||||||
@@ -910,12 +910,14 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
setStemAudioUrls([]);
|
setStemAudioUrls([]);
|
||||||
setGenStatus('idle');
|
setGenStatus('idle');
|
||||||
setGenHint('');
|
setGenHint('');
|
||||||
|
setLocalProgressPercent(0);
|
||||||
|
setLocalProgressText('');
|
||||||
setErrorMsg('');
|
setErrorMsg('');
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
setImportError('');
|
setImportError('');
|
||||||
originalRegionRef.current = null;
|
originalRegionRef.current = null;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectName]);
|
}, [savedProjectName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setModel(availableSeparatorModels[0].value);
|
setModel(availableSeparatorModels[0].value);
|
||||||
@@ -967,6 +969,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [selectedRegionIds]);
|
}, [selectedRegionIds]);
|
||||||
|
const hasStemResults = stemAudioUrls.length > 0;
|
||||||
|
|
||||||
const getConfiguredLocalSeparatorModelUrl = useCallback(() => {
|
const getConfiguredLocalSeparatorModelUrl = useCallback(() => {
|
||||||
const configured = ConfigManager.instance().get(currentLocalModelConfig.download.configKey);
|
const configured = ConfigManager.instance().get(currentLocalModelConfig.download.configKey);
|
||||||
@@ -1450,119 +1453,121 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAudioRegion ? (
|
<>
|
||||||
<>
|
{selectedAudioRegion && (
|
||||||
<div className="kgone-region-info">
|
<div className="kgone-region-info">
|
||||||
<div className="kgone-region-info-label">{t('kgone.shared.selectedRegion')}</div>
|
<div className="kgone-region-info-label">{t('kgone.shared.selectedRegion')}</div>
|
||||||
<div className="kgone-region-info-value">{selectedAudioRegion.region.getName()}</div>
|
<div className="kgone-region-info-value">{selectedAudioRegion.region.getName()}</div>
|
||||||
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>{t('kgone.shared.track')}</div>
|
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>{t('kgone.shared.track')}</div>
|
||||||
<div className="kgone-region-info-value">{selectedAudioRegion.trackName}</div>
|
<div className="kgone-region-info-value">{selectedAudioRegion.trackName}</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="kgone-field">
|
<div className="kgone-field">
|
||||||
<label className="kgone-label">{t('kgone.separator.field.separationModel')}</label>
|
<label className="kgone-label">{t('kgone.separator.field.separationModel')}</label>
|
||||||
<select className="kgone-select" value={model} onChange={e => setModel(e.target.value)}>
|
<select className="kgone-select" value={model} onChange={e => setModel(e.target.value)}>
|
||||||
{availableSeparatorModels.map(m => (
|
{availableSeparatorModels.map(m => (
|
||||||
<option key={m.value} value={m.value}>{getModelLabel(m.value, t)}</option>
|
<option key={m.value} value={m.value}>{getModelLabel(m.value, t)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
|
||||||
{localOnlyMode && (
|
|
||||||
<Expander label={t('kgone.shared.advancedSettings')}>
|
|
||||||
<div className="kgone-field">
|
|
||||||
<label className="kgone-label">{t('kgone.separator.field.chunkDuration')}</label>
|
|
||||||
<input
|
|
||||||
className="kgone-input"
|
|
||||||
aria-label={t('kgone.separator.field.chunkDuration')}
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={1}
|
|
||||||
value={localChunkDurationSeconds}
|
|
||||||
onChange={e => setLocalChunkDurationSeconds(e.target.value)}
|
|
||||||
placeholder={t('kgone.separator.field.chunkDurationPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="kgone-field">
|
|
||||||
<label className="kgone-label">{t('kgone.separator.field.modelOverlap')}</label>
|
|
||||||
<input
|
|
||||||
className="kgone-input"
|
|
||||||
aria-label={t('kgone.separator.field.modelOverlap')}
|
|
||||||
type="number"
|
|
||||||
min={0.001}
|
|
||||||
max={0.999}
|
|
||||||
step={0.01}
|
|
||||||
value={localOverlap}
|
|
||||||
onChange={e => setLocalOverlap(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Expander>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stem audio players — shown once separation is complete */}
|
|
||||||
{stemAudioUrls.length > 0 && (
|
|
||||||
<div className="kgone-stems">
|
|
||||||
{stemAudioUrls.map(stem => (
|
|
||||||
<div key={stem.name} className="kgone-stem-player">
|
|
||||||
<div className="kgone-label" style={{ marginBottom: 4 }}>{stem.name}</div>
|
|
||||||
<AudioPlayer
|
|
||||||
src={stem.url}
|
|
||||||
dragData={taskIdRef.current ? {
|
|
||||||
audioFileName: `KGOne_Stem_${stem.name}_${taskIdRef.current}.${localOnlyMode ? 'wav' : 'mp3'}`,
|
|
||||||
} : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag-to-track hint — shown after successful separation */}
|
|
||||||
{genStatus === 'done' && (
|
|
||||||
<div className="kgone-hint" dangerouslySetInnerHTML={{ __html: t('kgone.separator.hint.drag') }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bulk import button — shown after successful separation */}
|
|
||||||
{genStatus === 'done' && stemAudioUrls.length > 0 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="dialog-btn dialog-btn-primary kgone-btn-generate"
|
|
||||||
disabled={isImporting}
|
|
||||||
onClick={handleImportAll}
|
|
||||||
style={{ marginTop: 0 }}
|
|
||||||
>
|
|
||||||
{isImporting && <FaCircleNotch className="kgone-spinner" />}
|
|
||||||
{isImporting ? t('kgone.shared.btn.importing') : t('kgone.separator.btn.importAllStems')}
|
|
||||||
</button>
|
|
||||||
{importError && <div className="kgone-error-msg">{importError}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separation error message */}
|
|
||||||
{genStatus === 'error' && errorMsg && (
|
|
||||||
<div className="kgone-error-msg">{errorMsg}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="dialog-btn dialog-btn-primary kgone-btn-generate"
|
|
||||||
disabled={isGenerating || (localOnlyMode && !isLocalModelCached)}
|
|
||||||
onClick={handleSeparate}
|
|
||||||
>
|
|
||||||
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
|
|
||||||
{btnLabel()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Status hint below button */}
|
|
||||||
{(localOnlyMode ? localProgressText : genHint) && (
|
|
||||||
<div className="kgone-gen-hint">{localOnlyMode ? localProgressText : genHint}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="kgone-separator-hint">
|
|
||||||
{localOnlyMode && !isLocalModelCached
|
|
||||||
? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) })
|
|
||||||
: t('kgone.separator.hint.noRegion.select')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{localOnlyMode && (
|
||||||
|
<Expander label={t('kgone.shared.advancedSettings')}>
|
||||||
|
<div className="kgone-field">
|
||||||
|
<label className="kgone-label">{t('kgone.separator.field.chunkDuration')}</label>
|
||||||
|
<input
|
||||||
|
className="kgone-input"
|
||||||
|
aria-label={t('kgone.separator.field.chunkDuration')}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={localChunkDurationSeconds}
|
||||||
|
onChange={e => setLocalChunkDurationSeconds(e.target.value)}
|
||||||
|
placeholder={t('kgone.separator.field.chunkDurationPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="kgone-field">
|
||||||
|
<label className="kgone-label">{t('kgone.separator.field.modelOverlap')}</label>
|
||||||
|
<input
|
||||||
|
className="kgone-input"
|
||||||
|
aria-label={t('kgone.separator.field.modelOverlap')}
|
||||||
|
type="number"
|
||||||
|
min={0.001}
|
||||||
|
max={0.999}
|
||||||
|
step={0.01}
|
||||||
|
value={localOverlap}
|
||||||
|
onChange={e => setLocalOverlap(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Expander>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stem audio players — shown once separation is complete */}
|
||||||
|
{hasStemResults && (
|
||||||
|
<div className="kgone-stems">
|
||||||
|
{stemAudioUrls.map(stem => (
|
||||||
|
<div key={stem.name} className="kgone-stem-player">
|
||||||
|
<div className="kgone-label" style={{ marginBottom: 4 }}>{stem.name}</div>
|
||||||
|
<AudioPlayer
|
||||||
|
src={stem.url}
|
||||||
|
dragData={taskIdRef.current ? {
|
||||||
|
audioFileName: `KGOne_Stem_${stem.name}_${taskIdRef.current}.${localOnlyMode ? 'wav' : 'mp3'}`,
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag-to-track hint — shown after successful separation */}
|
||||||
|
{hasStemResults && (
|
||||||
|
<div className="kgone-hint" dangerouslySetInnerHTML={{ __html: t('kgone.separator.hint.drag') }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk import button — shown after successful separation */}
|
||||||
|
{hasStemResults && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||||
|
disabled={isImporting}
|
||||||
|
onClick={handleImportAll}
|
||||||
|
style={{ marginTop: 0 }}
|
||||||
|
>
|
||||||
|
{isImporting && <FaCircleNotch className="kgone-spinner" />}
|
||||||
|
{isImporting ? t('kgone.shared.btn.importing') : t('kgone.separator.btn.importAllStems')}
|
||||||
|
</button>
|
||||||
|
{importError && <div className="kgone-error-msg">{importError}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separation error message */}
|
||||||
|
{genStatus === 'error' && errorMsg && (
|
||||||
|
<div className="kgone-error-msg">{errorMsg}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||||
|
disabled={!selectedAudioRegion || isGenerating || (localOnlyMode && !isLocalModelCached)}
|
||||||
|
onClick={handleSeparate}
|
||||||
|
>
|
||||||
|
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
|
||||||
|
{btnLabel()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Status hint below button */}
|
||||||
|
{(localOnlyMode ? localProgressText : genHint) && (
|
||||||
|
<div className="kgone-gen-hint">{localOnlyMode ? localProgressText : genHint}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedAudioRegion && !hasStemResults && (
|
||||||
|
<div className="kgone-separator-hint">
|
||||||
|
{localOnlyMode && !isLocalModelCached
|
||||||
|
? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) })
|
||||||
|
: t('kgone.separator.hint.noRegion.select')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
|
||||||
{!localOnlyMode && (
|
{!localOnlyMode && (
|
||||||
<div className="kgone-powered-by">
|
<div className="kgone-powered-by">
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGProject } from '../../KGProject';
|
import { KGProject } from '../../KGProject';
|
||||||
import { GlobalTrackType } from '../../global-track';
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import { KGAudioTrack } from '../../track/KGAudioTrack';
|
||||||
import { CreateTempoRegionCommand } from './CreateTempoRegionCommand';
|
import { CreateTempoRegionCommand } from './CreateTempoRegionCommand';
|
||||||
import { DeleteTempoRegionCommand } from './DeleteTempoRegionCommand';
|
import { DeleteTempoRegionCommand } from './DeleteTempoRegionCommand';
|
||||||
import { ResizeTempoRegionCommand } from './ResizeTempoRegionCommand';
|
import { ResizeTempoRegionCommand } from './ResizeTempoRegionCommand';
|
||||||
@@ -118,4 +120,26 @@ describe('global tempo region commands', () => {
|
|||||||
command.undo();
|
command.undo();
|
||||||
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(120);
|
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getBpm()).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands max bars when a tempo edit makes audio require more space', () => {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const tempoTrack = getTempoTrack();
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('region', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 8, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const audioTrack = new KGAudioTrack('Audio', 1);
|
||||||
|
audioTrack.setRegions([
|
||||||
|
new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0),
|
||||||
|
]);
|
||||||
|
project.setTracks([audioTrack]);
|
||||||
|
|
||||||
|
const command = new UpdateTempoRegionCommand('region', 60);
|
||||||
|
command.execute();
|
||||||
|
expect(project.getMaxBars()).toBe(9);
|
||||||
|
expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(8);
|
||||||
|
command.undo();
|
||||||
|
expect(project.getMaxBars()).toBe(8);
|
||||||
|
expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,23 @@ import { KGCommand } from '../KGCommand';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { GlobalTrackType } from '../../global-track';
|
import { GlobalTrackType } from '../../global-track';
|
||||||
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
import {
|
||||||
|
findGlobalTrackByType,
|
||||||
|
getRequiredMaxBarsForAudioRegions,
|
||||||
|
normalizeTempoRegionsForProject,
|
||||||
|
restoreAudioRegionLengths,
|
||||||
|
syncAudioRegionLengthsToPlaybackDuration,
|
||||||
|
type AudioRegionLengthSnapshot,
|
||||||
|
} from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
export class UpdateTempoRegionCommand extends KGCommand {
|
export class UpdateTempoRegionCommand extends KGCommand {
|
||||||
private readonly regionId: string;
|
private readonly regionId: string;
|
||||||
private readonly nextBpm: number;
|
private readonly nextBpm: number;
|
||||||
private previousBpm: number | null = null;
|
private previousBpm: number | null = null;
|
||||||
|
private previousMaxBars: number | null = null;
|
||||||
|
private nextMaxBars: number | null = null;
|
||||||
private targetRegion: KGTempoRegion | null = null;
|
private targetRegion: KGTempoRegion | null = null;
|
||||||
|
private audioRegionLengthSnapshots: AudioRegionLengthSnapshot[] = [];
|
||||||
|
|
||||||
constructor(regionId: string, nextBpm: number) {
|
constructor(regionId: string, nextBpm: number) {
|
||||||
super();
|
super();
|
||||||
@@ -30,15 +40,32 @@ export class UpdateTempoRegionCommand extends KGCommand {
|
|||||||
|
|
||||||
this.targetRegion = region;
|
this.targetRegion = region;
|
||||||
this.previousBpm = region.getBpm();
|
this.previousBpm = region.getBpm();
|
||||||
|
this.previousMaxBars = project.getMaxBars();
|
||||||
region.setBpm(this.nextBpm);
|
region.setBpm(this.nextBpm);
|
||||||
|
this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(project);
|
||||||
|
|
||||||
|
const requiredMaxBars = getRequiredMaxBarsForAudioRegions(project);
|
||||||
|
this.nextMaxBars = Math.max(project.getMaxBars(), requiredMaxBars);
|
||||||
|
if (this.nextMaxBars > project.getMaxBars()) {
|
||||||
|
project.setMaxBars(this.nextMaxBars);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (!this.targetRegion || this.previousBpm === null) {
|
if (!this.targetRegion || this.previousBpm === null || this.previousMaxBars === null) {
|
||||||
throw new Error('Cannot undo tempo update without previous state');
|
throw new Error('Cannot undo tempo update without previous state');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
this.targetRegion.setBpm(this.previousBpm);
|
this.targetRegion.setBpm(this.previousBpm);
|
||||||
|
if (project.getMaxBars() !== this.previousMaxBars) {
|
||||||
|
project.setMaxBars(this.previousMaxBars);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
}
|
||||||
|
if (this.audioRegionLengthSnapshots.length > 0) {
|
||||||
|
restoreAudioRegionLengths(this.audioRegionLengthSnapshots);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGProject } from '../../KGProject';
|
import { KGProject } from '../../KGProject';
|
||||||
import { GlobalTrackType } from '../../global-track';
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||||
|
import { KGAudioTrack } from '../../track/KGAudioTrack';
|
||||||
import { WriteTempoTrackCommand } from './WriteTempoTrackCommand';
|
import { WriteTempoTrackCommand } from './WriteTempoTrackCommand';
|
||||||
|
|
||||||
describe('WriteTempoTrackCommand', () => {
|
describe('WriteTempoTrackCommand', () => {
|
||||||
@@ -90,4 +92,26 @@ describe('WriteTempoTrackCommand', () => {
|
|||||||
{ bpm: 128, startBar: 2, lengthBars: 6 },
|
{ bpm: 128, startBar: 2, lengthBars: 6 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands max bars when the rebuilt tempo map makes audio overflow', () => {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const audioTrack = new KGAudioTrack('Audio', 1);
|
||||||
|
audioTrack.setRegions([
|
||||||
|
new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0),
|
||||||
|
]);
|
||||||
|
project.setTracks([audioTrack]);
|
||||||
|
|
||||||
|
const command = new WriteTempoTrackCommand(60, []);
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
expect(project.getBpm()).toBe(60);
|
||||||
|
expect(project.getMaxBars()).toBe(9);
|
||||||
|
expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(8);
|
||||||
|
|
||||||
|
command.undo();
|
||||||
|
|
||||||
|
expect(project.getBpm()).toBe(120);
|
||||||
|
expect(project.getMaxBars()).toBe(8);
|
||||||
|
expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { KGTempoRegion } from '../../region/KGTempoRegion';
|
|||||||
import {
|
import {
|
||||||
cloneTempoRegions,
|
cloneTempoRegions,
|
||||||
findGlobalTrackByType,
|
findGlobalTrackByType,
|
||||||
|
getRequiredMaxBarsForAudioRegions,
|
||||||
getSongEndBar,
|
getSongEndBar,
|
||||||
getSortedTempoRegions,
|
getSortedTempoRegions,
|
||||||
|
normalizeTempoRegionsForProject,
|
||||||
|
restoreAudioRegionLengths,
|
||||||
|
syncAudioRegionLengthsToPlaybackDuration,
|
||||||
|
type AudioRegionLengthSnapshot,
|
||||||
} from '../../../util/globalTrackUtil';
|
} from '../../../util/globalTrackUtil';
|
||||||
import { generateUniqueId } from '../../../util/miscUtil';
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
|
|
||||||
@@ -24,7 +29,10 @@ export class WriteTempoTrackCommand extends KGCommand {
|
|||||||
private readonly replacements: WriteTempoEntry[];
|
private readonly replacements: WriteTempoEntry[];
|
||||||
private previousRegions: KGTempoRegion[] | null = null;
|
private previousRegions: KGTempoRegion[] | null = null;
|
||||||
private previousProjectBpm: number | null = null;
|
private previousProjectBpm: number | null = null;
|
||||||
|
private previousMaxBars: number | null = null;
|
||||||
private nextRegions: KGTempoRegion[] | null = null;
|
private nextRegions: KGTempoRegion[] | null = null;
|
||||||
|
private nextMaxBars: number | null = null;
|
||||||
|
private audioRegionLengthSnapshots: AudioRegionLengthSnapshot[] = [];
|
||||||
|
|
||||||
constructor(baseBpm: number, replacements: WriteTempoEntry[]) {
|
constructor(baseBpm: number, replacements: WriteTempoEntry[]) {
|
||||||
super();
|
super();
|
||||||
@@ -46,17 +54,29 @@ export class WriteTempoTrackCommand extends KGCommand {
|
|||||||
if (this.nextRegions) {
|
if (this.nextRegions) {
|
||||||
project.setBpm(this.baseBpm);
|
project.setBpm(this.baseBpm);
|
||||||
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
||||||
|
syncAudioRegionLengthsToPlaybackDuration(project);
|
||||||
|
if (this.nextMaxBars !== null) {
|
||||||
|
project.setMaxBars(this.nextMaxBars);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRegions = getSortedTempoRegions(track, beatsPerBar);
|
const currentRegions = getSortedTempoRegions(track, beatsPerBar);
|
||||||
this.previousProjectBpm = project.getBpm();
|
this.previousProjectBpm = project.getBpm();
|
||||||
|
this.previousMaxBars = project.getMaxBars();
|
||||||
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
|
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
|
||||||
project.setBpm(this.baseBpm);
|
project.setBpm(this.baseBpm);
|
||||||
|
|
||||||
if (this.replacements.length === 0) {
|
if (this.replacements.length === 0) {
|
||||||
this.nextRegions = [];
|
this.nextRegions = [];
|
||||||
track.setRegions([]);
|
track.setRegions([]);
|
||||||
|
this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(project);
|
||||||
|
this.nextMaxBars = getRequiredMaxBarsForAudioRegions(project);
|
||||||
|
if (this.nextMaxBars > project.getMaxBars()) {
|
||||||
|
project.setMaxBars(this.nextMaxBars);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +84,7 @@ export class WriteTempoTrackCommand extends KGCommand {
|
|||||||
if (songEndBar <= 0) {
|
if (songEndBar <= 0) {
|
||||||
this.nextRegions = [];
|
this.nextRegions = [];
|
||||||
track.setRegions([]);
|
track.setRegions([]);
|
||||||
|
this.nextMaxBars = getRequiredMaxBarsForAudioRegions(project);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +140,16 @@ export class WriteTempoTrackCommand extends KGCommand {
|
|||||||
|
|
||||||
this.nextRegions = nextRegions;
|
this.nextRegions = nextRegions;
|
||||||
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
||||||
|
this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(project);
|
||||||
|
this.nextMaxBars = getRequiredMaxBarsForAudioRegions(project);
|
||||||
|
if (this.nextMaxBars > project.getMaxBars()) {
|
||||||
|
project.setMaxBars(this.nextMaxBars);
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (!this.previousRegions || this.previousProjectBpm === null) {
|
if (!this.previousRegions || this.previousProjectBpm === null || this.previousMaxBars === null) {
|
||||||
throw new Error('Cannot undo tempo write without original state');
|
throw new Error('Cannot undo tempo write without original state');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +161,12 @@ export class WriteTempoTrackCommand extends KGCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
project.setBpm(this.previousProjectBpm);
|
project.setBpm(this.previousProjectBpm);
|
||||||
|
project.setMaxBars(this.previousMaxBars);
|
||||||
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
|
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
|
||||||
|
normalizeTempoRegionsForProject(project);
|
||||||
|
if (this.audioRegionLengthSnapshots.length > 0) {
|
||||||
|
restoreAudioRegionLengths(this.audioRegionLengthSnapshots);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { KGCommand } from '../KGCommand';
|
|||||||
import { KGCore } from '../../KGCore';
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGProject, type KeySignature } from '../../KGProject';
|
import { KGProject, type KeySignature } from '../../KGProject';
|
||||||
import type { TimeSignature } from '../../../types/projectTypes';
|
import type { TimeSignature } from '../../../types/projectTypes';
|
||||||
import { normalizeTempoRegionsForProject } from '../../../util/globalTrackUtil';
|
import {
|
||||||
|
getRequiredMaxBarsForAudioRegions,
|
||||||
|
normalizeTempoRegionsForProject,
|
||||||
|
restoreAudioRegionLengths,
|
||||||
|
syncAudioRegionLengthsToPlaybackDuration,
|
||||||
|
type AudioRegionLengthSnapshot,
|
||||||
|
} from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining properties that can be updated on a project
|
* Interface defining properties that can be updated on a project
|
||||||
@@ -26,12 +32,25 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
private originalProperties: ProjectUpdateProperties = {};
|
private originalProperties: ProjectUpdateProperties = {};
|
||||||
private targetProject: KGProject | null = null;
|
private targetProject: KGProject | null = null;
|
||||||
private changedProperties: Set<keyof ProjectUpdateProperties> = new Set();
|
private changedProperties: Set<keyof ProjectUpdateProperties> = new Set();
|
||||||
|
private audioRegionLengthSnapshots: AudioRegionLengthSnapshot[] = [];
|
||||||
|
|
||||||
constructor(properties: ProjectUpdateProperties) {
|
constructor(properties: ProjectUpdateProperties) {
|
||||||
super();
|
super();
|
||||||
this.newProperties = properties;
|
this.newProperties = properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clampPlayheadToSongEndIfNeeded(): void {
|
||||||
|
if (!this.targetProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const songEndBeat = this.targetProject.getMaxBars() * this.targetProject.getTimeSignature().numerator;
|
||||||
|
if (core.getPlayheadPosition() > songEndBeat) {
|
||||||
|
core.setPlayheadPosition(songEndBeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): void {
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
this.targetProject = core.getCurrentProject();
|
this.targetProject = core.getCurrentProject();
|
||||||
@@ -63,6 +82,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
normalizeTempoRegionsForProject(this.targetProject);
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
this.changedProperties.add('maxBars');
|
this.changedProperties.add('maxBars');
|
||||||
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`);
|
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${this.newProperties.maxBars}`);
|
||||||
|
this.clampPlayheadToSongEndIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update currentBars
|
// Update currentBars
|
||||||
@@ -79,6 +99,19 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
updatedProperties.push(`bpm: ${this.originalProperties.bpm} → ${this.newProperties.bpm}`);
|
updatedProperties.push(`bpm: ${this.originalProperties.bpm} → ${this.newProperties.bpm}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.changedProperties.has('bpm')) {
|
||||||
|
this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(this.targetProject);
|
||||||
|
const requiredMaxBars = getRequiredMaxBarsForAudioRegions(this.targetProject);
|
||||||
|
if (requiredMaxBars > this.targetProject.getMaxBars()) {
|
||||||
|
this.targetProject.setMaxBars(requiredMaxBars);
|
||||||
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
|
this.changedProperties.add('maxBars');
|
||||||
|
if (this.originalProperties.maxBars !== requiredMaxBars) {
|
||||||
|
updatedProperties.push(`maxBars: ${this.originalProperties.maxBars} → ${requiredMaxBars}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update time signature
|
// Update time signature
|
||||||
if (this.newProperties.timeSignature !== undefined) {
|
if (this.newProperties.timeSignature !== undefined) {
|
||||||
const originalTS = this.originalProperties.timeSignature!;
|
const originalTS = this.originalProperties.timeSignature!;
|
||||||
@@ -88,8 +121,10 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
|
if (originalTS.numerator !== newTS.numerator || originalTS.denominator !== newTS.denominator) {
|
||||||
this.targetProject.setTimeSignature(newTS);
|
this.targetProject.setTimeSignature(newTS);
|
||||||
normalizeTempoRegionsForProject(this.targetProject);
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
|
this.audioRegionLengthSnapshots = syncAudioRegionLengthsToPlaybackDuration(this.targetProject);
|
||||||
this.changedProperties.add('timeSignature');
|
this.changedProperties.add('timeSignature');
|
||||||
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator} → ${newTS.numerator}/${newTS.denominator}`);
|
updatedProperties.push(`timeSignature: ${originalTS.numerator}/${originalTS.denominator} → ${newTS.numerator}/${newTS.denominator}`);
|
||||||
|
this.clampPlayheadToSongEndIfNeeded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +168,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
this.targetProject.setMaxBars(this.originalProperties.maxBars);
|
this.targetProject.setMaxBars(this.originalProperties.maxBars);
|
||||||
normalizeTempoRegionsForProject(this.targetProject);
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
|
restoredProperties.push(`maxBars: ${this.originalProperties.maxBars}`);
|
||||||
|
this.clampPlayheadToSongEndIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore currentBars (only if it was changed)
|
// Restore currentBars (only if it was changed)
|
||||||
@@ -153,6 +189,7 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
normalizeTempoRegionsForProject(this.targetProject);
|
normalizeTempoRegionsForProject(this.targetProject);
|
||||||
const ts = this.originalProperties.timeSignature;
|
const ts = this.originalProperties.timeSignature;
|
||||||
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
|
restoredProperties.push(`timeSignature: ${ts.numerator}/${ts.denominator}`);
|
||||||
|
this.clampPlayheadToSongEndIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore key signature (only if it was changed)
|
// Restore key signature (only if it was changed)
|
||||||
@@ -167,6 +204,10 @@ export class ChangeProjectPropertyCommand extends KGCommand {
|
|||||||
restoredProperties.push(`selectedMode: "${this.originalProperties.selectedMode}"`);
|
restoredProperties.push(`selectedMode: "${this.originalProperties.selectedMode}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.audioRegionLengthSnapshots.length > 0) {
|
||||||
|
restoreAudioRegionLengths(this.audioRegionLengthSnapshots);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Restored project: ${restoredProperties.join(', ')}`);
|
console.log(`Restored project: ${restoredProperties.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
|||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { createDefaultGlobalTracks } from '../core/global-track';
|
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||||
|
import { getAudioRegionDisplayLengthBeats } from '../util/globalTrackUtil';
|
||||||
|
|
||||||
const pianoRollStateMocks = vi.hoisted(() => ({
|
const pianoRollStateMocks = vi.hoisted(() => ({
|
||||||
setSheetMusicViewEnabled: vi.fn(),
|
setSheetMusicViewEnabled: vi.fn(),
|
||||||
@@ -33,6 +34,7 @@ const toneMocks = vi.hoisted(() => {
|
|||||||
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
let mockIsMetronomeEnabled = false;
|
let mockIsMetronomeEnabled = false;
|
||||||
let mockShowGlobalTracks = false;
|
let mockShowGlobalTracks = false;
|
||||||
|
let mockPlayheadPosition = 0;
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
getMaxBars: () => 32,
|
getMaxBars: () => 32,
|
||||||
@@ -101,8 +103,10 @@ const mockCore = {
|
|||||||
clearSelectedItems: vi.fn(),
|
clearSelectedItems: vi.fn(),
|
||||||
getStatus: () => 'Ready',
|
getStatus: () => 'Ready',
|
||||||
setStatus: vi.fn(),
|
setStatus: vi.fn(),
|
||||||
getPlayheadPosition: () => 0,
|
getPlayheadPosition: () => mockPlayheadPosition,
|
||||||
setPlayheadPosition: vi.fn(),
|
setPlayheadPosition: vi.fn((position: number) => {
|
||||||
|
mockPlayheadPosition = position;
|
||||||
|
}),
|
||||||
getIsPlaying: () => false,
|
getIsPlaying: () => false,
|
||||||
startPlaying: vi.fn().mockResolvedValue(undefined),
|
startPlaying: vi.fn().mockResolvedValue(undefined),
|
||||||
stopPlaying: vi.fn().mockResolvedValue(undefined),
|
stopPlaying: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -161,10 +165,13 @@ describe('projectStore piano roll state', () => {
|
|||||||
pianoRollStateMocks.setPianoRollZoom.mockReset();
|
pianoRollStateMocks.setPianoRollZoom.mockReset();
|
||||||
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
currentProject = mockProject;
|
currentProject = mockProject;
|
||||||
|
mockPlayheadPosition = 0;
|
||||||
mockCore.startPlaying.mockReset();
|
mockCore.startPlaying.mockReset();
|
||||||
mockCore.startPlaying.mockResolvedValue(undefined);
|
mockCore.startPlaying.mockResolvedValue(undefined);
|
||||||
mockCore.stopPlaying.mockReset();
|
mockCore.stopPlaying.mockReset();
|
||||||
mockCore.stopPlaying.mockResolvedValue(undefined);
|
mockCore.stopPlaying.mockResolvedValue(undefined);
|
||||||
|
mockCore.executeCommand.mockReset();
|
||||||
|
mockCore.setPlayheadPosition.mockClear();
|
||||||
mockCore.undo.mockReset();
|
mockCore.undo.mockReset();
|
||||||
mockCore.undo.mockReturnValue(true);
|
mockCore.undo.mockReturnValue(true);
|
||||||
mockCore.redo.mockReset();
|
mockCore.redo.mockReset();
|
||||||
@@ -591,4 +598,143 @@ describe('projectStore piano roll state', () => {
|
|||||||
expect(state.settingsReturnSidePanel).toBeNull();
|
expect(state.settingsReturnSidePanel).toBeNull();
|
||||||
expect(state.lastActiveSidePanel).toBe('eventList');
|
expect(state.lastActiveSidePanel).toBe('eventList');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes expanded max bars after BPM reduction', async () => {
|
||||||
|
const { KGAudioTrack: TestAudioTrack } = await import('../core/track/KGAudioTrack');
|
||||||
|
const { KGAudioRegion: TestAudioRegion } = await import('../core/region/KGAudioRegion');
|
||||||
|
|
||||||
|
const audioTrack = new TestAudioTrack('Audio 1', 1);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
const audioRegion = new TestAudioRegion('audio-region-1', '1', 0, 'clip.wav', 124, 4, 'audio-file-1.wav', 'clip.wav', 8, 0);
|
||||||
|
audioTrack.setRegions([audioRegion]);
|
||||||
|
|
||||||
|
let projectName = 'Test Project';
|
||||||
|
let bpm = 120;
|
||||||
|
let maxBars = 32;
|
||||||
|
let currentBars = 0;
|
||||||
|
let timeSignature = { numerator: 4, denominator: 4 };
|
||||||
|
let keySignature = 'C major';
|
||||||
|
let selectedMode = 'major';
|
||||||
|
const project = {
|
||||||
|
getName: () => projectName,
|
||||||
|
setName: (value: string) => { projectName = value; },
|
||||||
|
getMaxBars: () => maxBars,
|
||||||
|
setMaxBars: (value: number) => { maxBars = value; },
|
||||||
|
getCurrentBars: () => currentBars,
|
||||||
|
setCurrentBars: (value: number) => { currentBars = value; },
|
||||||
|
getTimeSignature: () => timeSignature,
|
||||||
|
setTimeSignature: (value: { numerator: number; denominator: number }) => { timeSignature = value; },
|
||||||
|
getBarWidthMultiplier: () => 1,
|
||||||
|
getTracks: () => [audioTrack],
|
||||||
|
getGlobalTracks: () => createDefaultGlobalTracks(),
|
||||||
|
getBpm: () => bpm,
|
||||||
|
setBpm: (value: number) => { bpm = value; },
|
||||||
|
getKeySignature: () => keySignature,
|
||||||
|
setKeySignature: (value: string) => { keySignature = value; },
|
||||||
|
getSelectedMode: () => selectedMode,
|
||||||
|
setSelectedMode: (value: string) => { selectedMode = value; },
|
||||||
|
getIsLooping: () => false,
|
||||||
|
getIsMetronomeEnabled: () => false,
|
||||||
|
setIsMetronomeEnabled: vi.fn(),
|
||||||
|
getShowGlobalTracks: () => false,
|
||||||
|
setShowGlobalTracks: vi.fn(),
|
||||||
|
getLoopingRange: () => [0, 0] as [number, number],
|
||||||
|
getPianoRollZoom: () => 1,
|
||||||
|
};
|
||||||
|
currentProject = project as unknown as typeof mockProject;
|
||||||
|
mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute());
|
||||||
|
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().setBpm(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(bpm).toBe(60);
|
||||||
|
expect(maxBars).toBe(33);
|
||||||
|
expect(state.maxBars).toBe(33);
|
||||||
|
expect(audioRegion.getLength()).toBeCloseTo(8);
|
||||||
|
expect(getAudioRegionDisplayLengthBeats(project as any, audioRegion)).toBeCloseTo(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-shrink max bars after BPM increase', async () => {
|
||||||
|
const { KGAudioTrack: TestAudioTrack } = await import('../core/track/KGAudioTrack');
|
||||||
|
const { KGAudioRegion: TestAudioRegion } = await import('../core/region/KGAudioRegion');
|
||||||
|
|
||||||
|
const audioTrack = new TestAudioTrack('Audio 1', 1);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
audioTrack.setRegions([
|
||||||
|
new TestAudioRegion('audio-region-1', '1', 0, 'clip.wav', 124, 8, 'audio-file-1.wav', 'clip.wav', 4, 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let projectName = 'Test Project';
|
||||||
|
let bpm = 120;
|
||||||
|
let maxBars = 40;
|
||||||
|
let currentBars = 0;
|
||||||
|
let timeSignature = { numerator: 4, denominator: 4 };
|
||||||
|
let keySignature = 'C major';
|
||||||
|
let selectedMode = 'major';
|
||||||
|
const project = {
|
||||||
|
getName: () => projectName,
|
||||||
|
setName: (value: string) => { projectName = value; },
|
||||||
|
getMaxBars: () => maxBars,
|
||||||
|
setMaxBars: (value: number) => { maxBars = value; },
|
||||||
|
getCurrentBars: () => currentBars,
|
||||||
|
setCurrentBars: (value: number) => { currentBars = value; },
|
||||||
|
getTimeSignature: () => timeSignature,
|
||||||
|
setTimeSignature: (value: { numerator: number; denominator: number }) => { timeSignature = value; },
|
||||||
|
getBarWidthMultiplier: () => 1,
|
||||||
|
getTracks: () => [audioTrack],
|
||||||
|
getGlobalTracks: () => createDefaultGlobalTracks(),
|
||||||
|
getBpm: () => bpm,
|
||||||
|
setBpm: (value: number) => { bpm = value; },
|
||||||
|
getKeySignature: () => keySignature,
|
||||||
|
setKeySignature: (value: string) => { keySignature = value; },
|
||||||
|
getSelectedMode: () => selectedMode,
|
||||||
|
setSelectedMode: (value: string) => { selectedMode = value; },
|
||||||
|
getIsLooping: () => false,
|
||||||
|
getIsMetronomeEnabled: () => false,
|
||||||
|
setIsMetronomeEnabled: vi.fn(),
|
||||||
|
getShowGlobalTracks: () => false,
|
||||||
|
setShowGlobalTracks: vi.fn(),
|
||||||
|
getLoopingRange: () => [0, 0] as [number, number],
|
||||||
|
getPianoRollZoom: () => 1,
|
||||||
|
};
|
||||||
|
currentProject = project as unknown as typeof mockProject;
|
||||||
|
mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute());
|
||||||
|
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().setBpm(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(bpm).toBe(180);
|
||||||
|
expect(maxBars).toBe(40);
|
||||||
|
expect(state.maxBars).toBe(40);
|
||||||
|
expect((audioTrack.getRegions()[0] as KGAudioRegion).getLength()).toBeCloseTo(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the playhead to the song end when max bars shrink past it', async () => {
|
||||||
|
const { KGProject } = await import('../core/KGProject');
|
||||||
|
mockPlayheadPosition = 124;
|
||||||
|
|
||||||
|
currentProject = new KGProject('Test Project', 32, 0, 120) as unknown as typeof mockProject;
|
||||||
|
mockCore.executeCommand.mockImplementation((command: { execute: () => void }) => command.execute());
|
||||||
|
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().setMaxBars(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(currentProject.getMaxBars()).toBe(16);
|
||||||
|
expect(mockPlayheadPosition).toBe(64);
|
||||||
|
expect(mockCore.setPlayheadPosition).toHaveBeenCalledWith(64);
|
||||||
|
expect(state.maxBars).toBe(16);
|
||||||
|
expect(state.playheadPosition).toBe(64);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1553,8 +1553,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const command = new ChangeProjectPropertyCommand({ bpm });
|
const command = new ChangeProjectPropertyCommand({ bpm });
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
|
|
||||||
// Update the store state
|
get().refreshProjectState();
|
||||||
set({ bpm });
|
|
||||||
get().bumpAudioWaveformRedrawVersion();
|
get().bumpAudioWaveformRedrawVersion();
|
||||||
|
|
||||||
console.log(`Set BPM to ${bpm}`);
|
console.log(`Set BPM to ${bpm}`);
|
||||||
@@ -1570,10 +1569,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
const command = new ChangeProjectPropertyCommand({ maxBars });
|
const command = new ChangeProjectPropertyCommand({ maxBars });
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
|
|
||||||
// Update the store state
|
get().refreshProjectState();
|
||||||
set({ maxBars });
|
|
||||||
// Sync CSS var so layout adjusts immediately
|
|
||||||
updateMaxBarsCSS(maxBars);
|
|
||||||
|
|
||||||
console.log(`Set max bars to ${maxBars}`);
|
console.log(`Set max bars to ${maxBars}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1973,6 +1969,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
selectedMode: project.getSelectedMode(),
|
selectedMode: project.getSelectedMode(),
|
||||||
isMetronomeEnabled: project.getIsMetronomeEnabled(),
|
isMetronomeEnabled: project.getIsMetronomeEnabled(),
|
||||||
showGlobalTracks: project.getShowGlobalTracks(),
|
showGlobalTracks: project.getShowGlobalTracks(),
|
||||||
|
playheadPosition: core.getPlayheadPosition(),
|
||||||
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
|
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { KGProject } from '../core/KGProject';
|
|||||||
import { GlobalTrackType } from '../core/global-track';
|
import { GlobalTrackType } from '../core/global-track';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||||
import { beatRangeToSeconds, beatToSeconds, getAudioRegionDisplayLengthBeats, getEffectiveBpmAtBeat, normalizeTempoRegionsForProject, secondsToBeat } from './globalTrackUtil';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
|
import { beatRangeToSeconds, beatToSeconds, getAudioRegionDisplayLengthBeats, getEffectiveBpmAtBeat, getRequiredMaxBarsForAudioRegions, normalizeTempoRegionsForProject, secondsToBeat, syncAudioRegionLengthsToPlaybackDuration } from './globalTrackUtil';
|
||||||
|
|
||||||
describe('globalTrackUtil tempo helpers', () => {
|
describe('globalTrackUtil tempo helpers', () => {
|
||||||
it('falls back to project bpm when no tempo regions exist', () => {
|
it('falls back to project bpm when no tempo regions exist', () => {
|
||||||
@@ -72,4 +73,73 @@ describe('globalTrackUtil tempo helpers', () => {
|
|||||||
const region = new KGAudioRegion('audio', 'track-1', 0, 'Audio', 0, 48, 'file', 'file.wav', 24, 0);
|
const region = new KGAudioRegion('audio', 'track-1', 0, 'Audio', 0, 48, 'file', 'file.wav', 24, 0);
|
||||||
expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(32);
|
expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(32);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('computes required bars for a slower project BPM change', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 60);
|
||||||
|
const track = new KGAudioTrack('Audio', 1);
|
||||||
|
track.setRegions([
|
||||||
|
new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0),
|
||||||
|
]);
|
||||||
|
project.setTracks([track]);
|
||||||
|
|
||||||
|
expect(getRequiredMaxBarsForAudioRegions(project)).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes required bars from playable audio after clip offset', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 60);
|
||||||
|
const track = new KGAudioTrack('Audio', 1);
|
||||||
|
track.setRegions([
|
||||||
|
new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 10, 6),
|
||||||
|
]);
|
||||||
|
project.setTracks([track]);
|
||||||
|
|
||||||
|
expect(getRequiredMaxBarsForAudioRegions(project)).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the farthest audio region end when multiple regions are present', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 60);
|
||||||
|
const track = new KGAudioTrack('Audio', 1);
|
||||||
|
track.setRegions([
|
||||||
|
new KGAudioRegion('audio-a', '1', 0, 'Audio A', 24, 4, 'file-a', 'a.wav', 4, 0),
|
||||||
|
new KGAudioRegion('audio-b', '1', 0, 'Audio B', 28, 4, 'file-b', 'b.wav', 12, 0),
|
||||||
|
]);
|
||||||
|
project.setTracks([track]);
|
||||||
|
|
||||||
|
expect(getRequiredMaxBarsForAudioRegions(project)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends beyond the current song end using the trailing tempo region BPM', () => {
|
||||||
|
const project = new KGProject('Tempo', 8, 0, 120);
|
||||||
|
const tempoTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoTrack.setRegions([
|
||||||
|
new KGTempoRegion('a', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 4, 4),
|
||||||
|
new KGTempoRegion('b', tempoTrack.getId(), tempoTrack.getTrackIndex(), 60, 4, 4, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const track = new KGAudioTrack('Audio', 1);
|
||||||
|
track.setRegions([
|
||||||
|
new KGAudioRegion('audio', '1', 0, 'Audio', 28, 4, 'file', 'file.wav', 8, 0),
|
||||||
|
]);
|
||||||
|
project.setTracks([track]);
|
||||||
|
|
||||||
|
expect(getRequiredMaxBarsForAudioRegions(project)).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs stored audio region length to the tempo-aware playback duration', () => {
|
||||||
|
const project = new KGProject('Tempo', 16, 0, 120);
|
||||||
|
const track = new KGAudioTrack('Audio', 1);
|
||||||
|
const region = new KGAudioRegion('audio', '1', 0, 'Audio', 0, 24, 'file', 'file.wav', 12, 0);
|
||||||
|
track.setRegions([region]);
|
||||||
|
project.setTracks([track]);
|
||||||
|
|
||||||
|
project.setBpm(240);
|
||||||
|
syncAudioRegionLengthsToPlaybackDuration(project);
|
||||||
|
|
||||||
|
expect(region.getLength()).toBeCloseTo(48);
|
||||||
|
expect(getAudioRegionDisplayLengthBeats(project, region)).toBeCloseTo(48);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
|||||||
|
|
||||||
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
|
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
|
||||||
|
|
||||||
|
export interface AudioRegionLengthSnapshot {
|
||||||
|
region: KGAudioRegion;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureDefaultGlobalTracks(project: KGProject): KGGlobalTrack[] {
|
export function ensureDefaultGlobalTracks(project: KGProject): KGGlobalTrack[] {
|
||||||
const existingTracks = project.getGlobalTracks?.() ?? [];
|
const existingTracks = project.getGlobalTracks?.() ?? [];
|
||||||
const nextTracks = createDefaultGlobalTracks();
|
const nextTracks = createDefaultGlobalTracks();
|
||||||
@@ -360,8 +365,76 @@ export function getAudioRegionDisplayLengthBeats(project: KGProject, region: KGA
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startBeat = region.getStartFromBeat();
|
const startBeat = region.getStartFromBeat();
|
||||||
const beatBoundSeconds = beatRangeToSeconds(project, startBeat, startBeat + region.getLength());
|
|
||||||
const availableAudioSeconds = Math.max(0, region.getAudioDurationSeconds() - region.getClipStartOffsetSeconds());
|
const availableAudioSeconds = Math.max(0, region.getAudioDurationSeconds() - region.getClipStartOffsetSeconds());
|
||||||
const visibleSeconds = Math.min(beatBoundSeconds, availableAudioSeconds);
|
const endBeat = getAudioRegionPlaybackEndBeat(project, region);
|
||||||
return Math.max(0, secondsToBeat(project, beatToSeconds(project, startBeat) + visibleSeconds) - startBeat);
|
return Math.max(0, endBeat - startBeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAudioRegionPlaybackEndBeat(project: KGProject, region: KGAudioRegion): number {
|
||||||
|
const startBeat = region.getStartFromBeat();
|
||||||
|
const availableAudioSeconds = Math.max(0, region.getAudioDurationSeconds() - region.getClipStartOffsetSeconds());
|
||||||
|
if (availableAudioSeconds <= 0) {
|
||||||
|
return startBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSeconds = beatToSeconds(project, startBeat) + availableAudioSeconds;
|
||||||
|
const songEndBeat = getSongEndBeat(project);
|
||||||
|
const songEndSeconds = beatToSeconds(project, songEndBeat);
|
||||||
|
|
||||||
|
if (targetSeconds <= songEndSeconds) {
|
||||||
|
return secondsToBeat(project, targetSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailBpm = getEffectiveBpmAtBeat(project, Math.max(0, songEndBeat - 1e-6));
|
||||||
|
return songEndBeat + ((targetSeconds - songEndSeconds) / (60 / tailBpm));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredMaxBarsForAudioRegions(project: KGProject): number {
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
let requiredBars = project.getMaxBars();
|
||||||
|
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (!(region instanceof KGAudioRegion)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionEndBeat = getAudioRegionPlaybackEndBeat(project, region);
|
||||||
|
const regionRequiredBars = Math.ceil(regionEndBeat / beatsPerBar);
|
||||||
|
requiredBars = Math.max(requiredBars, regionRequiredBars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return requiredBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncAudioRegionLengthsToPlaybackDuration(project: KGProject): AudioRegionLengthSnapshot[] {
|
||||||
|
const changedRegions: AudioRegionLengthSnapshot[] = [];
|
||||||
|
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
for (const region of track.getRegions()) {
|
||||||
|
if (!(region instanceof KGAudioRegion)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLength = Math.max(0, getAudioRegionPlaybackEndBeat(project, region) - region.getStartFromBeat());
|
||||||
|
if (region.getLength() === nextLength) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changedRegions.push({
|
||||||
|
region,
|
||||||
|
length: region.getLength(),
|
||||||
|
});
|
||||||
|
region.setLength(nextLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedRegions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreAudioRegionLengths(snapshots: AudioRegionLengthSnapshot[]): void {
|
||||||
|
snapshots.forEach(({ region, length }) => {
|
||||||
|
region.setLength(length);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user