initial public release.
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* ABC Notation conversion utilities for KGSP
|
||||
* Converts MIDI regions to ABC notation format
|
||||
*/
|
||||
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGProject } from '../core/KGProject';
|
||||
import { pitchToNoteName } from './midiUtil';
|
||||
import { beatsToTicks, getTicksPerBar, reduceFraction } from './mathUtil';
|
||||
import type { TimeSignature } from '../types/projectTypes';
|
||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||
|
||||
// MIDI timing constants
|
||||
const TICKS_PER_QUARTER_NOTE = 480;
|
||||
const TICKS_PER_SIXTEENTH_NOTE = TICKS_PER_QUARTER_NOTE / 4; // 120 ticks
|
||||
|
||||
/**
|
||||
* ABC Note data structure for processing
|
||||
*/
|
||||
interface ABCNote {
|
||||
pitch: string[]; // ABC notation pitch array (C, D, E, F, G, A, B, with ^ for sharp, z for rest) - supports polyphonic notes
|
||||
startTick: number; // Start time in MIDI ticks
|
||||
endTick: number; // End time in MIDI ticks
|
||||
tieWithNext: boolean; // Whether this note should be tied to the next note
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
// Standard note duration values for quantization (in ticks relative to quarter note)
|
||||
const QUANTIZATION_TICKS: Record<QuantizationFraction, number> = {
|
||||
'1/1': TICKS_PER_QUARTER_NOTE * 4, // 1920 ticks (whole note)
|
||||
'1/2': TICKS_PER_QUARTER_NOTE * 2, // 960 ticks (half note)
|
||||
// '1/3': TICKS_PER_QUARTER_NOTE * 4 / 3, // 640 ticks (dotted half / triplet whole)
|
||||
'1/4': TICKS_PER_QUARTER_NOTE, // 480 ticks (quarter note)
|
||||
// '1/6': TICKS_PER_QUARTER_NOTE * 2 / 3, // 320 ticks (triplet half)
|
||||
'1/8': TICKS_PER_QUARTER_NOTE / 2, // 240 ticks (eighth note)
|
||||
// '1/12': TICKS_PER_QUARTER_NOTE / 3, // 160 ticks (triplet quarter)
|
||||
'1/16': TICKS_PER_QUARTER_NOTE / 4, // 120 ticks (sixteenth note)
|
||||
// '1/24': TICKS_PER_QUARTER_NOTE / 6, // 80 ticks (triplet eighth)
|
||||
// '1/32': TICKS_PER_QUARTER_NOTE / 8, // 60 ticks (thirty-second note)
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Convert MIDI pitch number to ABC notation
|
||||
* @param pitch - MIDI pitch number (0-127)
|
||||
* @returns ABC notation string (e.g., "C", "c", "c'", "C,")
|
||||
*/
|
||||
function midiPitchToABCNote(pitch: number): string {
|
||||
const { note, octave } = pitchToNoteName(pitch);
|
||||
|
||||
// ABC notation uses different octave conventions:
|
||||
// C4 (middle C) is represented as "C"
|
||||
// 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
|
||||
|
||||
if (octave >= 4) {
|
||||
if (octave === 4) {
|
||||
return baseNote; // C4 -> C
|
||||
} else if (octave === 5) {
|
||||
return baseNote.toLowerCase(); // C5 -> c
|
||||
} else {
|
||||
// C6+ -> c', c'', c'''
|
||||
const apostrophes = "'".repeat(octave - 5);
|
||||
return baseNote.toLowerCase() + apostrophes;
|
||||
}
|
||||
} else {
|
||||
// C3 and below -> C,, C,,, etc.
|
||||
const commas = ",".repeat(4 - octave);
|
||||
return baseNote + commas;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantize note duration in ticks to nearest standard musical grid
|
||||
* @param durationTicks - Duration in MIDI ticks
|
||||
* @returns Quantized duration in ticks
|
||||
*/
|
||||
function quantizeDuration(durationTicks: number): number {
|
||||
let bestMatch: QuantizationFraction = '1/4'; // Default to quarter note
|
||||
let minError = Infinity;
|
||||
|
||||
for (const [fraction, ticksPerUnit] of Object.entries(QUANTIZATION_TICKS)) {
|
||||
const remainder = durationTicks % ticksPerUnit;
|
||||
// Check both remainder and (ticksPerUnit - remainder) to find closest alignment
|
||||
const error = Math.min(remainder, ticksPerUnit - remainder);
|
||||
|
||||
if (error < minError) {
|
||||
minError = error;
|
||||
bestMatch = fraction as QuantizationFraction;
|
||||
}
|
||||
}
|
||||
|
||||
// Quantize to the best grid and return ticks
|
||||
const bestTicksPerUnit = QUANTIZATION_TICKS[bestMatch];
|
||||
const units = Math.round(durationTicks / bestTicksPerUnit);
|
||||
return units * bestTicksPerUnit;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert ticks to ABC notation length string
|
||||
* @param ticks - Duration in MIDI ticks
|
||||
* @param timeSignature - Project time signature for proper length calculation
|
||||
* @returns ABC length string (e.g., "4", "8", "2/3", etc.)
|
||||
*/
|
||||
function convertTicksToABCLength(ticks: number, timeSignature: TimeSignature): string {
|
||||
// ABC notation uses note length relative to the default note length (L: field)
|
||||
// We now set L:1/denominator in the header, so the base unit changes with time signature
|
||||
// For 4/4: L:1/4 means quarter note = length 1
|
||||
// For 6/8: L:1/8 means eighth note = length 1
|
||||
|
||||
const sixteenthNotes = Math.round(ticks / TICKS_PER_SIXTEENTH_NOTE);
|
||||
|
||||
if (sixteenthNotes <= 0) {
|
||||
return "1"; // Minimum length
|
||||
}
|
||||
|
||||
// Convert from sixteenth note units to the time signature's base unit
|
||||
// For 4/4: 16/4 = 4, so quarter note = 4 sixteenth notes
|
||||
// For 6/8: 16/8 = 2, so eighth note = 2 sixteenth notes
|
||||
const baseUnitInSixteenths = 16 / timeSignature.denominator;
|
||||
|
||||
// Calculate the length in terms of the base unit
|
||||
const lengthInBaseUnits = sixteenthNotes / baseUnitInSixteenths;
|
||||
|
||||
// Check if it's a whole number
|
||||
if (Number.isInteger(lengthInBaseUnits)) {
|
||||
return lengthInBaseUnits === 1 ? '' : lengthInBaseUnits.toString();
|
||||
}
|
||||
|
||||
// If not a whole number, express as a fraction
|
||||
// Convert to fraction by finding common denominator
|
||||
const numerator = Math.round(lengthInBaseUnits * baseUnitInSixteenths);
|
||||
const denominator = baseUnitInSixteenths;
|
||||
|
||||
// Reduce fraction to lowest terms using GCD
|
||||
const reduced = reduceFraction(numerator, denominator);
|
||||
|
||||
if (reduced.denominator === 1) {
|
||||
return reduced.numerator.toString();
|
||||
}
|
||||
|
||||
return `${reduced.numerator}/${reduced.denominator}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ABC notation header
|
||||
* @param region - MIDI region to convert
|
||||
* @param project - Project containing tempo and time signature info
|
||||
* @returns ABC header string
|
||||
*/
|
||||
function formatABCHeader(region: KGMidiRegion, project: KGProject): string {
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const bpm = project.getBpm();
|
||||
const keySignature = project.getKeySignature();
|
||||
const regionName = region.getName();
|
||||
|
||||
// Get ABC notation key signature from the key signature map
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
|
||||
const header = [
|
||||
'X:1', // Reference number
|
||||
`T:${regionName}`, // Title
|
||||
`M:${timeSignature.numerator}/${timeSignature.denominator}`, // Time signature
|
||||
`L:1/${timeSignature.denominator}`, // note length unit should be aligned with time signature
|
||||
`Q:1/${timeSignature.denominator}=${bpm}`, // Tempo (quarter note = BPM)
|
||||
`K:${abcKeySignature}` // Key signature from project settings
|
||||
];
|
||||
|
||||
return header.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ABC notation body with notes
|
||||
* @param notes - Array of MIDI notes to convert
|
||||
* @param relativeStartBeat - Start position relative to region
|
||||
* @param timeSignature - Project time signature
|
||||
* @returns ABC body string
|
||||
*/
|
||||
function formatABCBody(notes: KGMidiNote[], relativeStartBeat: number, timeSignature: TimeSignature): string {
|
||||
if (notes.length === 0) {
|
||||
return 'z16 |'; // Rest for one bar if no notes
|
||||
}
|
||||
|
||||
// Calculate ticks per bar based on time signature
|
||||
const ticksPerBar = getTicksPerBar(timeSignature);
|
||||
|
||||
// Step 1: Convert all notes to ABCNote instances
|
||||
const abcNotes: ABCNote[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const startBeats = note.getStartBeat();
|
||||
const endBeats = note.getEndBeat();
|
||||
|
||||
// Convert beats to ticks
|
||||
const startTicks = beatsToTicks(startBeats, timeSignature);
|
||||
const endTicks = beatsToTicks(endBeats, timeSignature);
|
||||
|
||||
// Quantize to closest 1/16 beat (120 ticks)
|
||||
const quantizedStartTicks = Math.round(startTicks / TICKS_PER_SIXTEENTH_NOTE) * TICKS_PER_SIXTEENTH_NOTE;
|
||||
const quantizedEndTicks = Math.round(endTicks / TICKS_PER_SIXTEENTH_NOTE) * TICKS_PER_SIXTEENTH_NOTE;
|
||||
|
||||
// Get ABC pitch notation
|
||||
const abcPitch = midiPitchToABCNote(note.getPitch());
|
||||
|
||||
abcNotes.push({
|
||||
pitch: [abcPitch],
|
||||
startTick: quantizedStartTicks,
|
||||
endTick: quantizedEndTicks,
|
||||
tieWithNext: false
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Sort by startTick, then by endTick
|
||||
abcNotes.sort((a, b) => {
|
||||
if (a.startTick !== b.startTick) {
|
||||
return a.startTick - b.startTick;
|
||||
}
|
||||
return a.endTick - b.endTick;
|
||||
});
|
||||
|
||||
// Step 3: Apply quantizeDuration to find best fit durations
|
||||
for (const abcNote of abcNotes) {
|
||||
const originalDuration = abcNote.endTick - abcNote.startTick;
|
||||
const quantizedDuration = quantizeDuration(originalDuration);
|
||||
abcNote.endTick = abcNote.startTick + quantizedDuration;
|
||||
}
|
||||
|
||||
// Step 4: Sort again after quantization
|
||||
abcNotes.sort((a, b) => {
|
||||
if (a.startTick !== b.startTick) {
|
||||
return a.startTick - b.startTick;
|
||||
}
|
||||
return a.endTick - b.endTick;
|
||||
});
|
||||
|
||||
// Step 5: Handle polyphonic notes and overlapping notes
|
||||
for (let i = 0; i < abcNotes.length - 1; ) {
|
||||
const currentNote = abcNotes[i];
|
||||
let nextIndex = i + 1;
|
||||
|
||||
// First, check for notes with identical startTick and endTick (polyphonic notes)
|
||||
while (nextIndex < abcNotes.length &&
|
||||
currentNote.startTick === abcNotes[nextIndex].startTick &&
|
||||
currentNote.endTick === abcNotes[nextIndex].endTick) {
|
||||
const nextNote = abcNotes[nextIndex];
|
||||
// Merge the pitch into current note's pitch array
|
||||
currentNote.pitch.push(...nextNote.pitch);
|
||||
// Remove the next note since it's now part of the current chord
|
||||
abcNotes.splice(nextIndex, 1);
|
||||
}
|
||||
|
||||
// Then, check if current note overlaps with remaining next notes
|
||||
while (nextIndex < abcNotes.length && currentNote.endTick > abcNotes[nextIndex].startTick) {
|
||||
const nextNote = abcNotes[nextIndex];
|
||||
|
||||
if (currentNote.endTick >= nextNote.endTick) {
|
||||
// Current note completely covers next note - remove next note
|
||||
abcNotes.splice(nextIndex, 1);
|
||||
// Don't increment nextIndex since we removed an element
|
||||
} else {
|
||||
// Truncate current note to avoid overlap
|
||||
currentNote.endTick = nextNote.startTick;
|
||||
nextIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
// Step 6: Insert rests where needed
|
||||
const finalNotes: ABCNote[] = [];
|
||||
const relativeStartTicks = beatsToTicks(relativeStartBeat, timeSignature);
|
||||
|
||||
for (let i = 0; i < abcNotes.length; i++) {
|
||||
const currentNote = abcNotes[i];
|
||||
const prevEndTick = i === 0 ? relativeStartTicks : finalNotes[finalNotes.length - 1].endTick;
|
||||
|
||||
// Add rest if there's a gap
|
||||
if (currentNote.startTick > prevEndTick) {
|
||||
finalNotes.push({
|
||||
pitch: ['z'],
|
||||
startTick: prevEndTick,
|
||||
endTick: currentNote.startTick,
|
||||
tieWithNext: false
|
||||
});
|
||||
}
|
||||
|
||||
finalNotes.push(currentNote);
|
||||
}
|
||||
|
||||
// Step 6.1: Complete the last bar with a rest if needed
|
||||
if (finalNotes.length > 0) {
|
||||
const lastNote = finalNotes[finalNotes.length - 1];
|
||||
const lastNoteEndTick = lastNote.endTick;
|
||||
|
||||
// Check if the last note ends exactly on a bar boundary
|
||||
const ticksFromBarStart = (lastNoteEndTick - relativeStartTicks) % ticksPerBar;
|
||||
|
||||
// If the last note doesn't end on a bar boundary, complete the bar with a rest
|
||||
if (ticksFromBarStart !== 0) {
|
||||
const lastBarIndex = Math.floor((lastNoteEndTick - relativeStartTicks) / ticksPerBar);
|
||||
const lastBarEndTick = relativeStartTicks + ((lastBarIndex + 1) * ticksPerBar);
|
||||
|
||||
finalNotes.push({
|
||||
pitch: ['z'],
|
||||
startTick: lastNoteEndTick,
|
||||
endTick: lastBarEndTick,
|
||||
tieWithNext: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6.5: Split notes that cross bar boundaries
|
||||
const splitNotes: ABCNote[] = [];
|
||||
|
||||
for (const note of finalNotes) {
|
||||
// Find which bar this note starts in
|
||||
const startBarIndex = Math.floor((note.startTick - relativeStartTicks) / ticksPerBar);
|
||||
const endBarIndex = Math.floor((note.endTick - relativeStartTicks - 1) / ticksPerBar); // -1 to handle exact bar boundaries
|
||||
|
||||
if (startBarIndex === endBarIndex) {
|
||||
// Note fits within a single bar
|
||||
splitNotes.push(note);
|
||||
} else {
|
||||
// Note crosses bar boundaries - split it
|
||||
const currentNote = { ...note };
|
||||
|
||||
for (let barIndex = startBarIndex; barIndex <= endBarIndex; barIndex++) {
|
||||
const barStartTick = relativeStartTicks + (barIndex * ticksPerBar);
|
||||
const barEndTick = barStartTick + ticksPerBar;
|
||||
|
||||
const noteStartInBar = Math.max(currentNote.startTick, barStartTick);
|
||||
const noteEndInBar = Math.min(currentNote.endTick, barEndTick);
|
||||
|
||||
const isLastPart = (barIndex === endBarIndex);
|
||||
|
||||
splitNotes.push({
|
||||
pitch: currentNote.pitch,
|
||||
startTick: noteStartInBar,
|
||||
endTick: noteEndInBar,
|
||||
tieWithNext: !isLastPart && currentNote.pitch[0] !== 'z' // Don't tie rests
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Generate ABC notation output with bar lines and ties
|
||||
const abcStrings: string[] = [];
|
||||
|
||||
for (let i = 0; i < splitNotes.length; i++) {
|
||||
const note = splitNotes[i];
|
||||
const duration = note.endTick - note.startTick;
|
||||
const lengthString = convertTicksToABCLength(duration, timeSignature);
|
||||
|
||||
// Build note string with length - handle polyphonic notes
|
||||
let noteString: string;
|
||||
if (note.pitch.length === 1) {
|
||||
// Single note
|
||||
noteString = `${note.pitch[0]}${lengthString}`;
|
||||
} else {
|
||||
// Polyphonic note (chord) - use ABC chord notation [C E G]
|
||||
const chordNotes = note.pitch.join(' ');
|
||||
noteString = `[${chordNotes}]${lengthString}`;
|
||||
}
|
||||
|
||||
// Add tie if needed
|
||||
if (note.tieWithNext) {
|
||||
noteString += '-';
|
||||
}
|
||||
|
||||
abcStrings.push(noteString);
|
||||
|
||||
// Check if we've reached the end of a bar
|
||||
const currentBarIndex = Math.floor((note.startTick - relativeStartTicks) / ticksPerBar);
|
||||
const nextNote = splitNotes[i + 1];
|
||||
|
||||
if (nextNote) {
|
||||
const nextBarIndex = Math.floor((nextNote.startTick - relativeStartTicks) / ticksPerBar);
|
||||
|
||||
// Add bar line if we're moving to a new bar
|
||||
if (nextBarIndex > currentBarIndex) {
|
||||
abcStrings.push('|');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add final bar line
|
||||
abcStrings.push('|');
|
||||
|
||||
return abcStrings.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a MIDI region to ABC notation
|
||||
* @param region - The MIDI region to convert
|
||||
* @param startFromBeat - Absolute beat position to start conversion from
|
||||
* @returns ABC notation as plain text string
|
||||
*/
|
||||
export function convertRegionToABCNotation(region: KGMidiRegion, startFromBeat: number, endBeat?: number): string {
|
||||
// Get project information
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
|
||||
// Round startFromBeat to floor bar beats and endBeat to next bar beats
|
||||
const roundedStartBeat = Math.floor(startFromBeat / beatsPerBar) * beatsPerBar;
|
||||
const roundedEndBeat = endBeat !== undefined ? Math.ceil(endBeat / beatsPerBar) * beatsPerBar : undefined;
|
||||
|
||||
// Convert absolute beats to relative position within the region
|
||||
const relativeStartBeat = roundedStartBeat - region.getStartFromBeat();
|
||||
const relativeEndBeat = roundedEndBeat !== undefined ? roundedEndBeat - region.getStartFromBeat() : undefined;
|
||||
|
||||
// Filter notes based on the rounded range
|
||||
const allNotes = region.getNotes();
|
||||
let filteredNotes = allNotes.filter(note => note.getStartBeat() >= relativeStartBeat);
|
||||
|
||||
// If endBeat is specified, also filter by end range
|
||||
if (relativeEndBeat !== undefined) {
|
||||
filteredNotes = filteredNotes.filter(note => note.getStartBeat() < relativeEndBeat);
|
||||
}
|
||||
|
||||
// Generate ABC notation
|
||||
const header = formatABCHeader(region, project);
|
||||
const body = formatABCBody(filteredNotes, relativeStartBeat, timeSignature);
|
||||
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
|
||||
/**
|
||||
* Clear chat history and reset chat state
|
||||
* This utility can be used from various parts of the application
|
||||
* to ensure consistent chat clearing behavior
|
||||
*/
|
||||
export const clearChatHistory = () => {
|
||||
// Clear agent state
|
||||
const agentCore = AgentCore.instance();
|
||||
agentCore.clearConversation();
|
||||
|
||||
console.log('Chat history cleared programmatically');
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear chat history with status message feedback
|
||||
*/
|
||||
export const clearChatHistoryWithStatus = (setStatus?: (message: string) => void) => {
|
||||
clearChatHistory();
|
||||
|
||||
if (setStatus) {
|
||||
setStatus('Chat history cleared');
|
||||
}
|
||||
};
|
||||
|
||||
// Global callback for clearing UI state
|
||||
let globalClearChatUI: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Register a callback to clear chat UI state
|
||||
* This allows external components to clear the ChatBox UI
|
||||
*/
|
||||
export const registerClearChatUICallback = (callback: () => void) => {
|
||||
globalClearChatUI = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear both chat history and UI state
|
||||
* This is the main function that should be called when starting new/loading projects
|
||||
*/
|
||||
export const clearChatHistoryAndUI = (setStatus?: (statusMessage: string) => void) => {
|
||||
// Clear the data model
|
||||
clearChatHistory();
|
||||
|
||||
// Clear the UI state if callback is registered
|
||||
if (globalClearChatUI) {
|
||||
globalClearChatUI();
|
||||
}
|
||||
|
||||
// Set status message
|
||||
if (setStatus) {
|
||||
setStatus('Chat history cleared');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Utility functions for copy/paste operations
|
||||
* Extracted from useGlobalKeyboardHandler for reuse in toolbar buttons
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles copy operation for currently selected items
|
||||
* @returns {boolean} true if items were copied, false if no items were selected
|
||||
*/
|
||||
export const handleCopyOperation = (): boolean => {
|
||||
const core = KGCore.instance();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
|
||||
if (selectedItems.length > 0) {
|
||||
core.copySelectedItems();
|
||||
console.log(`Copied ${selectedItems.length} items to clipboard`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('No items selected to copy');
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles paste operation based on current context
|
||||
* @returns {boolean} true if items were pasted, false if paste was not possible
|
||||
*/
|
||||
export const handlePasteOperation = (): boolean => {
|
||||
const core = KGCore.instance();
|
||||
const copiedItems = core.getCopiedItems();
|
||||
|
||||
if (copiedItems.length === 0) {
|
||||
console.log('No items in clipboard to paste');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current store state
|
||||
const {
|
||||
selectedTrackId,
|
||||
showPianoRoll,
|
||||
activeRegionId,
|
||||
pasteRegionsAtTrack,
|
||||
pasteNotesToActiveRegion
|
||||
} = useProjectStore.getState();
|
||||
|
||||
// Determine paste context and handle accordingly
|
||||
const hasRegions = copiedItems.some(item => item.getRootType() === 'KGRegion');
|
||||
const hasNotes = copiedItems.some(item => item.getRootType() === 'KGMidiNote');
|
||||
|
||||
if (hasRegions && !hasNotes) {
|
||||
// Pasting regions - need selected track and playhead position
|
||||
if (selectedTrackId) {
|
||||
const playheadPosition = core.getPlayheadPosition();
|
||||
pasteRegionsAtTrack(selectedTrackId, playheadPosition);
|
||||
console.log(`Pasted ${copiedItems.length} regions at track ${selectedTrackId}, position ${playheadPosition}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log('No track selected for pasting regions');
|
||||
return false;
|
||||
}
|
||||
} else if (hasNotes && !hasRegions) {
|
||||
// Pasting notes - only when piano roll is open
|
||||
if (showPianoRoll && activeRegionId) {
|
||||
// Use the active region's playhead position (could be different from global playhead)
|
||||
// For now, we'll use global playhead - this can be refined later
|
||||
const playheadPosition = core.getPlayheadPosition();
|
||||
pasteNotesToActiveRegion(activeRegionId, playheadPosition);
|
||||
console.log(`Pasted ${copiedItems.length} notes to active region ${activeRegionId}, position ${playheadPosition}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Piano roll must be open to paste notes');
|
||||
return false;
|
||||
}
|
||||
} else if (hasRegions && hasNotes) {
|
||||
console.log('Mixed clipboard content (regions + notes) cannot be pasted');
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Mathematical utility functions for KGSP
|
||||
* Contains common mathematical algorithms and calculations
|
||||
*/
|
||||
|
||||
import type { TimeSignature } from '../types/projectTypes';
|
||||
|
||||
// MIDI timing constants
|
||||
const TICKS_PER_QUARTER_NOTE = 480;
|
||||
|
||||
/**
|
||||
* Calculate Greatest Common Divisor (GCD) using Euclidean algorithm
|
||||
* @param a - First number
|
||||
* @param b - Second number
|
||||
* @returns GCD of a and b
|
||||
*/
|
||||
export function gcd(a: number, b: number): number {
|
||||
a = Math.abs(a);
|
||||
b = Math.abs(b);
|
||||
while (b !== 0) {
|
||||
const temp = b;
|
||||
b = a % b;
|
||||
a = temp;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert beats to MIDI ticks based on time signature
|
||||
* @param beats - Duration in beats
|
||||
* @param timeSignature - Project time signature
|
||||
* @returns Duration in MIDI ticks
|
||||
*/
|
||||
export function beatsToTicks(beats: number, timeSignature: TimeSignature): number {
|
||||
// In different time signatures, the beat unit changes:
|
||||
// 4/4: 1 beat = quarter note = 480 ticks
|
||||
// 4/8: 1 beat = eighth note = 240 ticks
|
||||
// 6/8: 1 beat = eighth note = 240 ticks
|
||||
const ticksPerBeat = TICKS_PER_QUARTER_NOTE * (4 / timeSignature.denominator);
|
||||
return Math.round(beats * ticksPerBeat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MIDI ticks to beats based on time signature
|
||||
* @param ticks - Duration in MIDI ticks
|
||||
* @param timeSignature - Project time signature
|
||||
* @returns Duration in beats
|
||||
*/
|
||||
export function ticksToBeats(ticks: number, timeSignature: TimeSignature): number {
|
||||
const ticksPerBeat = TICKS_PER_QUARTER_NOTE * (4 / timeSignature.denominator);
|
||||
return ticks / ticksPerBeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ticks per bar based on time signature
|
||||
* @param timeSignature - Project time signature
|
||||
* @returns Number of ticks in one bar
|
||||
*/
|
||||
export function getTicksPerBar(timeSignature: TimeSignature): number {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const beatUnit = 4 / timeSignature.denominator; // Quarter note units per beat
|
||||
return beatsPerBar * beatUnit * TICKS_PER_QUARTER_NOTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a fraction to its lowest terms using GCD
|
||||
* @param numerator - Fraction numerator
|
||||
* @param denominator - Fraction denominator
|
||||
* @returns Object with reduced numerator and denominator
|
||||
*/
|
||||
export function reduceFraction(numerator: number, denominator: number): { numerator: number; denominator: number } {
|
||||
const commonDivisor = gcd(numerator, denominator);
|
||||
return {
|
||||
numerator: numerator / commonDivisor,
|
||||
denominator: denominator / commonDivisor
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a number is an integer within a small tolerance (for floating point precision)
|
||||
* @param value - Number to check
|
||||
* @param tolerance - Tolerance for floating point comparison (default: 1e-10)
|
||||
* @returns True if the number is effectively an integer
|
||||
*/
|
||||
export function isEffectivelyInteger(value: number, tolerance: number = 1e-10): boolean {
|
||||
return Math.abs(value - Math.round(value)) < tolerance;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { clearChatHistoryAndUI } from '../chatUtil';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { SystemPrompts } from '../../agent/core/SystemPrompts';
|
||||
|
||||
export interface UserMessageFilterResult {
|
||||
// Whether to render the user message bubble (div.message-user)
|
||||
displayUserMessage: boolean;
|
||||
// Whether to send a message to the LLM
|
||||
sendToLLM: boolean;
|
||||
// The final text to send to the LLM (can differ from user input)
|
||||
finalMessageForLLM: string | null;
|
||||
// Optional pseudo assistant response to display immediately (without LLM)
|
||||
pseudoAssistantResponse: string | null;
|
||||
// Placeholder metadata for future extensibility
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a user message before it is displayed or sent to the LLM.
|
||||
* Handles slash-commands and returns a structured decision.
|
||||
*
|
||||
* For now:
|
||||
* - Supports `/clear` command (clears conversation and UI)
|
||||
* - Unknown commands surface a pseudo assistant response
|
||||
* - Non-commands pass through to LLM unchanged
|
||||
*/
|
||||
export async function processUserMessage(originalMessage: string): Promise<UserMessageFilterResult> {
|
||||
const trimmed = originalMessage.trim();
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const [command, ...rest] = trimmed.split(/\s+/);
|
||||
const argString = rest.join(' ');
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case '/clear': {
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: `_Chat history cleared. Starting a new chat._`,
|
||||
metadata: { command: 'clear' }
|
||||
};
|
||||
}
|
||||
|
||||
case '/welcome': {
|
||||
try {
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
const openaiKey = (configManager.get('general.openai.api_key') as string) || '';
|
||||
const oaiCompatKey = (configManager.get('general.openai_compatible.api_key') as string) || '';
|
||||
const oaiCompatBaseUrl = (configManager.get('general.openai_compatible.base_url') as string) || '';
|
||||
const isNew = openaiKey.trim() === '' && oaiCompatKey.trim() === '' && oaiCompatBaseUrl.trim() === '';
|
||||
|
||||
const url = isNew ? '/chat/welcome_new.md' : '/chat/welcome_again.md';
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
|
||||
}
|
||||
const md = await resp.text();
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: md,
|
||||
metadata: { command: 'welcome', variant: isNew ? 'new' : 'again' }
|
||||
};
|
||||
} catch (err) {
|
||||
const fallback = 'Welcome to K.G.Studio Musician Assistant.';
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: fallback,
|
||||
metadata: { command: 'welcome', error: String(err) }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
const help = 'Available commands: /clear, /welcome';
|
||||
setStatus(`Unknown command: ${command}. ${help}`);
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: `Unknown command: ${command}${argString ? ' ' + argString : ''}.\n${help}`,
|
||||
metadata: { command: 'unknown' }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-command message: require an active or selected region
|
||||
try {
|
||||
// Provider-specific configuration checks before sending to LLM
|
||||
try {
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
const provider = (configManager.get('general.llm_provider') as string) || 'openai';
|
||||
|
||||
if (provider === 'openai') {
|
||||
const openaiKey = (configManager.get('general.openai.api_key') as string) || '';
|
||||
if (openaiKey.trim() === '') {
|
||||
const url = '/chat/error_no_openai_key.md';
|
||||
let md = 'OpenAI provider selected, but no API key configured.';
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) md = await resp.text();
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch error_no_openai_key.md', e);
|
||||
}
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: md,
|
||||
metadata: { error: 'no_openai_key' }
|
||||
};
|
||||
}
|
||||
} else if (provider === 'openai_compatible') {
|
||||
const baseUrl = (configManager.get('general.openai_compatible.base_url') as string) || '';
|
||||
if (baseUrl.trim() === '') {
|
||||
const url = '/chat/error_no_openai_compatible_base_url.md';
|
||||
let md = 'OpenAI Compatible provider selected, but no Base URL configured.';
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) md = await resp.text();
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch error_no_openai_compatible_base_url.md', e);
|
||||
}
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: md,
|
||||
metadata: { error: 'no_openai_compatible_base_url' }
|
||||
};
|
||||
}
|
||||
|
||||
const model = (configManager.get('general.openai_compatible.model') as string) || '';
|
||||
if (model.trim() === '') {
|
||||
const url = '/chat/error_no_openai_compatible_model.md';
|
||||
let md = 'OpenAI Compatible provider selected, but no Model configured.';
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) md = await resp.text();
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch error_no_openai_compatible_model.md', e);
|
||||
}
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: md,
|
||||
metadata: { error: 'no_openai_compatible_model' }
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Provider config check failed; proceeding with defaults', e);
|
||||
}
|
||||
|
||||
const { activeRegionId, selectedRegionIds } = useProjectStore.getState();
|
||||
const hasContextRegion = !!activeRegionId || (Array.isArray(selectedRegionIds) && selectedRegionIds.length > 0);
|
||||
|
||||
if (!hasContextRegion) {
|
||||
// No region context: show guidance and do not send to LLM
|
||||
const url = '/chat/error_no_selected_region.md';
|
||||
let md = 'Please select a region or open a MIDI region in the piano roll before asking for editing.';
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
md = await resp.text();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch error_no_selected_region.md', e);
|
||||
// ignore fetch failure, use fallback text
|
||||
}
|
||||
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: md,
|
||||
metadata: { error: 'no_selected_region' }
|
||||
};
|
||||
}
|
||||
|
||||
// Has region context: pass through, but append processed appendix to the LLM-bound message
|
||||
let appendix = '';
|
||||
try {
|
||||
const resp = await fetch('/prompts/user_msg_appendix.md');
|
||||
if (resp.ok) {
|
||||
const rawAppendix = await resp.text();
|
||||
appendix = await SystemPrompts.getPromptWithContext(rawAppendix);
|
||||
}
|
||||
} catch (e) {
|
||||
// If appendix fetch fails, proceed without it
|
||||
console.warn('Failed to fetch user_msg_appendix.md', e);
|
||||
}
|
||||
|
||||
const finalForLLM = appendix ? `${trimmed}${appendix}` : trimmed;
|
||||
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: finalForLLM,
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: { mode: 'pass_through_with_region', appendixIncluded: appendix.length > 0 }
|
||||
};
|
||||
} catch {
|
||||
// Fallback: if store access fails, pass through unchanged
|
||||
return {
|
||||
displayUserMessage: true,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: trimmed,
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: { mode: 'pass_through_fallback' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Miscellaneous utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a unique ID with a consistent format
|
||||
* @param prefix - The prefix for the ID (typically class name like 'KGMidiNote')
|
||||
* @returns A unique ID in format: prefix_timestamp_randomString
|
||||
* @example generateUniqueId('KGMidiNote') -> 'KGMidiNote_1642123456789_abc123def'
|
||||
*/
|
||||
export const generateUniqueId = (prefix: string): string => {
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 11); // 9 character random string
|
||||
return `${prefix}_${timestamp}_${randomString}`;
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* OS utility functions for platform detection and keyboard shortcuts
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Detects the current operating system platform
|
||||
* @returns The platform name
|
||||
*/
|
||||
export const getPlatform = (): 'mac' | 'windows' | 'linux' | 'unknown' => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
|
||||
if (platform.includes('mac') || userAgent.includes('mac')) {
|
||||
return 'mac';
|
||||
} else if (platform.includes('win') || userAgent.includes('win')) {
|
||||
return 'windows';
|
||||
} else if (platform.includes('linux') || userAgent.includes('linux')) {
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the appropriate modifier key is pressed based on the current platform
|
||||
* On Mac: checks for metaKey (CMD)
|
||||
* On Windows/Linux: checks for ctrlKey (CTRL)
|
||||
* @param event - The keyboard or mouse event (both native and React events)
|
||||
* @returns true if the platform-appropriate modifier key is pressed
|
||||
*/
|
||||
export const isModifierKeyPressed = (event: KeyboardEvent | MouseEvent | React.KeyboardEvent | React.MouseEvent): boolean => {
|
||||
const platform = getPlatform();
|
||||
|
||||
if (platform === 'mac') {
|
||||
return event.metaKey;
|
||||
} else {
|
||||
// Windows, Linux, or unknown - use Ctrl key
|
||||
return event.ctrlKey;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the display name of the modifier key for the current platform
|
||||
* @returns The display name (e.g., "Cmd" for Mac, "Ctrl" for others)
|
||||
*/
|
||||
export const getModifierKeyDisplayName = (): string => {
|
||||
const platform = getPlatform();
|
||||
return platform === 'mac' ? 'Cmd' : 'Ctrl';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a keyboard shortcut string by replacing 'ctrl' with the appropriate modifier for the current platform
|
||||
* @param shortcut - The shortcut string (e.g., "ctrl+z", "ctrl+shift+z")
|
||||
* @returns The resolved shortcut with platform-specific modifiers
|
||||
*/
|
||||
export const resolveKeyboardShortcut = (shortcut: string): string => {
|
||||
const platform = getPlatform();
|
||||
if (platform === 'mac') {
|
||||
// Replace 'ctrl' with 'cmd' on Mac
|
||||
return shortcut.replace(/\bctrl\b/gi, 'cmd');
|
||||
}
|
||||
return shortcut;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a keyboard event matches a given shortcut string
|
||||
* @param event - The keyboard event
|
||||
* @param shortcut - The shortcut string (e.g., "ctrl+z", "ctrl+shift+z")
|
||||
* @returns true if the event matches the shortcut
|
||||
*/
|
||||
export const matchesKeyboardShortcut = (event: KeyboardEvent | React.KeyboardEvent, shortcut: string): boolean => {
|
||||
const normalizedShortcut = shortcut.toLowerCase();
|
||||
const keys = normalizedShortcut.split('+');
|
||||
|
||||
// Extract the main key (last part)
|
||||
const mainKey = keys[keys.length - 1];
|
||||
|
||||
// Handle special key mappings
|
||||
let eventKey = event.key.toLowerCase();
|
||||
if (eventKey === ' ') {
|
||||
eventKey = 'space';
|
||||
}
|
||||
|
||||
if (eventKey !== mainKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
const hasCtrl = keys.includes('ctrl');
|
||||
const hasShift = keys.includes('shift');
|
||||
const hasAlt = keys.includes('alt');
|
||||
const hasCmd = keys.includes('cmd');
|
||||
|
||||
const platform = getPlatform();
|
||||
|
||||
// Handle platform-specific modifier checking
|
||||
let expectedCtrlOrCmd = false;
|
||||
if (platform === 'mac') {
|
||||
expectedCtrlOrCmd = hasCmd || hasCtrl; // On Mac, both 'ctrl' and 'cmd' should map to metaKey
|
||||
} else {
|
||||
expectedCtrlOrCmd = hasCtrl; // On other platforms, only 'ctrl' maps to ctrlKey
|
||||
}
|
||||
|
||||
const actualCtrlOrCmd = platform === 'mac' ? event.metaKey : event.ctrlKey;
|
||||
|
||||
return actualCtrlOrCmd === expectedCtrlOrCmd &&
|
||||
event.shiftKey === hasShift &&
|
||||
event.altKey === hasAlt;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
type DeleteRegionsCallback = () => boolean;
|
||||
|
||||
class RegionDeleteManager {
|
||||
private static instance: RegionDeleteManager;
|
||||
private deleteCallback: DeleteRegionsCallback | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): RegionDeleteManager {
|
||||
if (!RegionDeleteManager.instance) {
|
||||
RegionDeleteManager.instance = new RegionDeleteManager();
|
||||
}
|
||||
return RegionDeleteManager.instance;
|
||||
}
|
||||
|
||||
registerDeleteCallback(callback: DeleteRegionsCallback): void {
|
||||
this.deleteCallback = callback;
|
||||
console.log('RegionDeleteManager: Delete callback registered');
|
||||
}
|
||||
|
||||
unregisterDeleteCallback(): void {
|
||||
this.deleteCallback = null;
|
||||
console.log('RegionDeleteManager: Delete callback unregistered');
|
||||
}
|
||||
|
||||
deleteSelectedRegions(): boolean {
|
||||
if (this.deleteCallback) {
|
||||
console.log('RegionDeleteManager: Executing delete callback');
|
||||
return this.deleteCallback();
|
||||
}
|
||||
console.log('RegionDeleteManager: No delete callback registered');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const regionDeleteManager = RegionDeleteManager.getInstance();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { KGStorage, DuplicateEntryError } from '../core/io/KGStorage';
|
||||
import { DB_CONSTANTS } from '../constants/coreConstants';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
|
||||
/**
|
||||
* Save project utility function
|
||||
* Handles saving the current project with proper error handling and user confirmation
|
||||
* @param projectName - The name of the project to save
|
||||
* @param setStatus - Function to update the status message
|
||||
* @returns Promise<boolean> - Returns true if save was successful, false otherwise
|
||||
*/
|
||||
export const saveProject = async (
|
||||
projectName: string,
|
||||
setStatus: (status: string) => void
|
||||
): Promise<boolean> => {
|
||||
const storage = KGStorage.getInstance();
|
||||
|
||||
try {
|
||||
await storage.save(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
projectName,
|
||||
KGCore.instance().getCurrentProject(),
|
||||
false,
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
console.log("project saved successfully");
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving project:", error);
|
||||
|
||||
if (error instanceof DuplicateEntryError) {
|
||||
const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
await storage.save(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
projectName,
|
||||
KGCore.instance().getCurrentProject(),
|
||||
true,
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
console.log("project saved successfully after overwrite");
|
||||
return true;
|
||||
|
||||
} catch (overwriteError) {
|
||||
console.error("Error overwriting project:", overwriteError);
|
||||
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// User cancelled the overwrite
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.error("Error saving project:", error);
|
||||
window.alert(`An unknown error ${error} occurred. Please try again.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Time utility functions for KGSP
|
||||
*/
|
||||
|
||||
import { TIME_CONSTANTS } from '../constants/coreConstants';
|
||||
import type { TimeSignature } from '../types/projectTypes';
|
||||
|
||||
/**
|
||||
* Parse and validate time signature string in format "numerator/denominator"
|
||||
* @param timeSignatureStr - String in format "numerator/denominator" (e.g., "4/4", "3/4", "6/8")
|
||||
* @returns TimeSignature object if valid, null if invalid
|
||||
*/
|
||||
export function parseTimeSignature(timeSignatureStr: string): TimeSignature | null {
|
||||
// Remove any whitespace and check format
|
||||
const trimmed = timeSignatureStr.trim();
|
||||
|
||||
// Check if it contains exactly one slash
|
||||
const parts = trimmed.split('/');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse numerator and denominator
|
||||
const numerator = parseInt(parts[0]);
|
||||
const denominator = parseInt(parts[1]);
|
||||
|
||||
// Check if both are valid numbers
|
||||
if (isNaN(numerator) || isNaN(denominator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate against available values
|
||||
if (!TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.includes(numerator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.includes(denominator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { numerator, denominator };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted error message for invalid time signature
|
||||
* @returns User-friendly error message with available options
|
||||
*/
|
||||
export function getTimeSignatureErrorMessage(): string {
|
||||
const numerators = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.join(', ');
|
||||
const denominators = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.join(', ');
|
||||
|
||||
return `Invalid time signature format. Please use "numerator/denominator" format.\n\nAvailable numerators: ${numerators}\nAvailable denominators: ${denominators}\n\nExamples: 4/4, 3/4, 6/8, 12/8`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert beats to combined bar/beat and time format (BBB:B | mm:ss:mmm)
|
||||
* @param beats - Current position in beats
|
||||
* @param bpm - Beats per minute
|
||||
* @param timeSignature - Time signature object with numerator and denominator
|
||||
* @returns Formatted time string in BBB:B | mm:ss:mmm format
|
||||
*/
|
||||
export function beatsToTimeString(
|
||||
beats: number,
|
||||
bpm: number,
|
||||
timeSignature: { numerator: number; denominator: number }
|
||||
): string {
|
||||
// Calculate bar and beat information
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const currentBar = Math.floor(beats / beatsPerBar) + 1; // 1-indexed bars
|
||||
const beatInBar = Math.floor(beats % beatsPerBar) + 1; // 1-indexed beats within bar
|
||||
|
||||
// Calculate total seconds from beats for time display
|
||||
const totalSeconds = (beats / bpm) * 60;
|
||||
|
||||
// Extract minutes, seconds, and milliseconds
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
const milliseconds = Math.floor((totalSeconds % 1) * 1000);
|
||||
|
||||
// Format bar/beat part
|
||||
const BBB = currentBar.toString().padStart(3, '0');
|
||||
const B = beatInBar.toString();
|
||||
|
||||
// Format time part with leading zeros
|
||||
const mm = minutes.toString().padStart(2, '0');
|
||||
const ss = seconds.toString().padStart(2, '0');
|
||||
const mmm = milliseconds.toString().padStart(3, '0');
|
||||
|
||||
return `${BBB}:${B} | ${mm}:${ss}:${mmm}`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* XML utility functions for KGSP
|
||||
* Contains functions for extracting and processing XML from strings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract all XML blocks from a given string
|
||||
* This function finds complete XML elements (from opening to closing tag) within mixed content,
|
||||
* such as LLM responses that contain XML tool invocations alongside natural language text.
|
||||
*
|
||||
* @param input - The input string that may contain XML blocks
|
||||
* @returns Array of XML strings found in the input, or empty array if none found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const response = `
|
||||
* I'll create a chord for you:
|
||||
* <add_notes>
|
||||
* <note>
|
||||
* <pitch>C4</pitch>
|
||||
* <start_beat>0</start_beat>
|
||||
* <length>4</length>
|
||||
* </note>
|
||||
* </add_notes>
|
||||
* This creates a C major note.
|
||||
* `;
|
||||
*
|
||||
* const xmlBlocks = extractXMLFromString(response);
|
||||
* // Returns: ['<add_notes>\n <note>\n <pitch>C4</pitch>\n <start_beat>0</start_beat>\n <length>4</length>\n </note>\n</add_notes>']
|
||||
* ```
|
||||
*/
|
||||
export function extractXMLFromString(input: string): string[] {
|
||||
// Regex pattern to match complete XML blocks:
|
||||
// - <([a-zA-Z_][a-zA-Z0-9_-]*) matches opening tag name (capture group 1)
|
||||
// - [^>]* matches any attributes in the opening tag
|
||||
// - [\s\S]*? matches any content (including newlines) non-greedily
|
||||
// - <\/\1> matches the corresponding closing tag using backreference
|
||||
const xmlPattern = /<([a-zA-Z_][a-zA-Z0-9_-]*)[^>]*>[\s\S]*?<\/\1>/g;
|
||||
|
||||
const matches: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Extract all XML blocks
|
||||
while ((match = xmlPattern.exec(input)) !== null) {
|
||||
const xmlBlock = match[0].trim();
|
||||
if (xmlBlock.length > 0) {
|
||||
matches.push(xmlBlock);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
Reference in New Issue
Block a user