Files
KGStudio/src/agent/tools/ReadMusicTool.ts
T

307 lines
11 KiB
TypeScript

import { BaseTool } from './BaseTool';
import type { ToolResult, ToolParameter } from './BaseTool';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { convertRegionToABCNotation } from '../../util/abcNotationUtil';
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
/**
* Tool for reading music content from the project
* Provides read-only access to project data and converts to ABC notation
*/
export class ReadMusicTool extends BaseTool {
readonly name = 'read_music';
readonly description = 'Read existing musical content from one or more tracks, returned as ABC notation. Use this to understand what notes already exist before making edits. Always call this before asking the user about their music. The output is bar-aligned and includes key/time signature headers.';
readonly parameters: Record<string, ToolParameter> = {
track_id: {
type: 'string',
description: 'Which track to read. Pass a specific track ID, or "all" to read every track. If omitted, reads the first available track.',
required: false
},
start: {
type: 'number',
description: 'Start beat — the absolute beat position to start reading from. The actual output will be rounded down to the nearest bar boundary. Defaults to 0.',
required: false
},
length: {
type: 'number',
description: 'Number of beats to read. The actual output will be rounded up to the nearest bar boundary. If omitted, reads to the end of the track.',
required: false
}
};
/**
* Get the display name for percussion instruments, or null if not percussion
*/
private getPercussionDisplayName(track: KGMidiTrack): string | null {
try {
const instrument = track.getInstrument();
const instrumentInfo = FLUIDR3_INSTRUMENT_MAP[instrument];
if (instrumentInfo && instrumentInfo.group === 'PERCUSSION_KIT') {
return instrumentInfo.displayName;
}
return null;
} catch (error) {
console.error('Error getting percussion display name:', error);
return null;
}
}
async execute(params: Record<string, unknown>): Promise<ToolResult> {
try {
// Validate parameters
this.validateParameters(params);
const trackId = params.track_id as string | undefined;
const startBeat = (params.start as number) || 0;
const length = params.length as number | undefined;
const project = this.getCurrentProject();
const tracks = project.getTracks();
if (tracks.length === 0) {
return this.createErrorResult('No tracks found in the project');
}
// Validate start
if (startBeat < 0) {
return this.createErrorResult(`Invalid start ${startBeat}. Must be >= 0.`);
}
// Validate length
if (length !== undefined && length <= 0) {
return this.createErrorResult(`Invalid length ${length}. Must be > 0.`);
}
// Get project settings for bar rounding
const timeSignature = project.getTimeSignature();
const beatsPerBar = timeSignature.numerator;
// Round startBeat to floor bar beats and calculate endBeat
const roundedStartBeat = Math.floor(startBeat / beatsPerBar) * beatsPerBar;
const rawEndBeat = length !== undefined ? startBeat + length : undefined;
const roundedEndBeat = rawEndBeat !== undefined ? Math.ceil(rawEndBeat / beatsPerBar) * beatsPerBar : undefined;
let abcOutput = '';
if (!trackId || trackId === '' || trackId === 'all') {
// Read all tracks
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack) as KGMidiTrack[];
abcOutput = this.generateAllTracksABC(midiTracks, roundedStartBeat, roundedEndBeat);
} else {
// Read specific track or first available track
const targetTrack = trackId
? tracks.find(t => t.getId().toString() === trackId)
: tracks[0];
if (!targetTrack) {
return this.createErrorResult(
trackId
? `Track with ID "${trackId}" not found`
: 'No tracks available'
);
}
if (!(targetTrack instanceof KGMidiTrack)) {
return this.createErrorResult(`Track "${targetTrack.getName()}" is not a MIDI track`);
}
abcOutput = this.generateSingleTrackABC(targetTrack, roundedStartBeat, roundedEndBeat);
}
return this.createSuccessResult(abcOutput);
} catch (error) {
return this.createErrorResult(`Failed to read music: ${error}`);
}
}
/**
* Find tracks that should be skipped because they have no musical content
* (no regions or regions with no notes)
*/
private findTracksToSkip(tracks: KGMidiTrack[]): KGMidiTrack[] {
try {
const tracksToSkip: KGMidiTrack[] = [];
for (const track of tracks) {
const regions = track.getRegions();
// Skip tracks with no regions
if (regions.length === 0) {
tracksToSkip.push(track);
continue;
}
// Check if all regions in this track are empty (have no notes)
const hasAnyNotes = regions.some(region => {
if (region.getCurrentType() === 'KGMidiRegion') {
return (region as KGMidiRegion).getNotes().length > 0;
}
return false;
});
// Skip tracks where no regions have notes
if (!hasAnyNotes) {
tracksToSkip.push(track);
}
}
return tracksToSkip;
} catch (error) {
console.error('Error finding tracks to skip:', error);
return [];
}
}
/**
* Generate ABC notation for all tracks
*/
private generateAllTracksABC(tracks: KGMidiTrack[], startBeat: number, endBeat?: number): string {
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack);
if (midiTracks.length === 0) {
return 'No MIDI tracks found in the project.';
}
// Find tracks to skip (tracks with no content)
const tracksToSkip = this.findTracksToSkip(midiTracks);
// Get project settings for proper notation
const project = this.getCurrentProject();
const timeSignature = project.getTimeSignature();
const keySignature = project.getKeySignature();
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
let output = `All Tracks (beats ${startBeat}-${endBeat || 'end'}):\n\n`;
midiTracks.forEach((track, index) => {
// Skip tracks that have no musical content
if (tracksToSkip.includes(track)) {
return; // Skip this track
}
const trackNumber = index + 1;
const trackName = track.getName() || `Track ${trackNumber}`;
// Check if this track uses a percussion instrument
const percussionDisplayName = this.getPercussionDisplayName(track);
let displayTrackName: string;
if (percussionDisplayName) {
// Use percussion instrument display name for all percussion tracks
displayTrackName = percussionDisplayName;
} else if (trackNumber === 1) {
// Use "Melody" for the first non-percussion track
displayTrackName = 'Melody';
} else {
// Use original track name for other non-percussion tracks
displayTrackName = trackName;
}
output += `Track ${trackNumber} - ${displayTrackName}:\n`;
// Get all regions from the track and convert each one
const regions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
if (regions.length === 0) {
output += 'X:' + trackNumber + '\n';
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
output += `K:${abcKeySignature}\n`;
output += 'z4 | // No regions found\n\n';
} else {
// Convert each region that overlaps with the requested range
let hasContent = false;
regions.forEach((region) => {
const regionStart = region.getStartFromBeat();
const regionEnd = regionStart + region.getLength();
// Check if region overlaps with requested range
if (regionStart < (endBeat || Infinity) && regionEnd > startBeat) {
const abcNotation = convertRegionToABCNotation(region, startBeat, endBeat);
// Update the X: line to include track number
const lines = abcNotation.split('\n');
lines[0] = `X:${trackNumber}`;
output += lines.join('\n') + '\n\n';
hasContent = true;
}
});
if (!hasContent) {
output += 'X:' + trackNumber + '\n';
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
output += `K:${abcKeySignature}\n`;
output += 'z4 | // No content in specified range\n\n';
}
}
});
return output.trim();
}
/**
* Generate ABC notation for a single track
*/
private generateSingleTrackABC(track: KGMidiTrack, startBeat: number, endBeat?: number): string {
if (!(track instanceof KGMidiTrack)) {
return `Track is not a MIDI track.`;
}
// Get project settings for proper notation
const project = this.getCurrentProject();
const timeSignature = project.getTimeSignature();
const keySignature = project.getKeySignature();
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
const trackName = track.getName() || 'Unnamed Track';
let output = `Track "${trackName}" (beats ${startBeat}-${endBeat || 'end'}):\n`;
// Get all regions from the track and convert each one
const regions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
if (regions.length === 0) {
output += 'X:1\n';
output += `T:${trackName}\n`;
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
output += `K:${abcKeySignature}\n`;
output += `L:1/${timeSignature.denominator}\n`;
output += 'z4 | // No regions found';
} else {
// Convert each region that overlaps with the requested range
let hasContent = false;
regions.forEach((region) => {
const regionStart = region.getStartFromBeat();
const regionEnd = regionStart + region.getLength();
// Check if region overlaps with requested range
if (regionStart < (endBeat || Infinity) && regionEnd > startBeat) {
const abcNotation = convertRegionToABCNotation(region, startBeat, endBeat);
// Update the title to include track name
const lines = abcNotation.split('\n');
lines[1] = `T:${trackName}`;
output += lines.join('\n');
hasContent = true;
}
});
if (!hasContent) {
output += 'X:1\n';
output += `T:${trackName}\n`;
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
output += `K:${abcKeySignature}\n`;
output += `L:1/${timeSignature.denominator}\n`;
output += 'z4 | // No content in specified range';
}
}
return output;
}
}