feat: add read_chord_progression tool for the Agent
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReadChordProgressionTool } from './ReadChordProgressionTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
const storeState = {
|
||||
activeRegionId: null as string | null,
|
||||
};
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => storeState,
|
||||
},
|
||||
}));
|
||||
|
||||
function buildProjectWithRegionAndOptionalChords(chords: string[] = []): {
|
||||
project: KGProject;
|
||||
midiRegion: KGMidiRegion;
|
||||
} {
|
||||
const project = new KGProject('tool-test', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const midiTrack = new KGMidiTrack('Melody', 1);
|
||||
const midiRegion = new KGMidiRegion('midi-region-1', '1', 0, 'Melody Region', 0, 32);
|
||||
midiTrack.addRegion(midiRegion);
|
||||
project.setTracks([midiTrack]);
|
||||
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(chordTrack).not.toBeNull();
|
||||
|
||||
chords.forEach((symbol, index) => {
|
||||
chordTrack!.addRegion(new KGChordRegion(`chord-${index}`, chordTrack!.getId(), chordTrack!.getTrackIndex(), symbol, index * 4, 4));
|
||||
});
|
||||
|
||||
return { project, midiRegion };
|
||||
}
|
||||
|
||||
describe('ReadChordProgressionTool', () => {
|
||||
beforeEach(() => {
|
||||
storeState.activeRegionId = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads chord progression from the active MIDI region', async () => {
|
||||
const { project, midiRegion } = buildProjectWithRegionAndOptionalChords(['Am', 'F', 'Dm', 'E7', 'Am', 'C', 'Dm', 'E7']);
|
||||
storeState.activeRegionId = midiRegion.getId();
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadChordProgressionTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('Chord-symbol representation:');
|
||||
expect(result.result).toContain('[Am]4 | [F]4 | [Dm]4 | [E7]4 | [Am]4 | [C]4 | [Dm]4 | [E7]4 |');
|
||||
expect(result.result).toContain('[A, C E]4 | [F, A, C]4 | [D F A]4 | [E ^G B d]4 | [A, C E]4 | [C E G]4 | [D F A]4 | [E ^G B d]4 |');
|
||||
});
|
||||
|
||||
it('falls back to the selected MIDI region when no active region exists', async () => {
|
||||
const { project, midiRegion } = buildProjectWithRegionAndOptionalChords(['Am']);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [midiRegion],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadChordProgressionTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('[Am]4 |');
|
||||
});
|
||||
|
||||
it('returns guidance when no chord progression is defined for the region range', async () => {
|
||||
const { project, midiRegion } = buildProjectWithRegionAndOptionalChords();
|
||||
storeState.activeRegionId = midiRegion.getId();
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadChordProgressionTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('No chord progression is defined for the selected MIDI region range.');
|
||||
expect(result.result).toContain('read_music');
|
||||
});
|
||||
|
||||
it('returns a clear error when no active or selected MIDI region exists', async () => {
|
||||
const { project } = buildProjectWithRegionAndOptionalChords(['Am']);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadChordProgressionTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('No active or selected MIDI region found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { convertBeatRangeChordProgressionToABCNotation } from '../../util/abcNotationUtil';
|
||||
|
||||
/**
|
||||
* Tool for reading user-defined chord progression content from the global chord track.
|
||||
*/
|
||||
export class ReadChordProgressionTool extends BaseTool {
|
||||
readonly name = 'read_chord_progression';
|
||||
readonly description = 'Read the user-defined chord progression for the currently active or selected MIDI region. The output has two representations of the same progression: first symbolic chord names such as Em7b5, then note-based ABC chord tokens. Chord progression data comes only from chord regions the user defined on the global chord track, so it may be empty. If no chord progression is defined for this range, read the notes directly with read_music.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const targetRegion = this.findTargetRegion();
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
}
|
||||
|
||||
const project = this.getCurrentProject();
|
||||
const startBeat = targetRegion.getStartFromBeat();
|
||||
const endBeat = startBeat + targetRegion.getLength();
|
||||
const result = convertBeatRangeChordProgressionToABCNotation(project, startBeat, endBeat);
|
||||
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read chord progression: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private findTargetRegion(): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
const storeState = useProjectStore.getState();
|
||||
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === storeState.activeRegionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedItems = KGCore.instance().getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@ export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, O
|
||||
import { AddNotesTool } from './AddNotesTool';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { ReadChordProgressionTool } from './ReadChordProgressionTool';
|
||||
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool };
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, ReadChordProgressionTool };
|
||||
|
||||
// Tool registry for easy access
|
||||
export const AVAILABLE_TOOLS = {
|
||||
add_notes: AddNotesTool,
|
||||
remove_notes: RemoveNotesTool,
|
||||
read_music: ReadMusicTool,
|
||||
read_chord_progression: ReadChordProgressionTool,
|
||||
} as const;
|
||||
|
||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||
|
||||
+72
-2
@@ -5,7 +5,7 @@
|
||||
|
||||
import { KGCore } from './KGCore';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { convertRegionToABCNotation } from '../util/abcNotationUtil';
|
||||
import { convertRegionToABCNotation, convertBeatRangeChordProgressionToABCNotation } from '../util/abcNotationUtil';
|
||||
import { extractXMLFromString } from '../util/xmlUtil';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { AVAILABLE_TOOLS } from '../agent/tools';
|
||||
@@ -23,6 +23,7 @@ export class KGDebugger {
|
||||
private constructor() {
|
||||
console.log("🔧 KGDebugger initialized - Available methods:", [
|
||||
'convertSelectedRegionToABCNotation(startFromBeat?)',
|
||||
'convertSelectedRegionChordProgressionToABCNotation()',
|
||||
'testQuantizeDuration(durationBeats, timeSignature?)',
|
||||
'debugSelectedItems()',
|
||||
'createTestRegion()',
|
||||
@@ -128,6 +129,74 @@ export class KGDebugger {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the chord progression that overlaps the currently selected MIDI region to ABC notation
|
||||
*/
|
||||
public convertSelectedRegionChordProgressionToABCNotation(): void {
|
||||
const core = KGCore.instance();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
|
||||
let midiRegion: KGMidiRegion | null = null;
|
||||
|
||||
if (selectedItems.length > 0) {
|
||||
midiRegion = selectedItems.find(item => item.getCurrentType() === 'KGMidiRegion') as KGMidiRegion;
|
||||
}
|
||||
|
||||
if (!midiRegion) {
|
||||
const storeState = useProjectStore.getState();
|
||||
const activeRegionId = storeState.activeRegionId;
|
||||
const tracks = storeState.tracks;
|
||||
|
||||
if (activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === activeRegionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
midiRegion = region;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!midiRegion) {
|
||||
console.error('❌ No MIDI region found.');
|
||||
console.log('💡 Try one of these:');
|
||||
console.log(' • Select a region in the track grid');
|
||||
console.log(' • Open a region in the piano roll editor');
|
||||
return;
|
||||
}
|
||||
|
||||
const startBeat = midiRegion.getStartFromBeat();
|
||||
const endBeat = startBeat + midiRegion.getLength();
|
||||
|
||||
console.log(`🎼 Reading chord progression for region: "${midiRegion.getName()}"`);
|
||||
console.log(`📍 Range: beats ${startBeat}-${endBeat}`);
|
||||
|
||||
try {
|
||||
const output = convertBeatRangeChordProgressionToABCNotation(
|
||||
KGCore.instance().getCurrentProject(),
|
||||
startBeat,
|
||||
endBeat,
|
||||
);
|
||||
|
||||
console.log('✅ Chord progression conversion successful!');
|
||||
console.log('📄 Result:');
|
||||
console.log('─'.repeat(50));
|
||||
console.log(output);
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(output).then(() => {
|
||||
console.log('📋 Chord progression notation copied to clipboard!');
|
||||
}).catch(() => {
|
||||
console.log('📋 Could not copy to clipboard (requires HTTPS)');
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error converting chord progression:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the quantization duration method with a specific duration
|
||||
* @param durationBeats - Duration in beats to test
|
||||
@@ -342,6 +411,7 @@ export class KGDebugger {
|
||||
console.log("🔧 KGDebugger Help");
|
||||
console.log("Available methods:");
|
||||
console.log(" convertSelectedRegionToABCNotation(startFromBeat?) - Convert selected region to ABC");
|
||||
console.log(" convertSelectedRegionChordProgressionToABCNotation() - Convert selected region chord progression to dual ABC views");
|
||||
console.log(" testQuantizeDuration(beats, timeSignature?) - Test quantization logic");
|
||||
console.log(" debugSelectedItems() - Show info about selected items");
|
||||
console.log(" createTestRegion() - Create test region (not implemented)");
|
||||
@@ -879,4 +949,4 @@ export class KGDebugger {
|
||||
console.error(`opfs: rm: ${name}: No such file or directory`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { convertRegionToABCNotation } from '../../../util/abcNotationUtil';
|
||||
import {
|
||||
convertRegionToABCNotation,
|
||||
convertBeatRangeChordProgressionToABCNotation,
|
||||
formatChordProgressionNoteLine,
|
||||
formatChordProgressionSymbolLine,
|
||||
getChordProgressionSegmentsForBeatRange,
|
||||
} from '../../../util/abcNotationUtil';
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||
import { KGChordRegion } from '../../../core/region/KGChordRegion';
|
||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import { KGProject } from '../../../core/KGProject';
|
||||
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../../core/global-track';
|
||||
|
||||
// Import the test fixture
|
||||
import joyProjectData from '../../fixtures/joy-project.json';
|
||||
@@ -222,4 +231,49 @@ E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2
|
||||
expect(musicLine).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chord progression formatting', () => {
|
||||
it('formats the exact 8-bar progression in both symbolic and note-based representations', () => {
|
||||
const project = new KGProject('chords', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const midiTrack = new KGMidiTrack('Melody', 1);
|
||||
const midiRegion = new KGMidiRegion('midi-region-1', '1', 0, 'Melody Region', 0, 32);
|
||||
midiTrack.addRegion(midiRegion);
|
||||
project.setTracks([midiTrack]);
|
||||
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(chordTrack).not.toBeNull();
|
||||
|
||||
const chords = ['Am', 'F', 'Dm', 'E7', 'Am', 'C', 'Dm', 'E7'];
|
||||
chords.forEach((symbol, index) => {
|
||||
chordTrack!.addRegion(new KGChordRegion(`chord-${index}`, chordTrack!.getId(), chordTrack!.getTrackIndex(), symbol, index * 4, 4));
|
||||
});
|
||||
|
||||
const segments = getChordProgressionSegmentsForBeatRange(project, 0, 32);
|
||||
|
||||
expect(formatChordProgressionSymbolLine(segments, project.getTimeSignature())).toBe(
|
||||
'[Am]4 | [F]4 | [Dm]4 | [E7]4 | [Am]4 | [C]4 | [Dm]4 | [E7]4 |'
|
||||
);
|
||||
expect(formatChordProgressionNoteLine(segments, project.getTimeSignature())).toBe(
|
||||
'[A, C E]4 | [F, A, C]4 | [D F A]4 | [E ^G B d]4 | [A, C E]4 | [C E G]4 | [D F A]4 | [E ^G B d]4 |'
|
||||
);
|
||||
|
||||
const output = convertBeatRangeChordProgressionToABCNotation(project, 0, 32);
|
||||
expect(output).toContain('M:4/4');
|
||||
expect(output).toContain('L:1/4');
|
||||
expect(output).toContain('Q:1/4=120');
|
||||
expect(output).toContain('K:C');
|
||||
expect(output).not.toContain('X:');
|
||||
expect(output).not.toContain('T:');
|
||||
});
|
||||
|
||||
it('formats accidentals with ABC prefix notation in note-based chord output', () => {
|
||||
const project = new KGProject('accidentals', 1, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(chordTrack).not.toBeNull();
|
||||
chordTrack!.addRegion(new KGChordRegion('chord-1', chordTrack!.getId(), chordTrack!.getTrackIndex(), 'E7', 0, 4));
|
||||
|
||||
const segments = getChordProgressionSegmentsForBeatRange(project, 0, 4);
|
||||
expect(formatChordProgressionNoteLine(segments, project.getTimeSignature())).toBe('[E ^G B d]4 |');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+205
-2
@@ -7,10 +7,14 @@ import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGProject } from '../core/KGProject';
|
||||
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||
import { pitchToNoteName } from './midiUtil';
|
||||
import { beatsToTicks, getTicksPerBar, reduceFraction } from './mathUtil';
|
||||
import type { TimeSignature } from '../types/projectTypes';
|
||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
||||
import { getEffectiveBpmAtBeat, getEffectiveKeySignatureAtBeat, findGlobalTrackByType } from './globalTrackUtil';
|
||||
import { GlobalTrackType } from '../core/global-track';
|
||||
|
||||
// MIDI timing constants
|
||||
const TICKS_PER_QUARTER_NOTE = 480;
|
||||
@@ -26,6 +30,12 @@ interface ABCNote {
|
||||
tieWithNext: boolean; // Whether this note should be tied to the next note
|
||||
}
|
||||
|
||||
export interface ChordProgressionSegment {
|
||||
symbol: string;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
}
|
||||
|
||||
// Define valid quantization fraction types
|
||||
// type QuantizationFraction = '1/1' | '1/2' | '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32'; // future
|
||||
type QuantizationFraction = '1/1' | '1/2' | '1/4' | '1/8' | '1/16';
|
||||
@@ -58,7 +68,9 @@ function midiPitchToABCNote(pitch: number): string {
|
||||
// C5 is "c", C6 is "c'", C7 is "c''"
|
||||
// C3 is "C,", C2 is "C,," etc.
|
||||
|
||||
const baseNote = note.replace('#', '^'); // Convert sharp to ABC sharp notation
|
||||
const accidental = note.includes('#') ? '^' : note.includes('b') ? '_' : '';
|
||||
const naturalNote = note.replace('#', '').replace('b', '');
|
||||
const baseNote = `${accidental}${naturalNote}`;
|
||||
|
||||
if (octave >= 4) {
|
||||
if (octave === 4) {
|
||||
@@ -177,6 +189,197 @@ function formatABCHeader(region: KGMidiRegion, project: KGProject): string {
|
||||
return header.join('\n');
|
||||
}
|
||||
|
||||
function formatABCSharedHeader(project: KGProject, beat: number): string {
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const bpm = getEffectiveBpmAtBeat(project, beat);
|
||||
const keySignature = getEffectiveKeySignatureAtBeat(project, beat);
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
|
||||
return [
|
||||
`M:${timeSignature.numerator}/${timeSignature.denominator}`,
|
||||
`L:1/${timeSignature.denominator}`,
|
||||
`Q:1/${timeSignature.denominator}=${bpm}`,
|
||||
`K:${abcKeySignature}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getChordRootMidi(symbol: string): number | null {
|
||||
const descriptor = parseChordSymbol(symbol);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootToPitchClass: Record<string, number> = {
|
||||
C: 0,
|
||||
'C#': 1,
|
||||
Db: 1,
|
||||
D: 2,
|
||||
'D#': 3,
|
||||
Eb: 3,
|
||||
E: 4,
|
||||
F: 5,
|
||||
'F#': 6,
|
||||
Gb: 6,
|
||||
G: 7,
|
||||
'G#': 8,
|
||||
Ab: 8,
|
||||
A: 9,
|
||||
'A#': 10,
|
||||
Bb: 10,
|
||||
B: 11,
|
||||
};
|
||||
|
||||
const pitchClass = rootToPitchClass[descriptor.root];
|
||||
if (pitchClass === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const middleCOctaveMidi = 60 + pitchClass;
|
||||
return middleCOctaveMidi > 64 ? middleCOctaveMidi - 12 : middleCOctaveMidi;
|
||||
}
|
||||
|
||||
function formatBeatsToABCLength(lengthBeats: number, timeSignature: TimeSignature): string {
|
||||
const ticks = beatsToTicks(lengthBeats, timeSignature);
|
||||
return convertTicksToABCLength(ticks, timeSignature);
|
||||
}
|
||||
|
||||
function splitSegmentAtBarBoundaries(
|
||||
startBeat: number,
|
||||
endBeat: number,
|
||||
beatsPerBar: number,
|
||||
): Array<{ startBeat: number; endBeat: number }> {
|
||||
const segments: Array<{ startBeat: number; endBeat: number }> = [];
|
||||
let currentStart = startBeat;
|
||||
|
||||
while (currentStart < endBeat) {
|
||||
const nextBarBeat = Math.floor(currentStart / beatsPerBar + 1) * beatsPerBar;
|
||||
const currentEnd = Math.min(endBeat, nextBarBeat);
|
||||
segments.push({ startBeat: currentStart, endBeat: currentEnd });
|
||||
currentStart = currentEnd;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function formatTimedChordTokens(
|
||||
values: string[],
|
||||
segments: ChordProgressionSegment[],
|
||||
timeSignature: TimeSignature,
|
||||
): string {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const tokens: string[] = [];
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const splitSegments = splitSegmentAtBarBoundaries(segment.startBeat, segment.endBeat, beatsPerBar);
|
||||
splitSegments.forEach((part) => {
|
||||
const length = formatBeatsToABCLength(part.endBeat - part.startBeat, timeSignature);
|
||||
tokens.push(`[${values[index]}]${length}`);
|
||||
tokens.push('|');
|
||||
});
|
||||
});
|
||||
|
||||
return tokens.join(' ');
|
||||
}
|
||||
|
||||
export function getChordProgressionSegmentsForBeatRange(
|
||||
project: KGProject,
|
||||
startBeat: number,
|
||||
endBeat: number,
|
||||
): ChordProgressionSegment[] {
|
||||
if (endBeat <= startBeat) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chordTrack.getRegions()
|
||||
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
|
||||
.map((region) => ({
|
||||
region,
|
||||
startBeat: Math.max(startBeat, region.getStartFromBeat()),
|
||||
endBeat: Math.min(endBeat, region.getStartFromBeat() + region.getLength()),
|
||||
}))
|
||||
.filter(({ startBeat: segmentStart, endBeat: segmentEnd }) => segmentEnd > segmentStart)
|
||||
.sort((left, right) => left.startBeat - right.startBeat)
|
||||
.map(({ region, startBeat: segmentStart, endBeat: segmentEnd }) => ({
|
||||
symbol: region.getSymbol(),
|
||||
startBeat: segmentStart,
|
||||
endBeat: segmentEnd,
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatChordProgressionSymbolLine(
|
||||
segments: ChordProgressionSegment[],
|
||||
timeSignature: TimeSignature,
|
||||
): string {
|
||||
return formatTimedChordTokens(
|
||||
segments.map(segment => segment.symbol),
|
||||
segments,
|
||||
timeSignature,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatChordProgressionNoteLine(
|
||||
segments: ChordProgressionSegment[],
|
||||
timeSignature: TimeSignature,
|
||||
): string {
|
||||
const values = segments.map((segment) => {
|
||||
const rootMidi = getChordRootMidi(segment.symbol);
|
||||
if (rootMidi === null) {
|
||||
throw new Error(`Unable to parse chord "${segment.symbol}"`);
|
||||
}
|
||||
|
||||
const pitches = getChordMidiPitches(segment.symbol, rootMidi);
|
||||
if (pitches.length === 0) {
|
||||
throw new Error(`Unable to parse chord "${segment.symbol}"`);
|
||||
}
|
||||
|
||||
return pitches.map(midiPitchToABCNote).join(' ');
|
||||
});
|
||||
|
||||
return formatTimedChordTokens(values, segments, timeSignature);
|
||||
}
|
||||
|
||||
export function convertBeatRangeChordProgressionToABCNotation(
|
||||
project: KGProject,
|
||||
startBeat: number,
|
||||
endBeat: number,
|
||||
): string {
|
||||
const segments = getChordProgressionSegmentsForBeatRange(project, startBeat, endBeat);
|
||||
const header = formatABCSharedHeader(project, startBeat);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return [
|
||||
'Selected Region Chord Progression',
|
||||
'This progression comes only from user-defined chord regions on the global chord track. If no chord progression is defined for this range, read the notes directly with `read_music`.',
|
||||
'',
|
||||
header,
|
||||
'',
|
||||
'No chord progression is defined for the selected MIDI region range. Use `read_music` to inspect the notes directly.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const chordSymbols = formatChordProgressionSymbolLine(segments, timeSignature);
|
||||
const chordNotes = formatChordProgressionNoteLine(segments, timeSignature);
|
||||
|
||||
return [
|
||||
'Selected Region Chord Progression',
|
||||
'This progression comes only from user-defined chord regions on the global chord track. Representation 1 uses symbolic chord names such as `Em7b5`. Representation 2 rewrites the same progression as note-based ABC chord tokens.',
|
||||
'',
|
||||
header,
|
||||
'',
|
||||
'Chord-symbol representation:',
|
||||
chordSymbols,
|
||||
'',
|
||||
'Note-based ABC chord representation:',
|
||||
chordNotes,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ABC notation body with notes
|
||||
* @param notes - Array of MIDI notes to convert
|
||||
@@ -433,4 +636,4 @@ export function convertRegionToABCNotation(region: KGMidiRegion, startFromBeat:
|
||||
const body = formatABCBody(filteredNotes, relativeStartBeat, timeSignature);
|
||||
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user