initial public release.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { CreateNotesCommand } from '../../core/commands/note/CreateNotesCommand';
|
||||
import type { NoteCreationData } from '../../core/commands/note/CreateNotesCommand';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Tool for adding notes to MIDI regions
|
||||
* Integrates with the existing command system for undo/redo support
|
||||
*/
|
||||
export class AddNotesTool extends BaseTool {
|
||||
readonly name = 'add_notes';
|
||||
readonly description = 'Create one or more MIDI notes in the current region. Each note requires pitch (e.g., "C4", "F#3"), start_beat (beat position), and length (duration in beats).';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
notes: {
|
||||
type: 'array',
|
||||
description: 'Array of notes to create',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A MIDI note definition',
|
||||
properties: {
|
||||
pitch: {
|
||||
type: 'string',
|
||||
description: 'Note pitch in scientific notation (e.g., "C4", "F#3", "Bb2")',
|
||||
required: true
|
||||
},
|
||||
start_beat: {
|
||||
type: 'number',
|
||||
description: 'Start position in beats (e.g., 0, 1.5, 2)',
|
||||
required: true
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Note duration in beats (e.g., 1, 0.5, 4)',
|
||||
required: true
|
||||
},
|
||||
velocity: {
|
||||
type: 'number',
|
||||
description: 'Note velocity (1-127, default: 127)',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
region_id: {
|
||||
type: 'string',
|
||||
description: 'ID of the region to add notes to. If not provided, uses the currently selected region.',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const notes = params.notes as Array<{
|
||||
pitch: string;
|
||||
start_beat: number;
|
||||
length: number;
|
||||
velocity?: number;
|
||||
}>;
|
||||
|
||||
const regionId = params.region_id as string | undefined;
|
||||
|
||||
// Find the target region
|
||||
const targetRegion = this.findTargetRegion(regionId);
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
regionId
|
||||
? `Region with ID "${regionId}" not found or is not a MIDI region`
|
||||
: 'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate and convert notes to creation data
|
||||
const noteCreationData: NoteCreationData[] = [];
|
||||
const createdNotes: Array<{ pitch: string; start_beat: number; length: number }> = [];
|
||||
|
||||
for (const note of notes) {
|
||||
try {
|
||||
const midiPitch = this.convertPitchToMidi(note.pitch);
|
||||
const velocity = note.velocity ?? 127;
|
||||
|
||||
// Validate velocity range
|
||||
if (velocity < 1 || velocity > 127) {
|
||||
return this.createErrorResult(`Invalid velocity ${velocity}. Must be between 1 and 127.`);
|
||||
}
|
||||
|
||||
// Validate beat positions
|
||||
if (note.start_beat < 0) {
|
||||
return this.createErrorResult(`Invalid start_beat ${note.start_beat}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
if (note.length <= 0) {
|
||||
return this.createErrorResult(`Invalid length ${note.length}. Must be > 0.`);
|
||||
}
|
||||
|
||||
// Adjust note position relative to region's start beat
|
||||
const regionStartBeat = targetRegion.getStartFromBeat();
|
||||
const adjustedStartBeat = note.start_beat - regionStartBeat;
|
||||
const adjustedEndBeat = adjustedStartBeat + note.length;
|
||||
|
||||
// Create note creation data
|
||||
noteCreationData.push({
|
||||
regionId: targetRegion.getId(),
|
||||
startBeat: adjustedStartBeat,
|
||||
endBeat: adjustedEndBeat,
|
||||
pitch: midiPitch,
|
||||
velocity
|
||||
});
|
||||
|
||||
createdNotes.push({
|
||||
pitch: note.pitch,
|
||||
start_beat: note.start_beat,
|
||||
length: note.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Invalid note pitch "${note.pitch}": ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the bulk note creation command
|
||||
const command = new CreateNotesCommand(noteCreationData);
|
||||
await this.executeCommand(command);
|
||||
|
||||
// Create success message
|
||||
const noteCount = createdNotes.length;
|
||||
const noteList = createdNotes
|
||||
.map(note => `${note.pitch} (beat ${note.start_beat}, length ${note.length})`)
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully created ${noteCount} note${noteCount > 1 ? 's' : ''}: ${noteList}`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to create notes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the target region for note creation
|
||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||
*/
|
||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
if (regionId) {
|
||||
// Find specific region by ID
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Smart region finding: try different sources in priority order
|
||||
|
||||
// 1. Try active piano roll region
|
||||
const storeState = useProjectStore.getState();
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try selected regions
|
||||
const core = this.getKGCore();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No fallback - return null to trigger error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance for selection access
|
||||
*/
|
||||
private getKGCore() {
|
||||
return KGCore.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pitch string to MIDI note number
|
||||
* Supports formats like: C4, F#3, Bb2, C#5
|
||||
*/
|
||||
private convertPitchToMidi(pitch: string): number {
|
||||
const match = pitch.match(/^([A-G])([#b]?)(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid pitch format "${pitch}". Use format like "C4", "F#3", "Bb2"`);
|
||||
}
|
||||
|
||||
const [, noteName, accidental, octaveStr] = match;
|
||||
const octave = parseInt(octaveStr);
|
||||
|
||||
// Base MIDI notes for C octave (C4 = 60)
|
||||
const noteOffsets: Record<string, number> = {
|
||||
'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11
|
||||
};
|
||||
|
||||
let midiNote = (octave + 1) * 12 + noteOffsets[noteName];
|
||||
|
||||
// Apply accidentals
|
||||
if (accidental === '#') {
|
||||
midiNote += 1;
|
||||
} else if (accidental === 'b') {
|
||||
midiNote -= 1;
|
||||
}
|
||||
|
||||
// Validate MIDI range
|
||||
if (midiNote < 0 || midiNote > 127) {
|
||||
throw new Error(`Note "${pitch}" is out of MIDI range (0-127)`);
|
||||
}
|
||||
|
||||
return midiNote;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { AgentCore } from '../core/AgentCore';
|
||||
|
||||
/**
|
||||
* Tool for signaling task completion
|
||||
* This is a pure agent state tool that doesn't modify the DAW but signals
|
||||
* to the agent system that the user's requested task has been completed
|
||||
*/
|
||||
export class AttemptCompletionTool extends BaseTool {
|
||||
readonly name = 'attempt_completion';
|
||||
readonly description = 'Signal that the current user task is fully complete. Only use this when you have successfully fulfilled all aspects of the user\'s request.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
comment: {
|
||||
type: 'string',
|
||||
description: 'A brief comment describing what was completed and any relevant details about the task fulfillment.',
|
||||
required: true
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const comment = params.comment as string;
|
||||
|
||||
// Validate comment is not empty
|
||||
if (!comment.trim()) {
|
||||
return this.createErrorResult('Comment cannot be empty. Please provide a meaningful completion summary.');
|
||||
}
|
||||
|
||||
// Get current agent state and update task completion status
|
||||
const agentCore = AgentCore.instance();
|
||||
const agentState = agentCore.getAgentState();
|
||||
|
||||
// Mark that we're no longer working on a task
|
||||
agentState.setIsWorkingOnTask(false);
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Task completed: ${comment}. Agent task status updated to not working.`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to mark task as complete: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { KGCommand } from '../../core/commands/KGCommand';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Result of tool execution
|
||||
*/
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
result: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter definition for tool parameters
|
||||
*/
|
||||
export interface ToolParameter {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
description: string;
|
||||
required?: boolean;
|
||||
items?: ToolParameter; // For array types
|
||||
properties?: Record<string, ToolParameter>; // For object types
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definition schema
|
||||
*/
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, ToolParameter>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for all agent tools
|
||||
* Provides integration with the existing command system and core architecture
|
||||
*/
|
||||
export abstract class BaseTool {
|
||||
abstract readonly name: string;
|
||||
abstract readonly description: string;
|
||||
abstract readonly parameters: Record<string, ToolParameter>;
|
||||
|
||||
/**
|
||||
* Execute the tool with given parameters
|
||||
* @param params Tool parameters
|
||||
* @returns Promise resolving to tool execution result
|
||||
*/
|
||||
abstract execute(params: Record<string, unknown>): Promise<ToolResult>;
|
||||
|
||||
/**
|
||||
* Get the tool definition in OpenAI function calling format
|
||||
*/
|
||||
getDefinition(): ToolDefinition {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.parameters
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parameters against the tool's parameter schema
|
||||
* @param params Parameters to validate
|
||||
* @returns True if valid, throws error if invalid
|
||||
*/
|
||||
protected validateParameters(params: Record<string, unknown>): boolean {
|
||||
for (const [paramName, paramDef] of Object.entries(this.parameters)) {
|
||||
const value = params[paramName];
|
||||
|
||||
// Check required parameters
|
||||
if (paramDef.required && (value === undefined || value === null)) {
|
||||
throw new Error(`Required parameter '${paramName}' is missing`);
|
||||
}
|
||||
|
||||
// Skip type checking for undefined optional parameters
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (!this.validateParameterType(value, paramDef)) {
|
||||
throw new Error(`Parameter '${paramName}' has invalid type. Expected: ${paramDef.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single parameter value against its type definition
|
||||
*/
|
||||
private validateParameterType(value: unknown, paramDef: ToolParameter): boolean {
|
||||
switch (paramDef.type) {
|
||||
case 'string':
|
||||
return typeof value === 'string';
|
||||
case 'number':
|
||||
return typeof value === 'number';
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean';
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (paramDef.items) {
|
||||
return value.every(item => this.validateParameterType(item, paramDef.items!));
|
||||
}
|
||||
return true;
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
if (paramDef.properties) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const [propName, propDef] of Object.entries(paramDef.properties)) {
|
||||
if (propDef.required && !(propName in obj)) {
|
||||
return false;
|
||||
}
|
||||
if (propName in obj && !this.validateParameterType(obj[propName], propDef)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command through the existing command system
|
||||
* This provides undo/redo functionality and proper state management
|
||||
* @param command Command to execute
|
||||
*/
|
||||
protected async executeCommand(command: KGCommand): Promise<void> {
|
||||
const core = KGCore.instance();
|
||||
return core.executeCommand(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current project from KGCore
|
||||
*/
|
||||
protected getCurrentProject() {
|
||||
const core = KGCore.instance();
|
||||
return core.getCurrentProject();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful tool result
|
||||
*/
|
||||
protected createSuccessResult(result: string): ToolResult {
|
||||
return {
|
||||
success: true,
|
||||
result
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed tool result
|
||||
*/
|
||||
protected createErrorResult(result: string): ToolResult {
|
||||
return {
|
||||
success: false,
|
||||
result
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
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 { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGRegion } from '../../core/region/KGRegion';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* 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 the music content from a specific track or all tracks, returning the content in ABC notation format.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'The track ID to read, or "all" to read all tracks. If not provided, reads the first available track.',
|
||||
required: false
|
||||
},
|
||||
start_beat: {
|
||||
type: 'number',
|
||||
description: 'Start beat position to read from (default: 0)',
|
||||
required: false
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Length in beats to read (default: entire track/project)',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
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_beat 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_beat
|
||||
if (startBeat < 0) {
|
||||
return this.createErrorResult(`Invalid start_beat ${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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance
|
||||
*/
|
||||
private getKGCore(): KGCore {
|
||||
return KGCore.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the track that contains the active piano roll region or first selected region
|
||||
*/
|
||||
private findTrackToSkip(tracks: KGMidiTrack[]): KGMidiTrack | null {
|
||||
try {
|
||||
const store = useProjectStore.getState();
|
||||
const core = this.getKGCore();
|
||||
|
||||
// First check for active piano roll region
|
||||
if (store.activeRegionId) {
|
||||
const activeRegion = this.findRegionById(store.activeRegionId, tracks);
|
||||
if (activeRegion) {
|
||||
const track = this.findTrackByRegion(activeRegion, tracks);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for selected regions
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find((item: unknown) => item instanceof KGRegion) as KGRegion;
|
||||
|
||||
if (selectedRegion) {
|
||||
const track = this.findTrackByRegion(selectedRegion, tracks);
|
||||
return track;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error finding track to skip:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a region by ID across all tracks
|
||||
*/
|
||||
private findRegionById(regionId: string, tracks: KGMidiTrack[]): KGRegion | null {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find track that contains the given region
|
||||
*/
|
||||
private findTrackByRegion(region: KGRegion, tracks: KGMidiTrack[]): KGMidiTrack | null {
|
||||
return tracks.find(track => track.getRegions().includes(region)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the track to skip (unless it's the first track)
|
||||
const trackToSkip = this.findTrackToSkip(midiTracks);
|
||||
const firstTrack = midiTracks[0]; // The melody 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';
|
||||
|
||||
let output = `All Tracks (beats ${startBeat}-${endBeat || 'end'}):\n\n`;
|
||||
|
||||
midiTracks.forEach((track, index) => {
|
||||
// Skip this track if it's the track to skip AND it's not the first track (melody)
|
||||
if (trackToSkip && track === trackToSkip && track !== firstTrack) {
|
||||
return; // Skip this track
|
||||
}
|
||||
const trackNumber = index + 1;
|
||||
const trackName = track.getName() || `Track ${trackNumber}`;
|
||||
|
||||
// hardcode the 1st track to be the melody, other track names are the same as the original track names
|
||||
output += `Track ${trackNumber} - ${trackNumber === 1 ? 'Melody' : trackName}:\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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { DeleteNotesCommand } from '../../core/commands/note/DeleteNotesCommand';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
/**
|
||||
* Tool for removing notes from MIDI regions within a specified beat range
|
||||
* Integrates with the existing command system for undo/redo support
|
||||
*/
|
||||
export class RemoveNotesTool extends BaseTool {
|
||||
readonly name = 'remove_notes';
|
||||
readonly description = 'Remove MIDI notes from the current region within a specified beat range. All notes that start within the range will be deleted.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start_beat: {
|
||||
type: 'number',
|
||||
description: 'Start of the beat range to remove notes from (inclusive)',
|
||||
required: true
|
||||
},
|
||||
end_beat: {
|
||||
type: 'number',
|
||||
description: 'End of the beat range to remove notes from (exclusive)',
|
||||
required: true
|
||||
},
|
||||
region_id: {
|
||||
type: 'string',
|
||||
description: 'ID of the region to remove notes from. If not provided, uses the currently selected region.',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start_beat as number;
|
||||
const endBeat = params.end_beat as number;
|
||||
const regionId = params.region_id as string | undefined;
|
||||
|
||||
// Validate beat range
|
||||
if (startBeat < 0) {
|
||||
return this.createErrorResult(`Invalid start_beat ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
if (endBeat <= startBeat) {
|
||||
return this.createErrorResult(`Invalid beat range: end_beat (${endBeat}) must be greater than start_beat (${startBeat}).`);
|
||||
}
|
||||
|
||||
// Find the target region
|
||||
const targetRegion = this.findTargetRegion(regionId);
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
regionId
|
||||
? `Region with ID "${regionId}" not found or is not a MIDI region`
|
||||
: 'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust beat range relative to region's start beat
|
||||
const regionStartBeat = targetRegion.getStartFromBeat();
|
||||
const adjustedStartBeat = startBeat - regionStartBeat;
|
||||
const adjustedEndBeat = endBeat - regionStartBeat;
|
||||
|
||||
// Find all notes within the specified beat range
|
||||
const notesToRemove = this.findNotesInRange(targetRegion, adjustedStartBeat, adjustedEndBeat);
|
||||
|
||||
if (notesToRemove.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No notes found in the range from beat ${startBeat} to ${endBeat}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract note IDs for deletion
|
||||
const noteIds = notesToRemove.map(note => note.getId());
|
||||
|
||||
// Execute the deletion command
|
||||
const command = new DeleteNotesCommand(noteIds);
|
||||
await this.executeCommand(command);
|
||||
|
||||
// Create success message
|
||||
const noteCount = notesToRemove.length;
|
||||
const noteList = notesToRemove
|
||||
.map(note => {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(note.getPitch() / 12) - 1;
|
||||
const noteName = noteNames[note.getPitch() % 12];
|
||||
return `${noteName}${octave}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${noteCount} note${noteCount > 1 ? 's' : ''} from beats ${startBeat}-${endBeat}: ${noteList}`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove notes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the target region for note removal
|
||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||
*/
|
||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
if (regionId) {
|
||||
// Find specific region by ID
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Smart region finding: try different sources in priority order
|
||||
|
||||
// 1. Try active piano roll region
|
||||
const storeState = useProjectStore.getState();
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try selected regions
|
||||
const core = this.getKGCore();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No fallback - return null to trigger error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance for selection access
|
||||
*/
|
||||
private getKGCore() {
|
||||
return KGCore.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notes within the specified beat range
|
||||
* Notes are included if their start beat is within [startBeat, endBeat)
|
||||
*/
|
||||
private findNotesInRange(region: KGMidiRegion, startBeat: number, endBeat: number) {
|
||||
const notes = region.getNotes();
|
||||
return notes.filter(note => {
|
||||
const noteStartBeat = note.getStartBeat();
|
||||
return noteStartBeat >= startBeat && noteStartBeat < endBeat;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
|
||||
/**
|
||||
* Pseudo tool for handling <think> tags in LLM responses
|
||||
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
|
||||
* Handles XML format: <think>any content here</think>
|
||||
* This is functionally identical to ThinkingTool but handles the shorter tag name
|
||||
*/
|
||||
export class ThinkTool extends BaseTool {
|
||||
readonly name = 'think';
|
||||
readonly description = 'Pseudo tool for handling LLM thinking content from <think> tags. Shows content in UI but does not send results back to LLM.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The thinking content from the XML tag',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Extract the thinking content from the parameters
|
||||
// const content = params.content as string || '';
|
||||
|
||||
// Return the thinking content as a successful result
|
||||
// This will be displayed in the UI but not sent back to the LLM
|
||||
return this.createSuccessResult("Thinking completed.");
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to process thinking content: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
|
||||
/**
|
||||
* Pseudo tool for handling <thinking> tags in LLM responses
|
||||
* This tool displays the thinking content in the UI but doesn't send results back to the LLM
|
||||
* Handles XML format: <thinking>any content here</thinking>
|
||||
*/
|
||||
export class ThinkingTool extends BaseTool {
|
||||
readonly name = 'thinking';
|
||||
readonly description = 'Pseudo tool for handling LLM thinking content. Shows content in UI but does not send results back to LLM.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The thinking content from the XML tag',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Extract the thinking content from the parameters
|
||||
// const content = params.content as string || '';
|
||||
|
||||
// Return the thinking content as a successful result
|
||||
// This will be displayed in the UI but not sent back to the LLM
|
||||
return this.createSuccessResult("Thinking completed.");
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to process thinking content: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Base tool system
|
||||
export { BaseTool } from './BaseTool';
|
||||
export type { ToolResult, ToolParameter, ToolDefinition } from './BaseTool';
|
||||
|
||||
// Specific tools
|
||||
import { AddNotesTool } from './AddNotesTool';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { AttemptCompletionTool } from './AttemptCompletionTool';
|
||||
import { ThinkingTool } from './ThinkingTool';
|
||||
import { ThinkTool } from './ThinkTool';
|
||||
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, AttemptCompletionTool, ThinkingTool, ThinkTool };
|
||||
|
||||
// Tool registry for easy access
|
||||
export const AVAILABLE_TOOLS = {
|
||||
add_notes: AddNotesTool,
|
||||
remove_notes: RemoveNotesTool,
|
||||
read_music: ReadMusicTool,
|
||||
attempt_completion: AttemptCompletionTool,
|
||||
thinking: ThinkingTool,
|
||||
think: ThinkTool
|
||||
} as const;
|
||||
|
||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||
Reference in New Issue
Block a user