feat: add audio chord detection for spectrogram regions

This commit is contained in:
Xiaohan-Tian
2026-05-25 19:09:19 -07:00
parent a7158b1ee0
commit f12add935a
15 changed files with 931 additions and 8 deletions
@@ -6,6 +6,7 @@ import { KGChordRegion } from '../../region/KGChordRegion';
import { CreateChordRegionCommand } from './CreateChordRegionCommand';
import { InsertChordRegionAtBeatCommand } from './InsertChordRegionAtBeatCommand';
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ReplaceChordRegionsInRangeCommand } from './ReplaceChordRegionsInRangeCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
@@ -104,4 +105,46 @@ describe('global chord region commands', () => {
expect(chordTrack.getRegions()).toHaveLength(1);
expect(region.getLength()).toBe(8);
});
it('replaces only the requested chord span and restores the original layout on undo', () => {
const chordTrack = getChordTrack();
chordTrack.setRegions([
new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
new KGChordRegion('middle', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 4, 4),
new KGChordRegion('right', chordTrack.getId(), chordTrack.getTrackIndex(), 'F', 8, 4),
]);
const command = new ReplaceChordRegionsInRangeCommand(2, 10, [
{ startBeat: 2, length: 2, symbol: 'Dm' },
{ startBeat: 4, length: 4, symbol: 'E' },
{ startBeat: 8, length: 2, symbol: 'Am' },
]);
command.execute();
const replacedRegions = getChordTrack().getRegions() as KGChordRegion[];
expect(replacedRegions.map(region => ({
symbol: region.getSymbol(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ symbol: 'C', start: 0, length: 2 },
{ symbol: 'Dm', start: 2, length: 2 },
{ symbol: 'E', start: 4, length: 4 },
{ symbol: 'Am', start: 8, length: 2 },
{ symbol: 'F', start: 10, length: 2 },
]);
command.undo();
const restoredRegions = getChordTrack().getRegions() as KGChordRegion[];
expect(restoredRegions.map(region => ({
symbol: region.getSymbol(),
start: region.getStartFromBeat(),
length: region.getLength(),
}))).toEqual([
{ symbol: 'C', start: 0, length: 4 },
{ symbol: 'Am', start: 4, length: 4 },
{ symbol: 'F', start: 8, length: 4 },
]);
});
});
@@ -0,0 +1,132 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
import { generateUniqueId } from '../../../util/miscUtil';
export interface ChordRegionReplacementData {
startBeat: number;
length: number;
symbol: string;
}
function cloneChordRegion(region: KGChordRegion): KGChordRegion {
return new KGChordRegion(
region.getId(),
region.getTrackId(),
region.getTrackIndex(),
region.getSymbol(),
region.getStartFromBeat(),
region.getLength(),
);
}
function cloneChordRegions(regions: KGChordRegion[]): KGChordRegion[] {
return regions.map(cloneChordRegion);
}
export class ReplaceChordRegionsInRangeCommand extends KGCommand {
private readonly rangeStartBeat: number;
private readonly rangeEndBeat: number;
private readonly replacements: ChordRegionReplacementData[];
private originalRegions: KGChordRegion[] | null = null;
private nextRegions: KGChordRegion[] | null = null;
constructor(rangeStartBeat: number, rangeEndBeat: number, replacements: ChordRegionReplacementData[]) {
super();
this.rangeStartBeat = Math.max(0, rangeStartBeat);
this.rangeEndBeat = Math.max(this.rangeStartBeat, rangeEndBeat);
this.replacements = replacements.map(replacement => ({
startBeat: replacement.startBeat,
length: replacement.length,
symbol: replacement.symbol,
}));
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found');
}
if (this.nextRegions) {
chordTrack.setRegions(cloneChordRegions(this.nextRegions));
return;
}
const currentRegions = chordTrack.getRegions()
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
this.originalRegions = cloneChordRegions(currentRegions);
const preservedRegions: KGChordRegion[] = [];
for (const region of currentRegions) {
const regionStart = region.getStartFromBeat();
const regionEnd = regionStart + region.getLength();
if (regionEnd <= this.rangeStartBeat || regionStart >= this.rangeEndBeat) {
preservedRegions.push(cloneChordRegion(region));
continue;
}
if (regionStart < this.rangeStartBeat) {
preservedRegions.push(new KGChordRegion(
region.getId(),
region.getTrackId(),
region.getTrackIndex(),
region.getSymbol(),
regionStart,
this.rangeStartBeat - regionStart,
));
}
if (regionEnd > this.rangeEndBeat) {
preservedRegions.push(new KGChordRegion(
generateUniqueId('KGChordRegion'),
region.getTrackId(),
region.getTrackIndex(),
region.getSymbol(),
this.rangeEndBeat,
regionEnd - this.rangeEndBeat,
));
}
}
const replacementRegions = this.replacements
.filter(replacement => replacement.length > 0 && replacement.symbol.trim() !== '')
.map(replacement => new KGChordRegion(
generateUniqueId('KGChordRegion'),
chordTrack.getId(),
chordTrack.getTrackIndex(),
replacement.symbol,
replacement.startBeat,
replacement.length,
));
this.nextRegions = [...preservedRegions, ...replacementRegions]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
chordTrack.setRegions(cloneChordRegions(this.nextRegions));
}
undo(): void {
if (!this.originalRegions) {
throw new Error('Cannot undo chord replacement without original regions');
}
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found during undo');
}
chordTrack.setRegions(cloneChordRegions(this.originalRegions));
}
getDescription(): string {
return 'Replace chord regions in range';
}
}
+4
View File
@@ -39,6 +39,10 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateChordRegionCommand } from './global-region/CreateChordRegionCommand';
export { InsertChordRegionAtBeatCommand } from './global-region/InsertChordRegionAtBeatCommand';
export {
ReplaceChordRegionsInRangeCommand,
type ChordRegionReplacementData,
} from './global-region/ReplaceChordRegionsInRangeCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';