Merge pull request #35 from KGAudioLab/feat/2026-04-30-midi-device-recording

Feat/2026 04 30 midi device recording
This commit is contained in:
Xiaohan-Tian
2026-05-01 14:37:30 -07:00
committed by GitHub
58 changed files with 3091 additions and 2234 deletions
+3 -1
View File
@@ -39,6 +39,7 @@
"hold_to_create_region": "ctrl", "hold_to_create_region": "ctrl",
"play": "space", "play": "space",
"loop": "c", "loop": "c",
"record": "r",
"undo": "ctrl+z", "undo": "ctrl+z",
"redo": "ctrl+shift+z", "redo": "ctrl+shift+z",
"select_all": "ctrl+a", "select_all": "ctrl+a",
@@ -73,7 +74,8 @@
"audio": { "audio": {
"enable_audio_capture_for_screen_sharing": false, "enable_audio_capture_for_screen_sharing": false,
"lookahead_time": 0.05, "lookahead_time": 0.05,
"playback_delay": 0.2 "playback_delay": 0.2,
"recording_offset": 0
}, },
"templates": { "templates": {
"custom_instructions": "" "custom_instructions": ""
+1 -1
View File
@@ -18,7 +18,7 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
import { KGCore } from './core/KGCore'; import { KGCore } from './core/KGCore';
import { ConfigManager } from './core/config/ConfigManager'; import { ConfigManager } from './core/config/ConfigManager';
import { validateFunctionalChordsJSON } from './util/scaleUtil'; import { validateFunctionalChordsJSON } from './util/scaleUtil';
import { showAlert } from './components/common/DialogProvider'; import { showAlert } from './util/dialogUtil';
import { KGProjectStorage } from './core/io/KGProjectStorage'; import { KGProjectStorage } from './core/io/KGProjectStorage';
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil'; import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
+6 -6
View File
@@ -14,7 +14,7 @@ import { sliceAudioToWav } from '../util/audioUtil';
import type { KeySignature } from '../core/KGProject'; import type { KeySignature } from '../core/KGProject';
import { ImportStemsCommand } from '../core/commands'; import { ImportStemsCommand } from '../core/commands';
import type { StemImportEntry } from '../core/commands'; import type { StemImportEntry } from '../core/commands';
import { showAlert } from './common/DialogProvider'; import { showAlert } from '../util/dialogUtil';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -300,7 +300,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
setGenStatus('polling'); setGenStatus('polling');
setGenHint('Generating clip...'); setGenHint('Generating clip...');
// eslint-disable-next-line no-constant-condition
while (true) { while (true) {
if (signal.aborted) return; if (signal.aborted) return;
@@ -626,7 +626,7 @@ const FullSongTab: React.FC = () => {
type ResultItem = { progress: number; stage: string; status: number }; type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number }; type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
// eslint-disable-next-line no-constant-condition
while (true) { while (true) {
if (signal.aborted) return; if (signal.aborted) return;
@@ -997,7 +997,7 @@ const SeparatorTab: React.FC = () => {
let files: string[] = []; let files: string[] = [];
// eslint-disable-next-line no-constant-condition
while (true) { while (true) {
if (signal.aborted) return; if (signal.aborted) return;
@@ -1414,7 +1414,7 @@ const RemixTab: React.FC = () => {
type ResultItem = { progress: number; stage: string; status: number }; type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number }; type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
// eslint-disable-next-line no-constant-condition
while (true) { while (true) {
if (signal.aborted) return; if (signal.aborted) return;
@@ -1932,7 +1932,7 @@ const RepaintTab: React.FC = () => {
type ResultItem = { progress: number; stage: string; status: number }; type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number }; type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
// eslint-disable-next-line no-constant-condition
while (true) { while (true) {
if (signal.aborted) return; if (signal.aborted) return;
+58 -2
View File
@@ -12,9 +12,11 @@ import {
FaUndo, FaRedo, FaMousePointer, FaStepBackward, FaUndo, FaRedo, FaMousePointer, FaStepBackward,
FaPlay, FaPause, FaComments, FaSync, FaPlay, FaPause, FaComments, FaSync,
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus, FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
FaCog, FaMagnet, FaCut FaCog, FaMagnet, FaCut, FaCircle
} from 'react-icons/fa'; } from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject'; import { KGProject, type KeySignature } from '../core/KGProject';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6'; import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState'; import { KGMainContentState } from '../core/state/KGMainContentState';
@@ -31,7 +33,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon'; import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon'; import MetronomeIcon from './common/icons/MetronomeIcon';
import { ConfigManager } from '../core/config/ConfigManager'; import { ConfigManager } from '../core/config/ConfigManager';
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './common/DialogProvider'; import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
const { const {
@@ -45,6 +47,7 @@ const Toolbar: React.FC = () => {
isLooping, toggleLoop, isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo, canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled, toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
isRecording, startRecording, stopRecording,
// Piano roll state/actions // Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId, showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
// Selection state // Selection state
@@ -515,6 +518,11 @@ const Toolbar: React.FC = () => {
console.log("Pause button clicked"); console.log("Pause button clicked");
} }
try { try {
if (isRecording) {
await stopRecording();
setStatus("Recording stopped — notes committed");
return;
}
await stopPlaying(); await stopPlaying();
} catch (error) { } catch (error) {
console.error("Failed to stop playback:", error); console.error("Failed to stop playback:", error);
@@ -860,6 +868,46 @@ const Toolbar: React.FC = () => {
} }
}; };
const handleRecordClick = async () => {
if (isRecording) {
await stopRecording();
setStatus("Recording stopped — notes committed");
return;
}
// Require an active or selected MIDI region
const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null);
if (!candidateId) {
await showAlert("Please open a MIDI region in the Piano Roll before starting recording.");
return;
}
const tracks = KGCore.instance().getCurrentProject().getTracks();
let isMidi = false;
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === candidateId);
if (region) { isMidi = region instanceof KGMidiRegion; break; }
}
if (!isMidi) {
await showAlert("Please select a MIDI region before starting recording.");
return;
}
// Require at least one connected MIDI device
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
await showAlert("No MIDI device detected. Please connect a MIDI keyboard and try again.");
return;
}
// Ensure the piano roll is open showing the target region
if (!activeRegionId) {
setActiveRegionId(candidateId);
setShowPianoRoll(true);
}
await startRecording();
setStatus("Recording started...");
};
return ( return (
<> <>
<div className="toolbar"> <div className="toolbar">
@@ -943,6 +991,14 @@ const Toolbar: React.FC = () => {
) : ( ) : (
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button> <button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
)} )}
<button
title={isRecording ? "Stop Recording" : "Record"}
className={`tool-button ${isRecording ? 'active' : ''}`}
style={isRecording ? { color: 'red' } : undefined}
onClick={handleRecordClick}
>
<FaCircle />
</button>
<button <button
title="Loop" title="Loop"
className={`tool-button ${isLooping ? 'active' : ''}`} className={`tool-button ${isLooping ? 'active' : ''}`}
+3 -58
View File
@@ -1,60 +1,8 @@
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css'; import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { registerDialogFns } from '../../util/dialogUtil';
export interface ConfirmOptions { import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
confirmLabel?: string;
cancelLabel?: string;
}
export interface PromptOptions {
confirmLabel?: string;
cancelLabel?: string;
placeholder?: string;
}
export interface TimeSigResult {
numerator: number;
denominator: number;
}
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
export function showAlert(message: string): Promise<void> {
if (!_showAlertFn) {
window.alert(message);
return Promise.resolve();
}
return _showAlertFn(message);
}
export function showConfirm(message: string, options?: ConfirmOptions): Promise<boolean> {
if (!_showConfirmFn) {
return Promise.resolve(window.confirm(message));
}
return _showConfirmFn(message, options);
}
export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> {
if (!_showPromptFn) {
return Promise.resolve(window.prompt(message, defaultValue));
}
return _showPromptFn(message, defaultValue, options);
}
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
if (!_showTimeSigFn) {
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
if (!raw) return Promise.resolve(null);
const [n, d] = raw.split('/').map(Number);
if (!n || !d) return Promise.resolve(null);
return Promise.resolve({ numerator: n, denominator: d });
}
return _showTimeSigFn(message, defaultValue);
}
interface DialogInfo { interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig'; type: 'alert' | 'confirm' | 'prompt' | 'timesig';
@@ -129,10 +77,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const registered = useRef(false); const registered = useRef(false);
if (!registered.current) { if (!registered.current) {
registered.current = true; registered.current = true;
_showAlertFn = openAlert; registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
_showConfirmFn = openConfirm;
_showPromptFn = openPrompt;
_showTimeSigFn = openTimeSig;
} }
if (!dialog) { if (!dialog) {
+1 -1
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import './FileImportModal.css'; import './FileImportModal.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { showAlert } from './DialogProvider'; import { showAlert } from '../../util/dialogUtil';
interface FileImportModalProps { interface FileImportModalProps {
isVisible: boolean; isVisible: boolean;
+1 -1
View File
@@ -3,7 +3,7 @@ import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-ic
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage'; import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
import { isValidProjectName } from '../../util/projectNameUtil'; import { isValidProjectName } from '../../util/projectNameUtil';
import './OpenProjectModal.css'; import './OpenProjectModal.css';
import { showAlert, showConfirm, showPrompt } from './DialogProvider'; import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
interface OpenProjectModalProps { interface OpenProjectModalProps {
onClose: () => void; onClose: () => void;
+3 -2
View File
@@ -3,5 +3,6 @@ export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal'; export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay'; export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal'; export { default as OpenProjectModal } from './OpenProjectModal';
export { default as DialogProvider, showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './DialogProvider'; export { default as DialogProvider } from './DialogProvider';
export type { ConfirmOptions, PromptOptions, TimeSigResult } from './DialogProvider'; export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
+8
View File
@@ -255,6 +255,14 @@
transition: opacity 0.1s ease; transition: opacity 0.1s ease;
} }
.piano-grid-recording-note {
position: absolute;
background-color: rgba(255, 60, 60, 0.35);
border: 1px solid rgba(255, 60, 60, 0.85);
pointer-events: none;
z-index: 3;
}
.resize-handle { .resize-handle {
position: absolute; position: absolute;
right: 5px; right: 5px;
+1 -1
View File
@@ -15,7 +15,7 @@ import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil'; import { beatsToBar } from '../../util/midiUtil';
import { UpdateRegionCommand } from '../../core/commands'; import { UpdateRegionCommand } from '../../core/commands';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
import { showAlert, showPrompt } from '../common/DialogProvider'; import { showAlert, showPrompt } from '../../util/dialogUtil';
interface PianoRollProps { interface PianoRollProps {
onClose: () => void; onClose: () => void;
+26 -2
View File
@@ -4,6 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGTrack } from '../../core/track/KGTrack'; import { KGTrack } from '../../core/track/KGTrack';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import { useProjectStore } from '../../stores/projectStore';
import PianoNote from './PianoNote'; import PianoNote from './PianoNote';
import PianoKeys from './PianoKeys'; import PianoKeys from './PianoKeys';
import PianoGridHeader from './PianoGridHeader'; import PianoGridHeader from './PianoGridHeader';
@@ -43,7 +44,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
}) => { }) => {
// Get KGCore instance // Get KGCore instance
const core = KGCore.instance(); const core = KGCore.instance();
// Recording state
const isRecording = useProjectStore(s => s.isRecording);
const recordingNotes = useProjectStore(s => s.recordingNotes);
// Use the note operations hook for resize and drag functionality // Use the note operations hook for resize and drag functionality
const { const {
resizingNoteId, resizingNoteId,
@@ -216,8 +221,26 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
}); });
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]); }, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
const recordingNoteOverlays = useMemo(() => {
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
return recordingNotes.map((note, index) => (
<div
key={`recording-note-${index}`}
className="piano-grid-recording-note"
style={{
left: note.startBeat * beatWidth,
top: (107 - note.pitch) * noteHeight,
width: Math.max((note.endBeat - note.startBeat) * beatWidth, 4),
height: noteHeight,
}}
/>
));
}, [isRecording, recordingNotes, activeRegion]);
return ( return (
<div <div
className="piano-roll-content" className="piano-roll-content"
ref={contentRef} ref={contentRef}
> >
@@ -239,6 +262,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
chordGuide={chordGuide} chordGuide={chordGuide}
> >
{memoizedNotes} {memoizedNotes}
{recordingNoteOverlays}
</PianoGrid> </PianoGrid>
</div> </div>
</div> </div>
@@ -7,9 +7,11 @@ const BehaviorSettings: React.FC = () => {
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true); const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50'); const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50');
const [playbackDelay, setPlaybackDelay] = useState<string>('200'); const [playbackDelay, setPlaybackDelay] = useState<string>('200');
const [recordingOffset, setRecordingOffset] = useState<string>('0');
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false); const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]); const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]); const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
const [recordingOffsetValidationErrors, setRecordingOffsetValidationErrors] = useState<string[]>([]);
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
@@ -26,6 +28,8 @@ const BehaviorSettings: React.FC = () => {
setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0))); setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0)));
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2; const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0))); setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0)));
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false); setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
}; };
@@ -102,6 +106,28 @@ const BehaviorSettings: React.FC = () => {
} }
}; };
const handleRecordingOffsetChange = async (value: string) => {
const numValueMs = value === '' ? 0 : parseFloat(value);
const numValueSeconds = numValueMs / 1000;
const errors: string[] = [];
if (isNaN(numValueMs)) {
errors.push('MIDI input latency must be a valid number');
} else if (numValueSeconds < 0) {
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
} else if (numValueSeconds > 0.5) {
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
}
setRecordingOffsetValidationErrors(errors);
if (errors.length === 0) {
setRecordingOffset(value);
await configManager.set('audio.recording_offset', numValueSeconds);
console.log(`MIDI input latency changed to: ${numValueSeconds}s (${numValueMs}ms)`);
}
};
const handleEnableAudioCaptureChange = async (value: string) => { const handleEnableAudioCaptureChange = async (value: string) => {
const boolValue = value === 'yes'; const boolValue = value === 'yes';
setEnableAudioCapture(boolValue); setEnableAudioCapture(boolValue);
@@ -212,6 +238,33 @@ const BehaviorSettings: React.FC = () => {
</div> </div>
</div> </div>
<div className="settings-item">
<label className="settings-label">
MIDI Input Latency (ms)
</label>
<input
type="number"
className="settings-select"
value={recordingOffset}
onChange={(e) => handleRecordingOffsetChange(e.target.value)}
min="0"
max="500"
step="1"
/>
{recordingOffsetValidationErrors.length > 0 && (
<div className="settings-validation-errors">
{recordingOffsetValidationErrors.map((error, index) => (
<div key={index} className="settings-validation-error">
{error}
</div>
))}
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Timing correction for MIDI recording (0-500ms). If recorded notes appear slightly late compared to where you intended to play them, increase this value to match your MIDI device's input latency. Each note's position is shifted back by this amount when committed.
</div>
</div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Capture Audio for Screen Sharing Capture Audio for Screen Sharing
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager'; import { ConfigManager } from '../../../core/config/ConfigManager';
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil'; import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
import { KGCore } from '../../../core/KGCore'; import { KGCore } from '../../../core/KGCore';
import { showAlert } from '../../common/DialogProvider'; import { showAlert } from '../../../util/dialogUtil';
const ChordGuideSettings: React.FC = () => { const ChordGuideSettings: React.FC = () => {
const [chordDefinition, setChordDefinition] = useState<string>(''); const [chordDefinition, setChordDefinition] = useState<string>('');
+29 -1
View File
@@ -51,7 +51,7 @@
.track-info { .track-info {
width: 200px; width: 200px;
height: 120px; height: 120px;
padding: 15px; padding: 15px 5px 15px 15px;
background-color: #2d2d2d; background-color: #2d2d2d;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -155,6 +155,34 @@
gap: 6px; gap: 6px;
} }
.volume-slider .volume-label {
flex-shrink: 0;
font-size: 9px;
color: #aaa;
min-width: 22px;
text-align: left;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.volume-slider .volume-label-clickable {
cursor: pointer;
}
.volume-slider .volume-label-clickable:hover {
color: #ddd;
}
.volume-slider .volume-label-input {
background: #2a2a2a;
border: 1px solid #555;
border-radius: 3px;
color: #e0e0e0;
padding: 0 2px;
width: 36px;
outline: none;
}
.volume-slider input[type="range"] { .volume-slider input[type="range"] {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+1 -1
View File
@@ -13,7 +13,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { generateNewRegionName } from '../../util/miscUtil'; import { generateNewRegionName } from '../../util/miscUtil';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage'; import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import { showAlert } from '../common/DialogProvider'; import { showAlert } from '../../util/dialogUtil';
import { parseMidiFirstTrackNotes } from '../../util/midiUtil'; import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
import * as Tone from 'tone'; import * as Tone from 'tone';
+96 -7
View File
@@ -12,7 +12,36 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { DEBUG_MODE } from '../../constants/uiConstants'; import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { showAlert, showConfirm, showPrompt } from '../common/DialogProvider'; import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
const UNITY_POS = 750;
const SLIDER_MAX = 1000;
function sliderToDb(pos: number): number {
const MIN = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
const MAX = AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB;
if (pos <= 0) return MIN;
if (pos >= SLIDER_MAX) return MAX;
if (pos <= UNITY_POS) {
const t = pos / UNITY_POS;
return MIN * (1 - t * t);
}
return MAX * (pos - UNITY_POS) / (SLIDER_MAX - UNITY_POS);
}
function dbToSlider(db: number): number {
const MIN = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
const MAX = AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB;
if (db <= MIN) return 0;
if (db >= MAX) return SLIDER_MAX;
if (db <= 0) return Math.round(Math.sqrt(1 - db / MIN) * UNITY_POS);
return Math.round(UNITY_POS + (db / MAX) * (SLIDER_MAX - UNITY_POS));
}
function formatDb(db: number): string {
if (db <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) return '−∞';
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
}
interface TrackInfoItemProps { interface TrackInfoItemProps {
track: KGTrack; track: KGTrack;
index: number; index: number;
@@ -56,6 +85,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
const settingsDropdownRef = useRef<HTMLDivElement>(null); const settingsDropdownRef = useRef<HTMLDivElement>(null);
const suppressDragRef = useRef(false); const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume()); const [volume, setVolume] = useState(track.getVolume());
const [isEditingVolume, setIsEditingVolume] = useState(false);
const [volumeInputText, setVolumeInputText] = useState('');
const volumeInputRef = useRef<HTMLInputElement>(null);
// Local flag to track slider interaction; not used for rendering // Local flag to track slider interaction; not used for rendering
const isAdjustingVolumeRef = useRef(false); const isAdjustingVolumeRef = useRef(false);
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
@@ -129,11 +161,10 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation(); e.stopPropagation();
const next = Number(e.target.value) / 100; const next = sliderToDb(Number(e.target.value));
isAdjustingVolumeRef.current = true; isAdjustingVolumeRef.current = true;
setVolume(next); setVolume(next);
try { try {
// Live preview: update audio only while sliding
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next); KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
} catch (err) { } catch (err) {
console.error('Failed to update live volume:', err); console.error('Failed to update live volume:', err);
@@ -167,6 +198,43 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
} }
}; };
const handleVolumeLabelClick = (e: React.MouseEvent) => {
e.stopPropagation();
const displayText = volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB
? '-60'
: volume.toFixed(1);
setVolumeInputText(displayText);
setIsEditingVolume(true);
setTimeout(() => {
volumeInputRef.current?.select();
}, 0);
};
const commitVolumeLabelInput = () => {
setIsEditingVolume(false);
const parsed = parseFloat(volumeInputText);
if (isNaN(parsed)) return;
const clamped = Math.max(
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, parsed)
);
setVolume(clamped);
try {
useProjectStore.getState().updateTrackProperties(track.getId(), { volume: clamped });
} catch (err) {
console.error('Failed to set volume from label input:', err);
}
};
const handleVolumeLabelKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commitVolumeLabelInput();
} else if (e.key === 'Escape') {
setIsEditingVolume(false);
}
};
const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => { const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
const next = !muted; const next = !muted;
@@ -302,8 +370,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
<input <input
type="range" type="range"
min="0" min="0"
max="100" max="1000"
value={Math.round(volume * 100)} step="1"
value={dbToSlider(volume)}
onChange={handleVolumeChange} onChange={handleVolumeChange}
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }} onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }} onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
@@ -314,12 +383,32 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
/> />
<button <button
className="reset-volume" className="reset-volume"
title="Reset volume" title="Reset to 0 dB"
aria-label="Reset volume" aria-label="Reset volume to 0 dB"
onClick={handleResetVolume} onClick={handleResetVolume}
> >
</button> </button>
{isEditingVolume ? (
<input
ref={volumeInputRef}
className="volume-label volume-label-input"
type="text"
value={volumeInputText}
onChange={(e) => setVolumeInputText(e.target.value)}
onKeyDown={handleVolumeLabelKeyDown}
onBlur={commitVolumeLabelInput}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="volume-label volume-label-clickable"
title="Click to enter dB value"
onClick={handleVolumeLabelClick}
>
{formatDb(volume)}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
+3 -1
View File
@@ -63,7 +63,9 @@ export const KEY_SIGNATURE_MAP = {
export const AUDIO_INTERFACE_CONSTANTS = { export const AUDIO_INTERFACE_CONSTANTS = {
DEFAULT_MASTER_VOLUME: 0.8, DEFAULT_MASTER_VOLUME: 0.8,
DEFAULT_TRACK_VOLUME: 0.8, DEFAULT_TRACK_VOLUME: 0, // 0 dB (unity gain)
MIN_TRACK_VOLUME_DB: -60, // practical floor; displayed as −∞
MAX_TRACK_VOLUME_DB: 12,
}; };
export const SAMPLER_CONSTANTS = { export const SAMPLER_CONSTANTS = {
+14 -6
View File
@@ -10,6 +10,10 @@ import { KGRegion } from './region/KGRegion';
import { generateUniqueId } from '../util/miscUtil'; import { generateUniqueId } from '../util/miscUtil';
import { KGCommand, KGCommandHistory } from './commands'; import { KGCommand, KGCommandHistory } from './commands';
interface PlaybackStartOptions {
preserveLoopPreroll?: boolean;
}
/** /**
* KGCore - Main application class for the DAW * KGCore - Main application class for the DAW
* Implements the singleton pattern for global access * Implements the singleton pattern for global access
@@ -231,7 +235,7 @@ export class KGCore {
return this.isPlaying; return this.isPlaying;
} }
public async preparePlay(): Promise<void> { public async preparePlay(options?: PlaybackStartOptions): Promise<void> {
try { try {
const audioInterface = KGAudioInterface.instance(); const audioInterface = KGAudioInterface.instance();
@@ -239,7 +243,9 @@ export class KGCore {
await audioInterface.startAudioContext(); await audioInterface.startAudioContext();
// Prepare playback with current project and playhead position // Prepare playback with current project and playhead position
audioInterface.preparePlayback(this.currentProject, this.playheadPosition); audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
});
// Sync BPM and transport settings // Sync BPM and transport settings
audioInterface.setBpm(this.currentProject.getBpm()); audioInterface.setBpm(this.currentProject.getBpm());
@@ -301,7 +307,7 @@ export class KGCore {
} }
// High-level playback control methods // High-level playback control methods
public async startPlaying(): Promise<void> { public async startPlaying(options?: PlaybackStartOptions): Promise<void> {
// Handle loop mode initialization // Handle loop mode initialization
if (this.currentProject.getIsLooping()) { if (this.currentProject.getIsLooping()) {
const [startBar, endBar] = this.currentProject.getLoopingRange(); const [startBar, endBar] = this.currentProject.getLoopingRange();
@@ -319,11 +325,13 @@ export class KGCore {
const updatedRange = this.currentProject.getLoopingRange(); const updatedRange = this.currentProject.getLoopingRange();
const beatsPerBar = this.currentProject.getTimeSignature().numerator; const beatsPerBar = this.currentProject.getTimeSignature().numerator;
const loopStartBeats = updatedRange[0] * beatsPerBar; const loopStartBeats = updatedRange[0] * beatsPerBar;
this.setPlayheadPosition(loopStartBeats); if (!options?.preserveLoopPreroll) {
this.setPlayheadPosition(loopStartBeats);
}
} }
// Prepare playback first // Prepare playback first
await this.preparePlay(); await this.preparePlay(options);
// Start playing (non-blocking) // Start playing (non-blocking)
this.play(); // Don't await this this.play(); // Don't await this
@@ -645,4 +653,4 @@ export class KGCore {
public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } { public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
return this.commandHistory.getHistoryStats(); return this.commandHistory.getHistoryStats();
} }
} }
+89 -91
View File
@@ -513,8 +513,7 @@ export class KGDebugger {
try { try {
textarea.selectionStart = textarea.selectionEnd = typed.length; textarea.selectionStart = textarea.selectionEnd = typed.length;
} catch { } catch { /* setting cursor position can fail in read-only or special inputs */ }
}
textarea.scrollTop = textarea.scrollHeight; textarea.scrollTop = textarea.scrollHeight;
@@ -583,8 +582,7 @@ export class KGDebugger {
try { try {
textarea.selectionStart = textarea.selectionEnd = typed.length; textarea.selectionStart = textarea.selectionEnd = typed.length;
} catch { } catch { /* setting cursor position can fail in read-only or special inputs */ }
}
textarea.scrollTop = textarea.scrollHeight; textarea.scrollTop = textarea.scrollHeight;
@@ -673,212 +671,212 @@ export class KGDebugger {
* await KGDebugger.opfs('cat project.json') * await KGDebugger.opfs('cat project.json')
*/ */
public async opfs(command: string): Promise<void> { public async opfs(command: string): Promise<void> {
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
const cmd = parts[0] const cmd = parts[0];
// Strip surrounding quotes from arguments // Strip surrounding quotes from arguments
const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ') const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ');
try { try {
switch (cmd) { switch (cmd) {
case 'pwd': case 'pwd':
console.log('/' + this.opfsCwd.join('/')) console.log('/' + this.opfsCwd.join('/'));
break break;
case 'ls': case 'ls':
await this.opfsLs() await this.opfsLs();
break break;
case 'cd': case 'cd':
await this.opfsCd(arg) await this.opfsCd(arg);
break break;
case 'cat': case 'cat':
await this.opfsCat(arg) await this.opfsCat(arg);
break break;
case 'dl': case 'dl':
await this.opfsDl(arg) await this.opfsDl(arg);
break break;
case 'rm': case 'rm':
await this.opfsRm(arg) await this.opfsRm(arg);
break break;
default: default:
console.log(`opfs: command not found: ${cmd}`) console.log(`opfs: command not found: ${cmd}`);
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>') console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>');
} }
} catch (error) { } catch (error) {
console.error(`opfs: ${error}`) console.error(`opfs: ${error}`);
} }
} }
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> { private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
let dir = await navigator.storage.getDirectory() let dir = await navigator.storage.getDirectory();
for (const segment of this.opfsCwd) { for (const segment of this.opfsCwd) {
dir = await dir.getDirectoryHandle(segment) dir = await dir.getDirectoryHandle(segment);
} }
return dir return dir;
} }
private async opfsLs(): Promise<void> { private async opfsLs(): Promise<void> {
const dir = await this.opfsResolveCwd() const dir = await this.opfsResolveCwd();
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [] const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [];
for await (const entry of dir.values()) { for await (const entry of dir.values()) {
if (entry.kind === 'file') { if (entry.kind === 'file') {
const fileHandle = entry as FileSystemFileHandle const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile() const file = await fileHandle.getFile();
entries.push({ entries.push({
kind: 'file', kind: 'file',
name: entry.name, name: entry.name,
size: file.size, size: file.size,
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19), modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
}) });
} else { } else {
entries.push({ entries.push({
kind: 'dir', kind: 'dir',
name: entry.name + '/', name: entry.name + '/',
size: 0, size: 0,
modified: '-', modified: '-',
}) });
} }
} }
// Sort: directories first, then files, alphabetically within each group // Sort: directories first, then files, alphabetically within each group
entries.sort((a, b) => { entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name);
}) });
if (entries.length === 0) { if (entries.length === 0) {
console.log('(empty directory)') console.log('(empty directory)');
return return;
} }
// Print header // Print header
const cwdPath = '/' + this.opfsCwd.join('/') const cwdPath = '/' + this.opfsCwd.join('/');
console.log(`total ${entries.length} (${cwdPath})`) console.log(`total ${entries.length} (${cwdPath})`);
// Format like ls -lla // Format like ls -lla
const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4) const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4);
for (const e of entries) { for (const e of entries) {
const typeChar = e.kind === 'dir' ? 'd' : '-' const typeChar = e.kind === 'dir' ? 'd' : '-';
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--' const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--';
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen) const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen);
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`) console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`);
} }
} }
private async opfsCd(path: string): Promise<void> { private async opfsCd(path: string): Promise<void> {
if (!path || path === '') { if (!path || path === '') {
// cd with no args goes to root // cd with no args goes to root
this.opfsCwd = [] this.opfsCwd = [];
return return;
} }
let segments: string[] let segments: string[];
if (path === '/') { if (path === '/') {
this.opfsCwd = [] this.opfsCwd = [];
return return;
} else if (path.startsWith('/')) { } else if (path.startsWith('/')) {
// Absolute path // Absolute path
segments = path.split('/').filter(Boolean) segments = path.split('/').filter(Boolean);
} else { } else {
// Relative path // Relative path
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)] segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)];
} }
// Resolve . and .. // Resolve . and ..
const resolved: string[] = [] const resolved: string[] = [];
for (const seg of segments) { for (const seg of segments) {
if (seg === '.') continue if (seg === '.') continue;
if (seg === '..') { if (seg === '..') {
resolved.pop() resolved.pop();
} else { } else {
resolved.push(seg) resolved.push(seg);
} }
} }
// Verify the path exists // Verify the path exists
let dir = await navigator.storage.getDirectory() let dir = await navigator.storage.getDirectory();
for (const seg of resolved) { for (const seg of resolved) {
try { try {
dir = await dir.getDirectoryHandle(seg) dir = await dir.getDirectoryHandle(seg);
} catch { } catch {
console.error(`opfs: cd: no such directory: ${path}`) console.error(`opfs: cd: no such directory: ${path}`);
return return;
} }
} }
this.opfsCwd = resolved this.opfsCwd = resolved;
} }
private async opfsCat(fileName: string): Promise<void> { private async opfsCat(fileName: string): Promise<void> {
if (!fileName) { if (!fileName) {
console.error('opfs: cat: missing file name') console.error('opfs: cat: missing file name');
return return;
} }
const dir = await this.opfsResolveCwd() const dir = await this.opfsResolveCwd();
try { try {
const fileHandle = await dir.getFileHandle(fileName) const fileHandle = await dir.getFileHandle(fileName);
const file = await fileHandle.getFile() const file = await fileHandle.getFile();
const text = await file.text() const text = await file.text();
// Pretty-print JSON files // Pretty-print JSON files
if (fileName.endsWith('.json')) { if (fileName.endsWith('.json')) {
try { try {
const parsed = JSON.parse(text) const parsed = JSON.parse(text);
console.log(JSON.stringify(parsed, null, 2)) console.log(JSON.stringify(parsed, null, 2));
} catch { } catch {
console.log(text) console.log(text);
} }
} else { } else {
console.log(text) console.log(text);
} }
} catch { } catch {
console.error(`opfs: cat: ${fileName}: No such file`) console.error(`opfs: cat: ${fileName}: No such file`);
} }
} }
private async opfsDl(fileName: string): Promise<void> { private async opfsDl(fileName: string): Promise<void> {
if (!fileName) { if (!fileName) {
console.error('opfs: dl: missing file name') console.error('opfs: dl: missing file name');
return return;
} }
const dir = await this.opfsResolveCwd() const dir = await this.opfsResolveCwd();
try { try {
const fileHandle = await dir.getFileHandle(fileName) const fileHandle = await dir.getFileHandle(fileName);
const file = await fileHandle.getFile() const file = await fileHandle.getFile();
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file);
const a = document.createElement('a') const a = document.createElement('a');
a.href = url a.href = url;
a.download = fileName a.download = fileName;
document.body.appendChild(a) document.body.appendChild(a);
a.click() a.click();
document.body.removeChild(a) document.body.removeChild(a);
URL.revokeObjectURL(url) URL.revokeObjectURL(url);
console.log(`downloaded: ${fileName} (${file.size} bytes)`) console.log(`downloaded: ${fileName} (${file.size} bytes)`);
} catch { } catch {
console.error(`opfs: dl: ${fileName}: No such file`) console.error(`opfs: dl: ${fileName}: No such file`);
} }
} }
private async opfsRm(name: string): Promise<void> { private async opfsRm(name: string): Promise<void> {
if (!name) { if (!name) {
console.error('opfs: rm: missing file or directory name') console.error('opfs: rm: missing file or directory name');
return return;
} }
const dir = await this.opfsResolveCwd() const dir = await this.opfsResolveCwd();
try { try {
await dir.removeEntry(name, { recursive: true }) await dir.removeEntry(name, { recursive: true });
console.log(`removed: ${name}`) console.log(`removed: ${name}`);
} catch { } catch {
console.error(`opfs: rm: ${name}: No such file or directory`) console.error(`opfs: rm: ${name}: No such file or directory`);
} }
} }
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export class KGProject {
@WithDefault(0) @WithDefault(0)
private projectStructureVersion: number = 0; private projectStructureVersion: number = 0;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6; public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 7;
@Expose() @Expose()
@Type(() => KGTrack, { @Type(() => KGTrack, {
+4 -11
View File
@@ -283,9 +283,8 @@ export class KGAudioBus {
*/ */
private updateSamplerVolume(): void { private updateSamplerVolume(): void {
try { try {
const effectiveVolume = this.muted ? 0 : this.volume; const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity; this.sampler.volume.value = isSilent ? -Infinity : this.volume;
this.sampler.volume.value = volumeDb;
} catch (error) { } catch (error) {
console.error(`Error updating volume for ${this.instrument}:`, error); console.error(`Error updating volume for ${this.instrument}:`, error);
} }
@@ -297,14 +296,8 @@ export class KGAudioBus {
*/ */
public applyEffectiveVolume(hasSoloedTracks: boolean): void { public applyEffectiveVolume(hasSoloedTracks: boolean): void {
try { try {
let effectiveVolume = this.volume; const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
if (this.muted) { this.sampler.volume.value = isSilent ? -Infinity : this.volume;
effectiveVolume = 0;
} else if (hasSoloedTracks && !this.solo) {
effectiveVolume = 0;
}
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
this.sampler.volume.value = volumeDb;
} catch (error) { } catch (error) {
console.error(`Error applying effective volume for ${this.instrument}:`, error); console.error(`Error applying effective volume for ${this.instrument}:`, error);
} }
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createMockProject } from '../../test/utils/mock-data';
import { MockTransport } from '../../test/mocks/tone';
vi.mock('tone', async () => {
const { ToneMock } = await import('../../test/mocks/tone');
return ToneMock;
});
vi.mock('../KGCore', () => ({
KGCore: {
instance: vi.fn()
}
}));
vi.mock('../config/ConfigManager', () => ({
ConfigManager: {
instance: vi.fn()
}
}));
import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager';
import { KGAudioInterface } from './KGAudioInterface';
describe('KGAudioInterface preroll playback', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
vi.clearAllMocks();
MockTransport.position = 0;
MockTransport.start.mockClear();
MockTransport.stop.mockClear();
MockTransport.clear.mockClear();
MockTransport.schedule.mockClear();
MockTransport.bpm.value = 120;
const project = createMockProject({
bpm: 120,
timeSignature: { numerator: 4, denominator: 4 },
tracks: [],
});
vi.mocked(KGCore.instance).mockReturnValue({
getCurrentProject: () => project,
} as unknown as KGCore);
vi.mocked(ConfigManager.instance).mockReturnValue({
get: (key: string) => {
if (key === 'audio.playback_delay') return 0.2;
if (key === 'audio.lookahead_time') return 0.05;
return null;
},
} as unknown as ConfigManager)
;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null;
});
it('uses virtual negative beats until the delayed transport start reaches beat 0', () => {
const project = KGCore.instance().getCurrentProject();
const audio = KGAudioInterface.instance()
;(audio as unknown as { isInitialized: boolean }).isInitialized = true
;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true;
const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start');
audio.setMetronomeEnabled(true);
audio.preparePlayback(project, -2);
audio.startPlayback();
expect(MockTransport.position).toBe(0);
expect(MockTransport.start).not.toHaveBeenCalled();
expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2);
expect(audio.getTransportPosition()).toBeCloseTo(-2, 2);
vi.advanceTimersByTime(500);
expect(audio.getTransportPosition()).toBeCloseTo(-1, 1);
vi.advanceTimersByTime(500);
expect(MockTransport.start).toHaveBeenCalledTimes(1);
expect(audio.getTransportPosition()).toBe(0);
});
it('cancels the delayed transport start when playback stops during preroll', () => {
const project = KGCore.instance().getCurrentProject();
const audio = KGAudioInterface.instance()
;(audio as unknown as { isInitialized: boolean }).isInitialized = true
;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true;
audio.preparePlayback(project, -2);
audio.startPlayback();
audio.stopPlayback();
vi.runAllTimers();
expect(MockTransport.start).not.toHaveBeenCalled();
expect(MockTransport.stop).toHaveBeenCalledTimes(1);
expect(audio.getTransportPosition()).toBe(0);
});
it('allows a first-pass start before the loop start when explicitly requested', () => {
const project = createMockProject({
bpm: 120,
timeSignature: { numerator: 4, denominator: 4 },
tracks: [],
});
project.setIsLooping(true);
project.setLoopingRange([4, 7]);
const audio = KGAudioInterface.instance();
audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true });
expect(MockTransport.position).toBe(6);
});
});
+57 -6
View File
@@ -12,6 +12,10 @@ import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager'; import { ConfigManager } from '../config/ConfigManager';
import { KGMetronome } from './KGMetronome'; import { KGMetronome } from './KGMetronome';
interface PreparePlaybackOptions {
allowStartBeforeLoopStart?: boolean;
}
/** /**
* KGAudioInterface - Audio engine interface for the DAW * KGAudioInterface - Audio engine interface for the DAW
* Implements the singleton pattern for global audio management * Implements the singleton pattern for global audio management
@@ -42,6 +46,10 @@ export class KGAudioInterface {
private isPlaying: boolean = false; private isPlaying: boolean = false;
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME; private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
private scheduledEvents: Set<number> = new Set(); // Tone event IDs private scheduledEvents: Set<number> = new Set(); // Tone event IDs
private delayedTransportStartTimeoutId: number | null = null;
private delayedTransportStartSeconds: number = 0;
private virtualPrerollStartBeat: number | null = null;
private virtualPrerollStartAudioTime: number | null = null;
// Master volume control // Master volume control
private masterGain: Tone.Gain | null = null; private masterGain: Tone.Gain | null = null;
@@ -387,9 +395,10 @@ export class KGAudioInterface {
/** /**
* Prepare playback by scheduling all MIDI events * Prepare playback by scheduling all MIDI events
*/ */
public preparePlayback(project: KGProject, startPosition: number): void { public preparePlayback(project: KGProject, startPosition: number, options?: PreparePlaybackOptions): void {
// Clear any existing scheduled events // Clear any existing scheduled events
this.clearScheduledEvents(); this.clearScheduledEvents();
this.clearDelayedTransportStart();
console.log("Preparing playback"); console.log("Preparing playback");
@@ -432,7 +441,7 @@ export class KGAudioInterface {
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`); console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
// Adjust start position to loop start if before loop range // Adjust start position to loop start if before loop range
if (startPosition < scheduleStartBeat) { if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
startPosition = scheduleStartBeat; startPosition = scheduleStartBeat;
} }
} else { } else {
@@ -440,8 +449,18 @@ export class KGAudioInterface {
console.log("Loop mode disabled"); console.log("Loop mode disabled");
} }
if (startPosition < 0) {
this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
this.virtualPrerollStartBeat = startPosition;
this.virtualPrerollStartAudioTime = null;
} else {
this.delayedTransportStartSeconds = 0;
this.virtualPrerollStartBeat = null;
this.virtualPrerollStartAudioTime = null;
}
// Set transport position (convert beats to Tone.js format) // Set transport position (convert beats to Tone.js format)
this.setTransportPosition(startPosition); this.setTransportPosition(Math.max(0, startPosition));
// Start metronome if enabled // Start metronome if enabled
if (this.isMetronomeEnabled) { if (this.isMetronomeEnabled) {
@@ -646,8 +665,19 @@ export class KGAudioInterface {
if (!this.isAudioContextStarted) { if (!this.isAudioContextStarted) {
throw new Error('Audio context not started'); throw new Error('Audio context not started');
} }
Tone.Transport.start(); if (this.delayedTransportStartSeconds > 0 && this.virtualPrerollStartBeat !== null) {
this.virtualPrerollStartAudioTime = Tone.now();
this.delayedTransportStartTimeoutId = Tone.getContext().setTimeout(() => {
this.delayedTransportStartTimeoutId = null;
this.virtualPrerollStartBeat = null;
this.virtualPrerollStartAudioTime = null;
Tone.Transport.start();
}, this.delayedTransportStartSeconds);
} else {
Tone.Transport.start();
}
this.isPlaying = true; this.isPlaying = true;
console.log('Audio playback started'); console.log('Audio playback started');
@@ -662,6 +692,7 @@ export class KGAudioInterface {
*/ */
public stopPlayback(): void { public stopPlayback(): void {
try { try {
this.clearDelayedTransportStart();
Tone.Transport.stop(); Tone.Transport.stop();
this.metronome.stop(); this.metronome.stop();
@@ -789,7 +820,8 @@ export class KGAudioInterface {
public setTransportPosition(position: number): void { public setTransportPosition(position: number): void {
try { try {
// Convert beats to Tone.js time format // Convert beats to Tone.js time format
const toneTime = this.beatsToToneTime(position); const safePosition = Math.max(0, position);
const toneTime = this.beatsToToneTime(safePosition);
Tone.Transport.position = toneTime; Tone.Transport.position = toneTime;
console.log(`Set transport position to ${position} beats (${toneTime})`); console.log(`Set transport position to ${position} beats (${toneTime})`);
} catch (error) { } catch (error) {
@@ -802,6 +834,14 @@ export class KGAudioInterface {
*/ */
public getTransportPosition(): number { public getTransportPosition(): number {
try { try {
if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartAudioTime !== null) {
const project = KGCore.instance().getCurrentProject();
const secondsPerBeat = 60 / project.getBpm();
const elapsedSeconds = Math.max(0, Tone.now() - this.virtualPrerollStartAudioTime);
const elapsedBeats = elapsedSeconds / secondsPerBeat;
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
}
const position = Tone.Transport.position; const position = Tone.Transport.position;
return this.toneTimeToBeats(position); return this.toneTimeToBeats(position);
} catch (error) { } catch (error) {
@@ -973,6 +1013,17 @@ export class KGAudioInterface {
return this.captureStream; return this.captureStream;
} }
private clearDelayedTransportStart(): void {
if (this.delayedTransportStartTimeoutId !== null) {
Tone.getContext().clearTimeout(this.delayedTransportStartTimeoutId);
this.delayedTransportStartTimeoutId = null;
}
this.delayedTransportStartSeconds = 0;
this.virtualPrerollStartBeat = null;
this.virtualPrerollStartAudioTime = null;
}
// ===== PRIVATE UTILITY METHODS ===== // ===== PRIVATE UTILITY METHODS =====
/** /**
+4 -11
View File
@@ -200,14 +200,8 @@ export class KGAudioPlayerBus {
*/ */
public applyEffectiveVolume(hasSoloedTracks: boolean): void { public applyEffectiveVolume(hasSoloedTracks: boolean): void {
try { try {
let effectiveVolume = this.volume; const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
if (this.muted) { this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
effectiveVolume = 0;
} else if (hasSoloedTracks && !this.solo) {
effectiveVolume = 0;
}
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
this.gainNode.gain.value = Math.pow(10, volumeDb / 20);
} catch (error) { } catch (error) {
console.error('Error applying effective volume for audio player bus:', error); console.error('Error applying effective volume for audio player bus:', error);
} }
@@ -266,9 +260,8 @@ export class KGAudioPlayerBus {
private updateGainVolume(): void { private updateGainVolume(): void {
try { try {
const effectiveVolume = this.muted ? 0 : this.volume; const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
// Convert linear volume to gain value this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
this.gainNode.gain.value = effectiveVolume;
} catch (error) { } catch (error) {
console.error('Error updating gain volume:', error); console.error('Error updating gain volume:', error);
} }
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MockLoop, MockTransport } from '../../test/mocks/tone';
vi.mock('tone', async () => {
const { ToneMock: toneMock } = await import('../../test/mocks/tone');
return toneMock;
});
import { KGMetronome } from './KGMetronome';
describe('KGMetronome', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
vi.clearAllMocks();
MockTransport.bpm.value = 120;
MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ);
});
it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => {
const metronome = new KGMetronome();
const triggerAttackRelease = vi.fn()
;(metronome as unknown as { sampler: unknown }).sampler = {
loaded: true,
triggerAttackRelease,
};
metronome.start(-4, 4, 0.2);
vi.advanceTimersByTime(200);
expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5');
expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n');
expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5);
vi.advanceTimersByTime(1000);
expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4');
expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n');
expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5);
const transportLoop = MockLoop.mock.results[0]?.value;
expect(transportLoop).toBeDefined();
transportLoop.callback(0);
expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2);
});
it('cancels pending preroll clicks when stopped', () => {
const metronome = new KGMetronome();
const triggerAttackRelease = vi.fn()
;(metronome as unknown as { sampler: unknown }).sampler = {
loaded: true,
triggerAttackRelease,
};
metronome.start(-2, 4, 0.2);
metronome.stop();
vi.runAllTimers();
expect(triggerAttackRelease).not.toHaveBeenCalled();
});
});
+28
View File
@@ -8,6 +8,7 @@ import { KGToneSamplerFactory } from './KGToneSamplerFactory';
export class KGMetronome { export class KGMetronome {
private loop: Tone.Loop | null = null; private loop: Tone.Loop | null = null;
private sampler: Tone.Sampler | null = null; private sampler: Tone.Sampler | null = null;
private prerollTimeoutIds: number[] = [];
/** /**
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() * Load the woodblock sampler. Called once from KGAudioInterface.initialize()
@@ -34,6 +35,8 @@ export class KGMetronome {
const ppq = Tone.Transport.PPQ; const ppq = Tone.Transport.PPQ;
this.schedulePrerollClicks(startPositionBeats, beatsPerBar, playbackDelay);
this.loop = new Tone.Loop((time) => { this.loop = new Tone.Loop((time) => {
if (this.sampler?.loaded) { if (this.sampler?.loaded) {
// Derive bar position from the exact Transport tick count at the // Derive bar position from the exact Transport tick count at the
@@ -52,6 +55,10 @@ export class KGMetronome {
/** Stop and dispose the loop only — sampler is kept alive for reuse. */ /** Stop and dispose the loop only — sampler is kept alive for reuse. */
stop(): void { stop(): void {
const context = Tone.getContext();
this.prerollTimeoutIds.forEach(timeoutId => context.clearTimeout(timeoutId));
this.prerollTimeoutIds = [];
if (this.loop) { if (this.loop) {
this.loop.dispose(); this.loop.dispose();
this.loop = null; this.loop = null;
@@ -65,4 +72,25 @@ export class KGMetronome {
this.sampler = null; this.sampler = null;
} }
} }
private schedulePrerollClicks(startPositionBeats: number, beatsPerBar: number, playbackDelay: number): void {
if (startPositionBeats >= 0 || !this.sampler?.loaded) {
return;
}
const secondsPerBeat = 60 / Tone.Transport.bpm.value;
const context = Tone.getContext();
const firstBeat = Math.ceil(startPositionBeats);
for (let beat = firstBeat; beat < 0; beat += 1) {
const waitSeconds = Math.max(0, (beat - startPositionBeats) * secondsPerBeat + playbackDelay);
const note = beat % beatsPerBar === 0 ? 'C5' : 'C4';
const timeoutId = context.setTimeout(() => {
if (this.sampler?.loaded) {
this.sampler.triggerAttackRelease(note, '16n', Tone.now());
}
}, waitSeconds);
this.prerollTimeoutIds.push(timeoutId);
}
}
} }
@@ -1,34 +1,34 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CreateNoteCommand } from './CreateNoteCommand' import { CreateNoteCommand } from './CreateNoteCommand';
import { KGCore } from '../../KGCore' import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote' import { KGMidiNote } from '../../midi/KGMidiNote';
import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data' import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data';
// Mock the KGCore singleton // Mock the KGCore singleton
vi.mock('../../KGCore', () => ({ vi.mock('../../KGCore', () => ({
KGCore: { KGCore: {
instance: vi.fn() instance: vi.fn()
} }
})) }));
// Mock generateUniqueId utility // Mock generateUniqueId utility
vi.mock('../../../util/miscUtil', () => ({ vi.mock('../../../util/miscUtil', () => ({
generateUniqueId: vi.fn().mockReturnValue('mock-note-id') generateUniqueId: vi.fn().mockReturnValue('mock-note-id')
})) }));
// Import the mocked function properly // Import the mocked function properly
const { generateUniqueId } = await import('../../../util/miscUtil') const { generateUniqueId } = await import('../../../util/miscUtil');
interface MockCore { interface MockCore {
getCurrentProject: ReturnType<typeof vi.fn> getCurrentProject: ReturnType<typeof vi.fn>
} }
describe('CreateNoteCommand', () => { describe('CreateNoteCommand', () => {
let mockCore: MockCore let mockCore: MockCore;
let mockProject: ReturnType<typeof createMockProject> let mockProject: ReturnType<typeof createMockProject>;
let mockTrack: ReturnType<typeof createMockMidiTrack> let mockTrack: ReturnType<typeof createMockMidiTrack>;
let mockRegion: ReturnType<typeof createMockMidiRegion> let mockRegion: ReturnType<typeof createMockMidiRegion>;
let command: CreateNoteCommand let command: CreateNoteCommand;
beforeEach(() => { beforeEach(() => {
// Create test data // Create test data
@@ -36,25 +36,25 @@ describe('CreateNoteCommand', () => {
id: 'test-region', id: 'test-region',
trackId: 'test-track', trackId: 'test-track',
name: 'Test Region' name: 'Test Region'
}) });
mockTrack = createMockMidiTrack({ mockTrack = createMockMidiTrack({
id: 1, id: 1,
name: 'Test Track', name: 'Test Track',
regions: [mockRegion] regions: [mockRegion]
}) });
mockProject = createMockProject({ mockProject = createMockProject({
name: 'Test Project', name: 'Test Project',
tracks: [mockTrack] tracks: [mockTrack]
}) });
// Mock KGCore methods // Mock KGCore methods
mockCore = { mockCore = {
getCurrentProject: vi.fn().mockReturnValue(mockProject) getCurrentProject: vi.fn().mockReturnValue(mockProject)
} };
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore) vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
// Create command // Create command
command = new CreateNoteCommand( command = new CreateNoteCommand(
@@ -63,135 +63,135 @@ describe('CreateNoteCommand', () => {
1, // endBeat 1, // endBeat
60, // pitch (middle C) 60, // pitch (middle C)
80 // velocity 80 // velocity
) );
}) });
describe('constructor', () => { describe('constructor', () => {
it('should create command with correct parameters', () => { it('should create command with correct parameters', () => {
const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id') const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id');
expect(cmd).toBeInstanceOf(CreateNoteCommand) expect(cmd).toBeInstanceOf(CreateNoteCommand);
// We can't directly test private properties, but we can test execution // We can't directly test private properties, but we can test execution
}) });
it('should generate unique ID when not provided', () => { it('should generate unique ID when not provided', () => {
new CreateNoteCommand('region-1', 0, 1, 60, 80) new CreateNoteCommand('region-1', 0, 1, 60, 80);
// The generateUniqueId mock should have been called // The generateUniqueId mock should have been called
expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote') expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote');
}) });
it('should use provided ID when given', () => { it('should use provided ID when given', () => {
// Clear previous calls // Clear previous calls
vi.clearAllMocks() vi.clearAllMocks();
new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id') new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id');
// Should not call generateUniqueId when ID is provided // Should not call generateUniqueId when ID is provided
expect(generateUniqueId).not.toHaveBeenCalled() expect(generateUniqueId).not.toHaveBeenCalled();
}) });
}) });
describe('execute', () => { describe('execute', () => {
it('should create a note in the target region', () => { it('should create a note in the target region', () => {
// Mock the addNote method // Mock the addNote method
const addNoteSpy = vi.spyOn(mockRegion, 'addNote') const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
// Execute the command // Execute the command
command.execute() command.execute();
// Verify note was added // Verify note was added
expect(addNoteSpy).toHaveBeenCalledTimes(1) expect(addNoteSpy).toHaveBeenCalledTimes(1);
// Verify the note has correct properties // Verify the note has correct properties
const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote;
expect(addedNote).toBeInstanceOf(KGMidiNote) expect(addedNote).toBeInstanceOf(KGMidiNote);
expect(addedNote.getStartBeat()).toBe(0) expect(addedNote.getStartBeat()).toBe(0);
expect(addedNote.getEndBeat()).toBe(1) expect(addedNote.getEndBeat()).toBe(1);
expect(addedNote.getPitch()).toBe(60) expect(addedNote.getPitch()).toBe(60);
expect(addedNote.getVelocity()).toBe(80) expect(addedNote.getVelocity()).toBe(80);
}) });
it('should throw error for non-existent region', () => { it('should throw error for non-existent region', () => {
// Create command for non-existent region // Create command for non-existent region
const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80) const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80);
// Should throw error for non-existent region // Should throw error for non-existent region
expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found') expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found');
}) });
it('should store created note for undo operation', () => { it('should store created note for undo operation', () => {
const addNoteSpy = vi.spyOn(mockRegion, 'addNote') const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
command.execute() command.execute();
// Note should be created and stored internally for undo // Note should be created and stored internally for undo
expect(addNoteSpy).toHaveBeenCalledTimes(1) expect(addNoteSpy).toHaveBeenCalledTimes(1);
}) });
}) });
describe('undo', () => { describe('undo', () => {
it('should remove the created note', () => { it('should remove the created note', () => {
// Execute first to create the note // Execute first to create the note
command.execute() command.execute();
// Mock removeNote method // Mock removeNote method
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote') const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
// Undo the command // Undo the command
command.undo() command.undo();
// Verify note was removed // Verify note was removed
expect(removeNoteSpy).toHaveBeenCalledTimes(1) expect(removeNoteSpy).toHaveBeenCalledTimes(1);
}) });
it('should throw error when undoing without execute', () => { it('should throw error when undoing without execute', () => {
// Try to undo without executing first // Try to undo without executing first
expect(() => command.undo()).toThrow('Cannot undo: no note was created') expect(() => command.undo()).toThrow('Cannot undo: no note was created');
}) });
}) });
describe('re-execute (redo pattern)', () => { describe('re-execute (redo pattern)', () => {
it('should re-add the note after undo using execute', () => { it('should re-add the note after undo using execute', () => {
// Execute, undo, then execute again (redo pattern) // Execute, undo, then execute again (redo pattern)
command.execute() command.execute();
command.undo() command.undo();
const addNoteSpy = vi.spyOn(mockRegion, 'addNote') const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
command.execute() // Commands are re-executed for redo command.execute(); // Commands are re-executed for redo
// Note should be added again // Note should be added again
expect(addNoteSpy).toHaveBeenCalledTimes(1) expect(addNoteSpy).toHaveBeenCalledTimes(1);
}) });
}) });
describe('getDescription', () => { describe('getDescription', () => {
it('should return descriptive text', () => { it('should return descriptive text', () => {
const description = command.getDescription() const description = command.getDescription();
expect(description).toBeDefined() expect(description).toBeDefined();
expect(typeof description).toBe('string') expect(typeof description).toBe('string');
expect(description.length).toBeGreaterThan(0) expect(description.length).toBeGreaterThan(0);
}) });
}) });
describe('command lifecycle', () => { describe('command lifecycle', () => {
it('should support multiple execute/undo cycles', () => { it('should support multiple execute/undo cycles', () => {
const addNoteSpy = vi.spyOn(mockRegion, 'addNote') const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote') const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
// Execute -> Undo -> Execute -> Undo // Execute -> Undo -> Execute -> Undo
command.execute() command.execute();
expect(addNoteSpy).toHaveBeenCalledTimes(1) expect(addNoteSpy).toHaveBeenCalledTimes(1);
command.undo() command.undo();
expect(removeNoteSpy).toHaveBeenCalledTimes(1) expect(removeNoteSpy).toHaveBeenCalledTimes(1);
command.execute() // Re-execute for redo command.execute(); // Re-execute for redo
expect(addNoteSpy).toHaveBeenCalledTimes(2) expect(addNoteSpy).toHaveBeenCalledTimes(2);
command.undo() command.undo();
expect(removeNoteSpy).toHaveBeenCalledTimes(2) expect(removeNoteSpy).toHaveBeenCalledTimes(2);
}) });
}) });
}) });
@@ -47,9 +47,8 @@ class MockFileHandle {
async getFile() { return { text: () => Promise.resolve(this._content) }; } async getFile() { return { text: () => Promise.resolve(this._content) }; }
async createWritable() { async createWritable() {
const w = new MockWritable(); const w = new MockWritable();
const self = this;
const origClose = w.close.bind(w); const origClose = w.close.bind(w);
w.close = async () => { self._content = w.data; await origClose(); }; w.close = async () => { this._content = w.data; await origClose(); };
return w; return w;
} }
} }
+3 -1
View File
@@ -79,6 +79,7 @@ interface AppConfig {
enable_audio_capture_for_screen_sharing: boolean; enable_audio_capture_for_screen_sharing: boolean;
lookahead_time: number; lookahead_time: number;
playback_delay: number; playback_delay: number;
recording_offset: number;
}; };
templates: { templates: {
custom_instructions: string; custom_instructions: string;
@@ -250,7 +251,8 @@ export class ConfigManager {
audio: { audio: {
enable_audio_capture_for_screen_sharing: false, enable_audio_capture_for_screen_sharing: false,
lookahead_time: 0.05, lookahead_time: 0.05,
playback_delay: 0.2 playback_delay: 0.2,
recording_offset: 0
}, },
templates: { templates: {
custom_instructions: '' custom_instructions: ''
+1 -2
View File
@@ -19,10 +19,9 @@ class MockFileSystemFileHandle {
async createWritable() { async createWritable() {
const stream = new MockFileSystemWritableFileStream(); const stream = new MockFileSystemWritableFileStream();
// When stream closes, update our content // When stream closes, update our content
const self = this;
const origClose = stream.close.bind(stream); const origClose = stream.close.bind(stream);
stream.close = async () => { stream.close = async () => {
self._content = stream.data; this._content = stream.data;
await origClose(); await origClose();
}; };
return stream; return stream;
+16
View File
@@ -15,6 +15,10 @@ export class KGMidiInput {
private isInitialized: boolean = false; private isInitialized: boolean = false;
private connectedInputs: Map<string, MIDIInput> = new Map(); private connectedInputs: Map<string, MIDIInput> = new Map();
// Recording callbacks
private onRecordNoteOn: ((pitch: number) => void) | null = null;
private onRecordNoteOff: ((pitch: number) => void) | null = null;
// Private constructor to prevent direct instantiation // Private constructor to prevent direct instantiation
private constructor() { private constructor() {
console.log("KGMidiInput initialized"); console.log("KGMidiInput initialized");
@@ -161,11 +165,13 @@ export class KGMidiInput {
if (command === 0x90 && velocity > 0) { if (command === 0x90 && velocity > 0) {
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`); console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
this.triggerNoteOn(pitch, velocity); this.triggerNoteOn(pitch, velocity);
this.onRecordNoteOn?.(pitch);
} }
// Note Off: command = 0x80 (128) or Note On with velocity 0 // Note Off: command = 0x80 (128) or Note On with velocity 0
else if (command === 0x80 || (command === 0x90 && velocity === 0)) { else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`); console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
this.triggerNoteOff(pitch); this.triggerNoteOff(pitch);
this.onRecordNoteOff?.(pitch);
} }
// Control Change: command = 0xB0 (176) // Control Change: command = 0xB0 (176)
else if (command === 0xb0) { else if (command === 0xb0) {
@@ -264,6 +270,16 @@ export class KGMidiInput {
} }
} }
// ===== RECORDING =====
public setRecordingCallbacks(
onNoteOn: ((pitch: number) => void) | null,
onNoteOff: ((pitch: number) => void) | null
): void {
this.onRecordNoteOn = onNoteOn;
this.onRecordNoteOff = onNoteOff;
}
// ===== GETTERS ===== // ===== GETTERS =====
public getIsInitialized(): boolean { public getIsInitialized(): boolean {
+89 -89
View File
@@ -1,151 +1,151 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest';
import { KGMidiNote } from './KGMidiNote' import { KGMidiNote } from './KGMidiNote';
describe('KGMidiNote', () => { describe('KGMidiNote', () => {
let note: KGMidiNote let note: KGMidiNote;
beforeEach(() => { beforeEach(() => {
note = new KGMidiNote('test-note', 0, 1, 60, 80) note = new KGMidiNote('test-note', 0, 1, 60, 80);
}) });
describe('constructor', () => { describe('constructor', () => {
it('should create a note with correct properties', () => { it('should create a note with correct properties', () => {
const testNote = new KGMidiNote('note-1', 2, 4, 72, 100) const testNote = new KGMidiNote('note-1', 2, 4, 72, 100);
expect(testNote.getId()).toBe('note-1') expect(testNote.getId()).toBe('note-1');
expect(testNote.getStartBeat()).toBe(2) expect(testNote.getStartBeat()).toBe(2);
expect(testNote.getEndBeat()).toBe(4) expect(testNote.getEndBeat()).toBe(4);
expect(testNote.getPitch()).toBe(72) expect(testNote.getPitch()).toBe(72);
expect(testNote.getVelocity()).toBe(100) expect(testNote.getVelocity()).toBe(100);
}) });
it('should use default values when not provided', () => { it('should use default values when not provided', () => {
const defaultNote = new KGMidiNote('default-note') const defaultNote = new KGMidiNote('default-note');
expect(defaultNote.getId()).toBe('default-note') expect(defaultNote.getId()).toBe('default-note');
expect(defaultNote.getStartBeat()).toBe(0) expect(defaultNote.getStartBeat()).toBe(0);
expect(defaultNote.getEndBeat()).toBe(0) expect(defaultNote.getEndBeat()).toBe(0);
expect(defaultNote.getPitch()).toBe(0) expect(defaultNote.getPitch()).toBe(0);
expect(defaultNote.getVelocity()).toBe(127) expect(defaultNote.getVelocity()).toBe(127);
}) });
}) });
describe('getters and setters', () => { describe('getters and setters', () => {
it('should get and set start beat', () => { it('should get and set start beat', () => {
expect(note.getStartBeat()).toBe(0) expect(note.getStartBeat()).toBe(0);
note.setStartBeat(1.5) note.setStartBeat(1.5);
expect(note.getStartBeat()).toBe(1.5) expect(note.getStartBeat()).toBe(1.5);
}) });
it('should get and set end beat', () => { it('should get and set end beat', () => {
expect(note.getEndBeat()).toBe(1) expect(note.getEndBeat()).toBe(1);
note.setEndBeat(3.5) note.setEndBeat(3.5);
expect(note.getEndBeat()).toBe(3.5) expect(note.getEndBeat()).toBe(3.5);
}) });
it('should get and set pitch', () => { it('should get and set pitch', () => {
expect(note.getPitch()).toBe(60) expect(note.getPitch()).toBe(60);
note.setPitch(72) note.setPitch(72);
expect(note.getPitch()).toBe(72) expect(note.getPitch()).toBe(72);
}) });
it('should get and set velocity', () => { it('should get and set velocity', () => {
expect(note.getVelocity()).toBe(80) expect(note.getVelocity()).toBe(80);
note.setVelocity(100) note.setVelocity(100);
expect(note.getVelocity()).toBe(100) expect(note.getVelocity()).toBe(100);
}) });
it('should get and set ID', () => { it('should get and set ID', () => {
expect(note.getId()).toBe('test-note') expect(note.getId()).toBe('test-note');
note.setId('new-id') note.setId('new-id');
expect(note.getId()).toBe('new-id') expect(note.getId()).toBe('new-id');
}) });
}) });
describe('selection', () => { describe('selection', () => {
it('should start unselected', () => { it('should start unselected', () => {
expect(note.isSelected()).toBe(false) expect(note.isSelected()).toBe(false);
}) });
it('should select and deselect', () => { it('should select and deselect', () => {
note.select() note.select();
expect(note.isSelected()).toBe(true) expect(note.isSelected()).toBe(true);
note.deselect() note.deselect();
expect(note.isSelected()).toBe(false) expect(note.isSelected()).toBe(false);
}) });
}) });
describe('note duration', () => { describe('note duration', () => {
it('should calculate duration correctly', () => { it('should calculate duration correctly', () => {
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80) const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80);
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2) expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2);
}) });
it('should handle zero duration', () => { it('should handle zero duration', () => {
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80) const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80);
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0) expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0);
}) });
}) });
describe('pitch validation', () => { describe('pitch validation', () => {
it('should accept valid MIDI pitch range', () => { it('should accept valid MIDI pitch range', () => {
// MIDI pitch range is typically 0-127 // MIDI pitch range is typically 0-127
note.setPitch(0) note.setPitch(0);
expect(note.getPitch()).toBe(0) expect(note.getPitch()).toBe(0);
note.setPitch(127) note.setPitch(127);
expect(note.getPitch()).toBe(127) expect(note.getPitch()).toBe(127);
note.setPitch(60) // Middle C note.setPitch(60); // Middle C
expect(note.getPitch()).toBe(60) expect(note.getPitch()).toBe(60);
}) });
}) });
describe('velocity validation', () => { describe('velocity validation', () => {
it('should accept valid MIDI velocity range', () => { it('should accept valid MIDI velocity range', () => {
// MIDI velocity range is typically 0-127 // MIDI velocity range is typically 0-127
note.setVelocity(0) note.setVelocity(0);
expect(note.getVelocity()).toBe(0) expect(note.getVelocity()).toBe(0);
note.setVelocity(127) note.setVelocity(127);
expect(note.getVelocity()).toBe(127) expect(note.getVelocity()).toBe(127);
note.setVelocity(64) // Mid velocity note.setVelocity(64); // Mid velocity
expect(note.getVelocity()).toBe(64) expect(note.getVelocity()).toBe(64);
}) });
}) });
describe('clone and comparison', () => { describe('clone and comparison', () => {
it('should create independent instances', () => { it('should create independent instances', () => {
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80) const note1 = new KGMidiNote('note-1', 0, 1, 60, 80);
const note2 = new KGMidiNote('note-2', 0, 1, 60, 80) const note2 = new KGMidiNote('note-2', 0, 1, 60, 80);
expect(note1.getId()).not.toBe(note2.getId()) expect(note1.getId()).not.toBe(note2.getId());
note1.setPitch(72) note1.setPitch(72);
expect(note2.getPitch()).toBe(60) // Should remain unchanged expect(note2.getPitch()).toBe(60); // Should remain unchanged
}) });
}) });
describe('edge cases', () => { describe('edge cases', () => {
it('should handle negative start beat', () => { it('should handle negative start beat', () => {
note.setStartBeat(-1) note.setStartBeat(-1);
expect(note.getStartBeat()).toBe(-1) expect(note.getStartBeat()).toBe(-1);
}) });
it('should handle start beat after end beat', () => { it('should handle start beat after end beat', () => {
note.setStartBeat(5) note.setStartBeat(5);
note.setEndBeat(2) note.setEndBeat(2);
expect(note.getStartBeat()).toBe(5) expect(note.getStartBeat()).toBe(5);
expect(note.getEndBeat()).toBe(2) expect(note.getEndBeat()).toBe(2);
// Note: The class might need validation logic to prevent this // Note: The class might need validation logic to prevent this
}) });
}) });
}) });
@@ -5,6 +5,7 @@ import { upgradeToV3 } from './upgradeToV3';
import { upgradeToV4 } from './upgradeToV4'; import { upgradeToV4 } from './upgradeToV4';
import { upgradeToV5 } from './upgradeToV5'; import { upgradeToV5 } from './upgradeToV5';
import { upgradeToV6 } from './upgradeToV6'; import { upgradeToV6 } from './upgradeToV6';
import { upgradeToV7 } from './upgradeToV7';
/** /**
* Upgrade the given project to the latest structure version, one version at a time. * Upgrade the given project to the latest structure version, one version at a time.
@@ -48,6 +49,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV6(workingProject); workingProject = upgradeToV6(workingProject);
break; break;
} }
case 7: {
workingProject = upgradeToV7(workingProject);
break;
}
default: { default: {
// If an upgrader is missing, throw to prevent loading incompatible structures // If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`); throw new Error(`No upgrader found for project structure version ${nextVersion}`);
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { KGProject } from '../KGProject';
import { KGMidiTrack } from '../track/KGMidiTrack';
import { upgradeToV7 } from './upgradeToV7';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
function makeProject(volumes: number[]): KGProject {
const tracks = volumes.map((v, i) => {
const track = new KGMidiTrack(`Track ${i}`, i);
// Bypass setVolume clamping to simulate old 01 linear values stored in JSON
(track as unknown as { volume: number }).volume = v;
return track;
});
const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, undefined, tracks, 6);
return project;
}
describe('upgradeToV7', () => {
it('converts 1.0 linear to 0 dB', () => {
const project = makeProject([1.0]);
upgradeToV7(project);
expect(project.getTracks()[0].getVolume()).toBeCloseTo(0, 5);
});
it('converts 0.8 linear to ~1.94 dB', () => {
const project = makeProject([0.8]);
upgradeToV7(project);
expect(project.getTracks()[0].getVolume()).toBeCloseTo(20 * Math.log10(0.8), 3);
});
it('converts 0.5 linear to ~6.02 dB', () => {
const project = makeProject([0.5]);
upgradeToV7(project);
expect(project.getTracks()[0].getVolume()).toBeCloseTo(-6.021, 2);
});
it('converts 0.0 linear to MIN_TRACK_VOLUME_DB', () => {
const project = makeProject([0.0]);
upgradeToV7(project);
expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB);
});
it('clamps values that exceed MAX_TRACK_VOLUME_DB', () => {
// linear > 1.0 would give positive dB; cap at +6
const project = makeProject([2.0]);
upgradeToV7(project);
expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB);
});
it('bumps project structure version to 7', () => {
const project = makeProject([1.0]);
upgradeToV7(project);
expect(project.getProjectStructureVersion()).toBe(7);
});
it('handles multiple tracks independently', () => {
const project = makeProject([1.0, 0.5, 0.0]);
upgradeToV7(project);
const tracks = project.getTracks();
expect(tracks[0].getVolume()).toBeCloseTo(0, 5);
expect(tracks[1].getVolume()).toBeCloseTo(-6.021, 2);
expect(tracks[2].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { KGProject } from '../KGProject';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
export function upgradeToV7(project: KGProject): KGProject {
try {
// Convert track volumes from 01 linear scale to dB
for (const track of project.getTracks()) {
const linear = track.getVolume();
let db: number;
if (linear <= 0) {
db = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
} else {
db = 20 * Math.log10(linear);
db = Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, db));
}
track.setVolume(db);
}
} finally {
project.setProjectStructureVersion(7);
}
return project;
}
+210 -210
View File
@@ -1,11 +1,11 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest';
import { KGMidiRegion } from './KGMidiRegion' import { KGMidiRegion } from './KGMidiRegion';
import { KGRegion } from './KGRegion' import { KGRegion } from './KGRegion';
import { KGMidiNote } from '../midi/KGMidiNote' import { KGMidiNote } from '../midi/KGMidiNote';
import { createMockMidiNote } from '../../test/utils/mock-data' import { createMockMidiNote } from '../../test/utils/mock-data';
describe('KGMidiRegion', () => { describe('KGMidiRegion', () => {
let region: KGMidiRegion let region: KGMidiRegion;
beforeEach(() => { beforeEach(() => {
region = new KGMidiRegion( region = new KGMidiRegion(
@@ -15,8 +15,8 @@ describe('KGMidiRegion', () => {
'Test Region', 'Test Region',
0, 0,
4 4
) );
}) });
describe('constructor', () => { describe('constructor', () => {
it('should create a MIDI region with correct properties', () => { it('should create a MIDI region with correct properties', () => {
@@ -27,327 +27,327 @@ describe('KGMidiRegion', () => {
'My Region', 'My Region',
4, 4,
8 8
) );
expect(testRegion.getId()).toBe('region-1') expect(testRegion.getId()).toBe('region-1');
expect(testRegion.getTrackId()).toBe('track-1') expect(testRegion.getTrackId()).toBe('track-1');
expect(testRegion.getTrackIndex()).toBe(2) expect(testRegion.getTrackIndex()).toBe(2);
expect(testRegion.getName()).toBe('My Region') expect(testRegion.getName()).toBe('My Region');
expect(testRegion.getStartFromBeat()).toBe(4) expect(testRegion.getStartFromBeat()).toBe(4);
expect(testRegion.getLength()).toBe(8) expect(testRegion.getLength()).toBe(8);
expect(testRegion.getNotes()).toEqual([]) expect(testRegion.getNotes()).toEqual([]);
}) });
it('should use default values for optional parameters', () => { it('should use default values for optional parameters', () => {
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region') const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region');
expect(defaultRegion.getStartFromBeat()).toBe(0) expect(defaultRegion.getStartFromBeat()).toBe(0);
expect(defaultRegion.getLength()).toBe(0) expect(defaultRegion.getLength()).toBe(0);
expect(defaultRegion.getNotes()).toEqual([]) expect(defaultRegion.getNotes()).toEqual([]);
}) });
it('should set the correct type identifier', () => { it('should set the correct type identifier', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion') expect(region.getCurrentType()).toBe('KGMidiRegion');
}) });
}) });
describe('note management', () => { describe('note management', () => {
let note1: KGMidiNote let note1: KGMidiNote;
let note2: KGMidiNote let note2: KGMidiNote;
let note3: KGMidiNote let note3: KGMidiNote;
beforeEach(() => { beforeEach(() => {
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }) note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 });
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 }) note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 });
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 }) note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 });
}) });
describe('addNote', () => { describe('addNote', () => {
it('should add a single note to empty region', () => { it('should add a single note to empty region', () => {
region.addNote(note1) region.addNote(note1);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(1) expect(notes).toHaveLength(1);
expect(notes[0]).toBe(note1) expect(notes[0]).toBe(note1);
}) });
it('should add multiple notes to region', () => { it('should add multiple notes to region', () => {
region.addNote(note1) region.addNote(note1);
region.addNote(note2) region.addNote(note2);
region.addNote(note3) region.addNote(note3);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(3) expect(notes).toHaveLength(3);
expect(notes).toContain(note1) expect(notes).toContain(note1);
expect(notes).toContain(note2) expect(notes).toContain(note2);
expect(notes).toContain(note3) expect(notes).toContain(note3);
}) });
it('should maintain note order when adding', () => { it('should maintain note order when adding', () => {
region.addNote(note1) region.addNote(note1);
region.addNote(note2) region.addNote(note2);
region.addNote(note3) region.addNote(note3);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes[0]).toBe(note1) expect(notes[0]).toBe(note1);
expect(notes[1]).toBe(note2) expect(notes[1]).toBe(note2);
expect(notes[2]).toBe(note3) expect(notes[2]).toBe(note3);
}) });
it('should allow adding the same note multiple times', () => { it('should allow adding the same note multiple times', () => {
region.addNote(note1) region.addNote(note1);
region.addNote(note1) region.addNote(note1);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(2) expect(notes).toHaveLength(2);
expect(notes[0]).toBe(note1) expect(notes[0]).toBe(note1);
expect(notes[1]).toBe(note1) expect(notes[1]).toBe(note1);
}) });
}) });
describe('removeNote', () => { describe('removeNote', () => {
beforeEach(() => { beforeEach(() => {
region.addNote(note1) region.addNote(note1);
region.addNote(note2) region.addNote(note2);
region.addNote(note3) region.addNote(note3);
}) });
it('should remove note by ID', () => { it('should remove note by ID', () => {
region.removeNote('note-2') region.removeNote('note-2');
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(2) expect(notes).toHaveLength(2);
expect(notes).toContain(note1) expect(notes).toContain(note1);
expect(notes).toContain(note3) expect(notes).toContain(note3);
expect(notes).not.toContain(note2) expect(notes).not.toContain(note2);
}) });
it('should handle removing non-existent note gracefully', () => { it('should handle removing non-existent note gracefully', () => {
const initialLength = region.getNotes().length const initialLength = region.getNotes().length;
region.removeNote('non-existent-note') region.removeNote('non-existent-note');
expect(region.getNotes()).toHaveLength(initialLength) expect(region.getNotes()).toHaveLength(initialLength);
}) });
it('should remove all instances when note ID appears multiple times', () => { it('should remove all instances when note ID appears multiple times', () => {
// Add another note with same ID as note1 // Add another note with same ID as note1
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 }) const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 });
region.addNote(duplicateNote) region.addNote(duplicateNote);
expect(region.getNotes()).toHaveLength(4) expect(region.getNotes()).toHaveLength(4);
region.removeNote('note-1') region.removeNote('note-1');
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(2) expect(notes).toHaveLength(2);
expect(notes).toContain(note2) expect(notes).toContain(note2);
expect(notes).toContain(note3) expect(notes).toContain(note3);
expect(notes).not.toContain(note1) expect(notes).not.toContain(note1);
expect(notes).not.toContain(duplicateNote) expect(notes).not.toContain(duplicateNote);
}) });
it('should handle removing from empty region', () => { it('should handle removing from empty region', () => {
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region') const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region');
expect(() => emptyRegion.removeNote('note-1')).not.toThrow() expect(() => emptyRegion.removeNote('note-1')).not.toThrow();
expect(emptyRegion.getNotes()).toHaveLength(0) expect(emptyRegion.getNotes()).toHaveLength(0);
}) });
}) });
describe('getNotes', () => { describe('getNotes', () => {
it('should return empty array for new region', () => { it('should return empty array for new region', () => {
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toEqual([]) expect(notes).toEqual([]);
expect(notes).toHaveLength(0) expect(notes).toHaveLength(0);
}) });
it('should return all notes in region', () => { it('should return all notes in region', () => {
region.addNote(note1) region.addNote(note1);
region.addNote(note2) region.addNote(note2);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(2) expect(notes).toHaveLength(2);
expect(notes).toEqual([note1, note2]) expect(notes).toEqual([note1, note2]);
}) });
it('should return a reference to the internal notes array', () => { it('should return a reference to the internal notes array', () => {
region.addNote(note1) region.addNote(note1);
const notes1 = region.getNotes() const notes1 = region.getNotes();
const notes2 = region.getNotes() const notes2 = region.getNotes();
expect(notes1).toBe(notes2) // Same reference expect(notes1).toBe(notes2); // Same reference
}) });
}) });
describe('setNotes', () => { describe('setNotes', () => {
it('should replace all notes with new array', () => { it('should replace all notes with new array', () => {
region.addNote(note1) region.addNote(note1);
region.addNote(note2) region.addNote(note2);
expect(region.getNotes()).toHaveLength(2) expect(region.getNotes()).toHaveLength(2);
region.setNotes([note3]) region.setNotes([note3]);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(1) expect(notes).toHaveLength(1);
expect(notes[0]).toBe(note3) expect(notes[0]).toBe(note3);
}) });
it('should allow setting empty notes array', () => { it('should allow setting empty notes array', () => {
region.addNote(note1) region.addNote(note1);
region.addNote(note2) region.addNote(note2);
region.setNotes([]) region.setNotes([]);
expect(region.getNotes()).toHaveLength(0) expect(region.getNotes()).toHaveLength(0);
}) });
it('should accept notes array with multiple notes', () => { it('should accept notes array with multiple notes', () => {
const newNotes = [note1, note2, note3] const newNotes = [note1, note2, note3];
region.setNotes(newNotes) region.setNotes(newNotes);
const retrievedNotes = region.getNotes() const retrievedNotes = region.getNotes();
expect(retrievedNotes).toHaveLength(3) expect(retrievedNotes).toHaveLength(3);
expect(retrievedNotes).toEqual(newNotes) expect(retrievedNotes).toEqual(newNotes);
}) });
}) });
}) });
describe('inheritance from KGRegion', () => { describe('inheritance from KGRegion', () => {
it('should inherit all base region properties', () => { it('should inherit all base region properties', () => {
expect(region.getId()).toBe('test-region-1') expect(region.getId()).toBe('test-region-1');
expect(region.getTrackId()).toBe('test-track-1') expect(region.getTrackId()).toBe('test-track-1');
expect(region.getTrackIndex()).toBe(0) expect(region.getTrackIndex()).toBe(0);
expect(region.getName()).toBe('Test Region') expect(region.getName()).toBe('Test Region');
expect(region.getStartFromBeat()).toBe(0) expect(region.getStartFromBeat()).toBe(0);
expect(region.getLength()).toBe(4) expect(region.getLength()).toBe(4);
}) });
it('should inherit selection functionality', () => { it('should inherit selection functionality', () => {
expect(region.isSelected()).toBe(false) expect(region.isSelected()).toBe(false);
region.select() region.select();
expect(region.isSelected()).toBe(true) expect(region.isSelected()).toBe(true);
region.deselect() region.deselect();
expect(region.isSelected()).toBe(false) expect(region.isSelected()).toBe(false);
}) });
it('should inherit setters from base class', () => { it('should inherit setters from base class', () => {
region.setName('Updated Region') region.setName('Updated Region');
expect(region.getName()).toBe('Updated Region') expect(region.getName()).toBe('Updated Region');
region.setStartFromBeat(8) region.setStartFromBeat(8);
expect(region.getStartFromBeat()).toBe(8) expect(region.getStartFromBeat()).toBe(8);
region.setLength(12) region.setLength(12);
expect(region.getLength()).toBe(12) expect(region.getLength()).toBe(12);
}) });
}) });
describe('type identification', () => { describe('type identification', () => {
it('should return correct current type', () => { it('should return correct current type', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion') expect(region.getCurrentType()).toBe('KGMidiRegion');
}) });
it('should return correct root type', () => { it('should return correct root type', () => {
expect(region.getRootType()).toBe('KGRegion') expect(region.getRootType()).toBe('KGRegion');
}) });
it('should be instanceof both KGMidiRegion and KGRegion', () => { it('should be instanceof both KGMidiRegion and KGRegion', () => {
expect(region).toBeInstanceOf(KGMidiRegion) expect(region).toBeInstanceOf(KGMidiRegion);
expect(region).toBeInstanceOf(KGRegion) expect(region).toBeInstanceOf(KGRegion);
}) });
}) });
describe('edge cases and error handling', () => { describe('edge cases and error handling', () => {
it('should handle notes with overlapping time ranges', () => { it('should handle notes with overlapping time ranges', () => {
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 }) const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 });
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 }) const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 });
region.addNote(overlappingNote1) region.addNote(overlappingNote1);
region.addNote(overlappingNote2) region.addNote(overlappingNote2);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(2) expect(notes).toHaveLength(2);
expect(notes).toContain(overlappingNote1) expect(notes).toContain(overlappingNote1);
expect(notes).toContain(overlappingNote2) expect(notes).toContain(overlappingNote2);
}) });
it('should handle notes with same pitch but different timing', () => { it('should handle notes with same pitch but different timing', () => {
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 }) const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 });
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 }) const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 });
region.addNote(sameNote1) region.addNote(sameNote1);
region.addNote(sameNote2) region.addNote(sameNote2);
expect(region.getNotes()).toHaveLength(2) expect(region.getNotes()).toHaveLength(2);
}) });
it('should handle notes outside region boundaries', () => { it('should handle notes outside region boundaries', () => {
// Region is from beat 0 to 4, but note extends beyond // Region is from beat 0 to 4, but note extends beyond
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 }) const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 });
region.addNote(outsideNote) region.addNote(outsideNote);
const notes = region.getNotes() const notes = region.getNotes();
expect(notes).toHaveLength(1) expect(notes).toHaveLength(1);
expect(notes[0]).toBe(outsideNote) expect(notes[0]).toBe(outsideNote);
// Note: The region doesn't enforce boundary constraints - that's application logic // Note: The region doesn't enforce boundary constraints - that's application logic
}) });
it('should handle zero-length region', () => { it('should handle zero-length region', () => {
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0) const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0);
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 }) const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 });
zeroRegion.addNote(note) zeroRegion.addNote(note);
expect(zeroRegion.getNotes()).toHaveLength(1) expect(zeroRegion.getNotes()).toHaveLength(1);
expect(zeroRegion.getLength()).toBe(0) expect(zeroRegion.getLength()).toBe(0);
}) });
}) });
describe('data consistency', () => { describe('data consistency', () => {
it('should maintain note references correctly', () => { it('should maintain note references correctly', () => {
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 }) const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 });
region.addNote(originalNote) region.addNote(originalNote);
const retrievedNote = region.getNotes()[0] const retrievedNote = region.getNotes()[0];
expect(retrievedNote).toBe(originalNote) // Same reference expect(retrievedNote).toBe(originalNote); // Same reference
// Modify original note // Modify original note
originalNote.setPitch(64) originalNote.setPitch(64);
expect(retrievedNote.getPitch()).toBe(64) // Should reflect change expect(retrievedNote.getPitch()).toBe(64); // Should reflect change
}) });
it('should handle concurrent modifications correctly', () => { it('should handle concurrent modifications correctly', () => {
const notes = [ const notes = [
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }), createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }), createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
createMockMidiNote({ id: 'concurrent-3', pitch: 67 }) createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
] ];
// Add notes // Add notes
notes.forEach(note => region.addNote(note)) notes.forEach(note => region.addNote(note));
expect(region.getNotes()).toHaveLength(3) expect(region.getNotes()).toHaveLength(3);
// Remove middle note // Remove middle note
region.removeNote('concurrent-2') region.removeNote('concurrent-2');
expect(region.getNotes()).toHaveLength(2) expect(region.getNotes()).toHaveLength(2);
// Add new note // Add new note
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 }) const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 });
region.addNote(newNote) region.addNote(newNote);
expect(region.getNotes()).toHaveLength(3) expect(region.getNotes()).toHaveLength(3);
// Verify final state // Verify final state
const finalNotes = region.getNotes() const finalNotes = region.getNotes();
expect(finalNotes).toContain(notes[0]) // concurrent-1 expect(finalNotes).toContain(notes[0]); // concurrent-1
expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed) expect(finalNotes).not.toContain(notes[1]); // concurrent-2 (removed)
expect(finalNotes).toContain(notes[2]) // concurrent-3 expect(finalNotes).toContain(notes[2]); // concurrent-3
expect(finalNotes).toContain(newNote) // concurrent-4 expect(finalNotes).toContain(newNote); // concurrent-4
}) });
}) });
}) });
+208 -202
View File
@@ -1,70 +1,70 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest';
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack' import { KGMidiTrack, type InstrumentType } from './KGMidiTrack';
import { KGTrack, TrackType } from './KGTrack' import { KGTrack, TrackType } from './KGTrack';
import { KGMidiRegion } from '../region/KGMidiRegion' import { KGMidiRegion } from '../region/KGMidiRegion';
import { createMockMidiRegion } from '../../test/utils/mock-data' import { createMockMidiRegion } from '../../test/utils/mock-data';
describe('KGMidiTrack', () => { describe('KGMidiTrack', () => {
let track: KGMidiTrack let track: KGMidiTrack;
beforeEach(() => { beforeEach(() => {
track = new KGMidiTrack() track = new KGMidiTrack();
}) });
describe('constructor', () => { describe('constructor', () => {
it('should create track with default values', () => { it('should create track with default values', () => {
const defaultTrack = new KGMidiTrack() const defaultTrack = new KGMidiTrack();
expect(defaultTrack.getName()).toBe('Untitled MIDI Track') expect(defaultTrack.getName()).toBe('Untitled MIDI Track');
expect(defaultTrack.getId()).toBe(0) expect(defaultTrack.getId()).toBe(0);
expect(defaultTrack.getType()).toBe(TrackType.MIDI) expect(defaultTrack.getType()).toBe(TrackType.MIDI);
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano') expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano');
expect(defaultTrack.getVolume()).toBe(0.8) // DEFAULT_TRACK_VOLUME expect(defaultTrack.getVolume()).toBe(0); // DEFAULT_TRACK_VOLUME (0 dB)
expect(defaultTrack.getRegions()).toEqual([]) expect(defaultTrack.getRegions()).toEqual([]);
}) });
it('should create track with custom parameters', () => { it('should create track with custom parameters', () => {
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6) const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4);
expect(customTrack.getName()).toBe('My Piano Track') expect(customTrack.getName()).toBe('My Piano Track');
expect(customTrack.getId()).toBe(5) expect(customTrack.getId()).toBe(5);
expect(customTrack.getType()).toBe(TrackType.MIDI) expect(customTrack.getType()).toBe(TrackType.MIDI);
expect(customTrack.getInstrument()).toBe('electric_piano_1') expect(customTrack.getInstrument()).toBe('electric_piano_1');
expect(customTrack.getVolume()).toBe(0.6) expect(customTrack.getVolume()).toBe(-4);
}) });
it('should set correct type identifier', () => { it('should set correct type identifier', () => {
expect(track.getCurrentType()).toBe('KGMidiTrack') expect(track.getCurrentType()).toBe('KGMidiTrack');
}) });
it('should inherit from KGTrack', () => { it('should inherit from KGTrack', () => {
expect(track).toBeInstanceOf(KGMidiTrack) expect(track).toBeInstanceOf(KGMidiTrack);
expect(track).toBeInstanceOf(KGTrack) expect(track).toBeInstanceOf(KGTrack);
}) });
}) });
describe('instrument management', () => { describe('instrument management', () => {
describe('getInstrument', () => { describe('getInstrument', () => {
it('should return default instrument when not set', () => { it('should return default instrument when not set', () => {
expect(track.getInstrument()).toBe('acoustic_grand_piano') expect(track.getInstrument()).toBe('acoustic_grand_piano');
}) });
it('should return current instrument', () => { it('should return current instrument', () => {
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean') const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean');
expect(customTrack.getInstrument()).toBe('electric_guitar_clean') expect(customTrack.getInstrument()).toBe('electric_guitar_clean');
}) });
it('should provide backward compatibility for undefined instrument', () => { it('should provide backward compatibility for undefined instrument', () => {
// This tests the backward compatibility mentioned in the code // This tests the backward compatibility mentioned in the code
expect(track.getInstrument()).toBe('acoustic_grand_piano') expect(track.getInstrument()).toBe('acoustic_grand_piano');
}) });
}) });
describe('setInstrument', () => { describe('setInstrument', () => {
it('should update instrument', () => { it('should update instrument', () => {
track.setInstrument('violin') track.setInstrument('violin');
expect(track.getInstrument()).toBe('violin') expect(track.getInstrument()).toBe('violin');
}) });
it('should handle different instrument types', () => { it('should handle different instrument types', () => {
const instruments: InstrumentType[] = [ const instruments: InstrumentType[] = [
@@ -75,28 +75,28 @@ describe('KGMidiTrack', () => {
'violin', 'violin',
'trumpet', 'trumpet',
'flute' 'flute'
] ];
instruments.forEach(instrument => { instruments.forEach(instrument => {
track.setInstrument(instrument) track.setInstrument(instrument);
expect(track.getInstrument()).toBe(instrument) expect(track.getInstrument()).toBe(instrument);
}) });
}) });
it('should handle rapid instrument changes', () => { it('should handle rapid instrument changes', () => {
track.setInstrument('piano') track.setInstrument('piano');
track.setInstrument('guitar') track.setInstrument('guitar');
track.setInstrument('violin') track.setInstrument('violin');
expect(track.getInstrument()).toBe('violin') expect(track.getInstrument()).toBe('violin');
}) });
}) });
}) });
describe('region management', () => { describe('region management', () => {
let region1: KGMidiRegion let region1: KGMidiRegion;
let region2: KGMidiRegion let region2: KGMidiRegion;
let region3: KGMidiRegion let region3: KGMidiRegion;
beforeEach(() => { beforeEach(() => {
region1 = createMockMidiRegion({ region1 = createMockMidiRegion({
@@ -105,137 +105,137 @@ describe('KGMidiTrack', () => {
name: 'Region 1', name: 'Region 1',
startFromBeat: 0, startFromBeat: 0,
length: 4 length: 4
}) });
region2 = createMockMidiRegion({ region2 = createMockMidiRegion({
id: 'region-2', id: 'region-2',
trackId: track.getId().toString(), trackId: track.getId().toString(),
name: 'Region 2', name: 'Region 2',
startFromBeat: 4, startFromBeat: 4,
length: 4 length: 4
}) });
region3 = createMockMidiRegion({ region3 = createMockMidiRegion({
id: 'region-3', id: 'region-3',
trackId: track.getId().toString(), trackId: track.getId().toString(),
name: 'Region 3', name: 'Region 3',
startFromBeat: 8, startFromBeat: 8,
length: 4 length: 4
}) });
}) });
describe('setRegions', () => { describe('setRegions', () => {
it('should set regions array', () => { it('should set regions array', () => {
const regions = [region1, region2] const regions = [region1, region2];
track.setRegions(regions) track.setRegions(regions);
expect(track.getRegions()).toHaveLength(2) expect(track.getRegions()).toHaveLength(2);
expect(track.getRegions()).toEqual(regions) expect(track.getRegions()).toEqual(regions);
}) });
it('should replace existing regions', () => { it('should replace existing regions', () => {
track.setRegions([region1]) track.setRegions([region1]);
expect(track.getRegions()).toHaveLength(1) expect(track.getRegions()).toHaveLength(1);
track.setRegions([region2, region3]) track.setRegions([region2, region3]);
expect(track.getRegions()).toHaveLength(2) expect(track.getRegions()).toHaveLength(2);
expect(track.getRegions()).toContain(region2) expect(track.getRegions()).toContain(region2);
expect(track.getRegions()).toContain(region3) expect(track.getRegions()).toContain(region3);
expect(track.getRegions()).not.toContain(region1) expect(track.getRegions()).not.toContain(region1);
}) });
it('should accept empty array', () => { it('should accept empty array', () => {
track.setRegions([region1, region2]) track.setRegions([region1, region2]);
track.setRegions([]) track.setRegions([]);
expect(track.getRegions()).toHaveLength(0) expect(track.getRegions()).toHaveLength(0);
}) });
it('should enforce KGMidiRegion type', () => { it('should enforce KGMidiRegion type', () => {
const regions: KGMidiRegion[] = [region1, region2] const regions: KGMidiRegion[] = [region1, region2];
track.setRegions(regions) track.setRegions(regions);
const retrievedRegions = track.getRegions() const retrievedRegions = track.getRegions();
retrievedRegions.forEach(region => { retrievedRegions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion) expect(region).toBeInstanceOf(KGMidiRegion);
}) });
}) });
}) });
describe('inherited region methods', () => { describe('inherited region methods', () => {
beforeEach(() => { beforeEach(() => {
track.setRegions([region1, region2]) track.setRegions([region1, region2]);
}) });
it('should inherit addRegion method', () => { it('should inherit addRegion method', () => {
track.addRegion(region3) track.addRegion(region3);
const regions = track.getRegions() const regions = track.getRegions();
expect(regions).toHaveLength(3) expect(regions).toHaveLength(3);
expect(regions).toContain(region3) expect(regions).toContain(region3);
}) });
it('should inherit removeRegion method', () => { it('should inherit removeRegion method', () => {
track.removeRegion('region-1') track.removeRegion('region-1');
const regions = track.getRegions() const regions = track.getRegions();
expect(regions).toHaveLength(1) expect(regions).toHaveLength(1);
expect(regions).not.toContain(region1) expect(regions).not.toContain(region1);
expect(regions).toContain(region2) expect(regions).toContain(region2);
}) });
it('should inherit getRegions method', () => { it('should inherit getRegions method', () => {
const regions = track.getRegions() const regions = track.getRegions();
expect(regions).toHaveLength(2) expect(regions).toHaveLength(2);
expect(regions).toContain(region1) expect(regions).toContain(region1);
expect(regions).toContain(region2) expect(regions).toContain(region2);
}) });
}) });
}) });
describe('inheritance from KGTrack', () => { describe('inheritance from KGTrack', () => {
it('should inherit all base track properties', () => { it('should inherit all base track properties', () => {
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9) const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1);
expect(customTrack.getName()).toBe('Test Track') expect(customTrack.getName()).toBe('Test Track');
expect(customTrack.getId()).toBe(42) expect(customTrack.getId()).toBe(42);
expect(customTrack.getType()).toBe(TrackType.MIDI) expect(customTrack.getType()).toBe(TrackType.MIDI);
expect(customTrack.getVolume()).toBe(0.9) expect(customTrack.getVolume()).toBe(-1);
}) });
it('should inherit base track setters', () => { it('should inherit base track setters', () => {
track.setName('Updated Track') track.setName('Updated Track');
expect(track.getName()).toBe('Updated Track') expect(track.getName()).toBe('Updated Track');
track.setVolume(0.5) track.setVolume(-6);
expect(track.getVolume()).toBe(0.5) expect(track.getVolume()).toBe(-6);
track.setTrackIndex(3) track.setTrackIndex(3);
expect(track.getTrackIndex()).toBe(3) expect(track.getTrackIndex()).toBe(3);
}) });
it('should inherit volume controls', () => { it('should inherit volume controls', () => {
expect(track.getVolume()).toBe(0.8) // Default volume expect(track.getVolume()).toBe(0); // Default volume (0 dB)
track.setVolume(0.5) track.setVolume(-6);
expect(track.getVolume()).toBe(0.5) expect(track.getVolume()).toBe(-6);
track.setVolume(1.0) track.setVolume(0);
expect(track.getVolume()).toBe(1.0) expect(track.getVolume()).toBe(0);
}) });
}) });
describe('type identification', () => { describe('type identification', () => {
it('should return correct current type', () => { it('should return correct current type', () => {
expect(track.getCurrentType()).toBe('KGMidiTrack') expect(track.getCurrentType()).toBe('KGMidiTrack');
}) });
it('should return correct root type', () => { it('should return correct root type', () => {
expect(track.getRootType()).toBe('KGTrack') expect(track.getRootType()).toBe('KGTrack');
}) });
it('should have MIDI track type', () => { it('should have MIDI track type', () => {
expect(track.getType()).toBe(TrackType.MIDI) expect(track.getType()).toBe(TrackType.MIDI);
}) });
}) });
describe('instrument type validation', () => { describe('instrument type validation', () => {
it('should handle all valid General MIDI instruments', () => { it('should handle all valid General MIDI instruments', () => {
@@ -259,94 +259,100 @@ describe('KGMidiTrack', () => {
'clarinet', 'clarinet',
'soprano_sax', 'soprano_sax',
'alto_sax' 'alto_sax'
] ];
validInstruments.forEach(instrument => { validInstruments.forEach(instrument => {
expect(() => { expect(() => {
track.setInstrument(instrument) track.setInstrument(instrument);
expect(track.getInstrument()).toBe(instrument) expect(track.getInstrument()).toBe(instrument);
}).not.toThrow() }).not.toThrow();
}) });
}) });
}) });
describe('track state consistency', () => { describe('track state consistency', () => {
it('should maintain consistent state after multiple operations', () => { it('should maintain consistent state after multiple operations', () => {
// Setup initial state // Setup initial state
track.setName('Piano Track') track.setName('Piano Track');
track.setInstrument('acoustic_grand_piano') track.setInstrument('acoustic_grand_piano');
track.setVolume(0.7) track.setVolume(-3);
const regions = [ const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }), createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' }) createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
] ];
track.setRegions(regions) track.setRegions(regions);
// Verify initial state // Verify initial state
expect(track.getName()).toBe('Piano Track') expect(track.getName()).toBe('Piano Track');
expect(track.getInstrument()).toBe('acoustic_grand_piano') expect(track.getInstrument()).toBe('acoustic_grand_piano');
expect(track.getVolume()).toBe(0.7) expect(track.getVolume()).toBe(-3);
expect(track.getRegions()).toHaveLength(2) expect(track.getRegions()).toHaveLength(2);
// Modify state // Modify state
track.setInstrument('electric_piano_1') track.setInstrument('electric_piano_1');
track.addRegion(createMockMidiRegion({ track.addRegion(createMockMidiRegion({
id: 'r3', id: 'r3',
trackId: '0', trackId: '0',
name: 'Chorus' name: 'Chorus'
})) }));
// Verify modified state // Verify modified state
expect(track.getName()).toBe('Piano Track') expect(track.getName()).toBe('Piano Track');
expect(track.getInstrument()).toBe('electric_piano_1') expect(track.getInstrument()).toBe('electric_piano_1');
expect(track.getVolume()).toBe(0.7) expect(track.getVolume()).toBe(-3);
expect(track.getRegions()).toHaveLength(3) expect(track.getRegions()).toHaveLength(3);
}) });
it('should handle region-track relationship correctly', () => { it('should handle region-track relationship correctly', () => {
const region = createMockMidiRegion({ const region = createMockMidiRegion({
id: 'test-region', id: 'test-region',
trackId: track.getId().toString(), trackId: track.getId().toString(),
name: 'Test Region' name: 'Test Region'
}) });
track.addRegion(region) track.addRegion(region);
// Verify region is in track // Verify region is in track
expect(track.getRegions()).toContain(region) expect(track.getRegions()).toContain(region);
// Find region manually since getRegionById doesn't exist // Find region manually since getRegionById doesn't exist
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region') const foundRegion = track.getRegions().find(r => r.getId() === 'test-region');
expect(foundRegion).toBe(region) expect(foundRegion).toBe(region);
// Verify region properties // Verify region properties
expect(region.getTrackId()).toBe(track.getId().toString()) expect(region.getTrackId()).toBe(track.getId().toString());
}) });
}) });
describe('edge cases and error handling', () => { describe('edge cases and error handling', () => {
it('should handle empty track name', () => { it('should handle empty track name', () => {
const emptyNameTrack = new KGMidiTrack('', 0, 'piano') const emptyNameTrack = new KGMidiTrack('', 0, 'piano');
expect(emptyNameTrack.getName()).toBe('') expect(emptyNameTrack.getName()).toBe('');
}) });
it('should handle negative track ID', () => { it('should handle negative track ID', () => {
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano') const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano');
expect(negativeIdTrack.getId()).toBe(-1) expect(negativeIdTrack.getId()).toBe(-1);
}) });
it('should handle volume boundaries', () => { it('should handle volume boundaries', () => {
track.setVolume(0.0) track.setVolume(-60);
expect(track.getVolume()).toBe(0.0) expect(track.getVolume()).toBe(-60);
track.setVolume(1.0) track.setVolume(0);
expect(track.getVolume()).toBe(1.0) expect(track.getVolume()).toBe(0);
// Volume outside normal range (should still work) track.setVolume(6);
track.setVolume(1.5) expect(track.getVolume()).toBe(6);
expect(track.getVolume()).toBe(1.5)
}) // Volume outside valid dB range is clamped
track.setVolume(10);
expect(track.getVolume()).toBe(6);
track.setVolume(-100);
expect(track.getVolume()).toBe(-60);
});
it('should handle large number of regions', () => { it('should handle large number of regions', () => {
const manyRegions = Array.from({ length: 100 }, (_, i) => const manyRegions = Array.from({ length: 100 }, (_, i) =>
@@ -355,44 +361,44 @@ describe('KGMidiTrack', () => {
trackId: track.getId().toString(), trackId: track.getId().toString(),
name: `Region ${i}` name: `Region ${i}`
}) })
) );
track.setRegions(manyRegions) track.setRegions(manyRegions);
expect(track.getRegions()).toHaveLength(100) expect(track.getRegions()).toHaveLength(100);
// Should be able to find any region // Should be able to find any region
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50') const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50');
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99') const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99');
expect(foundRegion50).toBeDefined() expect(foundRegion50).toBeDefined();
expect(foundRegion99).toBeDefined() expect(foundRegion99).toBeDefined();
}) });
}) });
describe('class-transformer compatibility', () => { describe('class-transformer compatibility', () => {
it('should have proper type annotations for serialization', () => { it('should have proper type annotations for serialization', () => {
// Verify the class has the necessary decorators for serialization // Verify the class has the necessary decorators for serialization
expect(track.getCurrentType()).toBe('KGMidiTrack') expect(track.getCurrentType()).toBe('KGMidiTrack');
// Test that default instrument fallback works // Test that default instrument fallback works
const instrument = track.getInstrument() const instrument = track.getInstrument();
expect(instrument).toBe('acoustic_grand_piano') expect(instrument).toBe('acoustic_grand_piano');
}) });
it('should maintain regions type after serialization simulation', () => { it('should maintain regions type after serialization simulation', () => {
const regions = [ const regions = [
createMockMidiRegion({ id: 'r1', trackId: '0' }), createMockMidiRegion({ id: 'r1', trackId: '0' }),
createMockMidiRegion({ id: 'r2', trackId: '0' }) createMockMidiRegion({ id: 'r2', trackId: '0' })
] ];
track.setRegions(regions) track.setRegions(regions);
// Simulate what happens during serialization/deserialization // Simulate what happens during serialization/deserialization
const retrievedRegions = track.getRegions() const retrievedRegions = track.getRegions();
expect(retrievedRegions).toHaveLength(2) expect(retrievedRegions).toHaveLength(2);
retrievedRegions.forEach(region => { retrievedRegions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion) expect(region).toBeInstanceOf(KGMidiRegion);
}) });
}) });
}) });
}) });
+4 -1
View File
@@ -104,7 +104,10 @@ export class KGTrack {
} }
public setVolume(volume: number): void { public setVolume(volume: number): void {
this.volume = volume; this.volume = Math.max(
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, volume)
);
} }
public setRegions(regions: KGRegion[]): void { public setRegions(regions: KGRegion[]): void {
+42 -2
View File
@@ -5,13 +5,16 @@ import { saveProject } from '../util/saveUtil';
import { ConfigManager } from '../core/config/ConfigManager'; import { ConfigManager } from '../core/config/ConfigManager';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { selectAllNotesInActiveRegion } from '../util/selectionUtil'; import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
import { KGCore } from '../core/KGCore';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
/** /**
* Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations * Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations
* Handles keyboard shortcuts defined in the configuration * Handles keyboard shortcuts defined in the configuration
*/ */
export const useGlobalKeyboardHandler = () => { export const useGlobalKeyboardHandler = () => {
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore(); const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll } = useProjectStore();
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -64,6 +67,7 @@ export const useGlobalKeyboardHandler = () => {
const playShortcut = configManager.get('hotkeys.main.play') as string; const playShortcut = configManager.get('hotkeys.main.play') as string;
const loopShortcut = configManager.get('hotkeys.main.loop') as string; const loopShortcut = configManager.get('hotkeys.main.loop') as string;
const saveShortcut = configManager.get('hotkeys.main.save') as string; const saveShortcut = configManager.get('hotkeys.main.save') as string;
const recordShortcut = configManager.get('hotkeys.main.record') as string;
// Check for undo shortcut // Check for undo shortcut
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) { if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
@@ -153,6 +157,42 @@ export const useGlobalKeyboardHandler = () => {
return; return;
} }
// Check for record toggle shortcut
if (recordShortcut && matchesKeyboardShortcut(event, recordShortcut)) {
event.preventDefault();
if (isRecording) {
stopRecording();
setStatus('Recording stopped — notes committed');
return;
}
const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null);
if (!candidateId) {
setStatus('Select a MIDI region before recording');
return;
}
const tracks = KGCore.instance().getCurrentProject().getTracks();
let isMidi = false;
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === candidateId);
if (region) { isMidi = region instanceof KGMidiRegion; break; }
}
if (!isMidi) {
setStatus('Select a MIDI region before recording');
return;
}
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
setStatus('No MIDI device detected');
return;
}
if (!activeRegionId) {
setActiveRegionId(candidateId);
setShowPianoRoll(true);
}
startRecording();
setStatus('Recording started...');
return;
}
// Check for save shortcut // Check for save shortcut
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault(); event.preventDefault();
@@ -176,5 +216,5 @@ export const useGlobalKeyboardHandler = () => {
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown, { capture: true }); document.removeEventListener('keydown', handleKeyDown, { capture: true });
}; };
}, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName]); // Include dependencies for store actions }, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll]); // Include dependencies for store actions
}; };
+123 -4
View File
@@ -18,6 +18,10 @@ import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrad
import { toggleLoop } from '../util/loopUtil'; import { toggleLoop } from '../util/loopUtil';
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants'; import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
import type { NoteCreationData } from '../core/commands/note/CreateNotesCommand';
/** /**
* Update CSS custom property for time signature numerator * Update CSS custom property for time signature numerator
@@ -92,6 +96,12 @@ interface ProjectState {
// Settings state // Settings state
showSettings: boolean; showSettings: boolean;
// Recording state
isRecording: boolean;
recordingTargetRegionId: string | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number }>;
recordingOriginalPlayhead: number;
// Undo/redo state // Undo/redo state
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
@@ -167,11 +177,19 @@ interface ProjectState {
// Project state refresh actions // Project state refresh actions
refreshProjectState: () => void; refreshProjectState: () => void;
// Recording actions
startRecording: () => Promise<void>;
stopRecording: () => Promise<void>;
// Initialization actions // Initialization actions
initializeFromConfig: () => Promise<void>; initializeFromConfig: () => Promise<void>;
} }
// Module-level recording state (not reactive — only used for timing during active recording)
let _recordingActiveNotes: Map<number, number> = new Map(); // pitch → region-relative startBeat
let _recordingRegionStartBeat: number = 0;
// Create the store // Create the store
export const useProjectStore = create<ProjectState>((set, get) => { export const useProjectStore = create<ProjectState>((set, get) => {
const currentProject = KGCore.instance().getCurrentProject(); const currentProject = KGCore.instance().getCurrentProject();
@@ -308,7 +326,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
canRedo: false, canRedo: false,
undoDescription: null, undoDescription: null,
redoDescription: null, redoDescription: null,
// Initial recording state
isRecording: false,
recordingTargetRegionId: null,
recordingNotes: [],
recordingOriginalPlayhead: 0,
// Actions // Actions
setProjectName: (name: string) => { setProjectName: (name: string) => {
try { try {
@@ -772,6 +796,102 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ isPlaying: false }); set({ isPlaying: false });
}, },
startRecording: async () => {
const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
const project = KGCore.instance().getCurrentProject();
let targetRegion: KGMidiRegion | null = null;
for (const track of project.getTracks()) {
const found = track.getRegions().find(r => r.getId() === activeRegionId);
if (found instanceof KGMidiRegion) { targetRegion = found; break; }
}
if (!targetRegion) return;
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
_recordingActiveNotes = new Map();
set({
isRecording: true,
recordingNotes: [],
recordingTargetRegionId: activeRegionId,
recordingOriginalPlayhead: playheadPosition,
});
const buildCorrectedBeat = (): number => {
const bpm = get().bpm;
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60);
return KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
};
KGMidiInput.instance().setRecordingCallbacks(
(pitch: number) => {
const beat = buildCorrectedBeat();
_recordingActiveNotes.set(pitch, beat);
},
(pitch: number) => {
const endBeat = buildCorrectedBeat();
const startBeat = _recordingActiveNotes.get(pitch);
if (startBeat !== undefined) {
_recordingActiveNotes.delete(pitch);
set(state => ({
recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat }],
}));
}
}
);
const projectLooping = project.getIsLooping();
const [loopStartBar] = project.getLoopingRange();
const loopStartBeat = loopStartBar * timeSignature.numerator;
const recordingStartBeat = projectLooping
? loopStartBeat - timeSignature.numerator
: playheadPosition - timeSignature.numerator;
setPlayheadPosition(recordingStartBeat);
await KGCore.instance().startPlaying({
preserveLoopPreroll: projectLooping,
});
set({ isPlaying: true, autoScrollEnabled: true });
},
stopRecording: async () => {
const { recordingNotes, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
// Finalize any held keys
const finalNotes = [...recordingNotes];
const bpm = get().bpm;
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60);
const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
_recordingActiveNotes.forEach((startBeat, pitch) => {
finalNotes.push({ pitch, startBeat, endBeat: endBeatForHeld });
});
_recordingActiveNotes.clear();
KGMidiInput.instance().setRecordingCallbacks(null, null);
if (finalNotes.length > 0 && recordingTargetRegionId) {
const noteData: NoteCreationData[] = finalNotes.map(n => ({
regionId: recordingTargetRegionId,
startBeat: n.startBeat,
endBeat: n.endBeat,
pitch: n.pitch,
velocity: 127,
}));
const command = new CreateNotesCommand(noteData);
KGCore.instance().executeCommand(command);
refreshProjectState();
}
await stopPlaying();
setPlayheadPosition(recordingOriginalPlayhead);
set({ isRecording: false, recordingNotes: [], recordingTargetRegionId: null });
},
toggleLoop: () => { toggleLoop: () => {
const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get(); const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get();
@@ -1077,7 +1197,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ showChatBox: defaultChatBoxOpen }); set({ showChatBox: defaultChatBoxOpen });
} }
} }
} };
}); });
@@ -1086,4 +1206,3 @@ export const useProjectStore = create<ProjectState>((set, get) => {
@@ -2,334 +2,334 @@
* Integration tests for command execution and undo/redo functionality * Integration tests for command execution and undo/redo functionality
* Tests the complete flow: Command execution Core model updates UI state sync * Tests the complete flow: Command execution Core model updates UI state sync
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react' import { renderHook, act } from '@testing-library/react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
// Import core classes // Import core classes
import { KGCore } from '../../../core/KGCore' import { KGCore } from '../../../core/KGCore';
import { KGProject } from '../../../core/KGProject' import { KGProject } from '../../../core/KGProject';
import { KGMidiTrack } from '../../../core/track/KGMidiTrack' import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
import { KGMidiRegion } from '../../../core/region/KGMidiRegion' import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
import { KGMidiNote } from '../../../core/midi/KGMidiNote' import { KGMidiNote } from '../../../core/midi/KGMidiNote';
import { KGCommandHistory } from '../../../core/commands/KGCommandHistory' import { KGCommandHistory } from '../../../core/commands/KGCommandHistory';
// Import commands // Import commands
import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand' import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand';
import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand' import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand';
import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand' import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand';
// Import store // Import store
import { useProjectStore } from '../../../stores/projectStore' import { useProjectStore } from '../../../stores/projectStore';
// Import test utilities // Import test utilities
import '../../utils/setup-integration-tests' import '../../utils/setup-integration-tests';
describe('Command Execution Integration Tests', () => { describe('Command Execution Integration Tests', () => {
let testProject: KGProject let testProject: KGProject;
let testTrack: KGMidiTrack let testTrack: KGMidiTrack;
let testRegion: KGMidiRegion let testRegion: KGMidiRegion;
beforeEach(async () => { beforeEach(async () => {
// Create a real project with track and region for testing // Create a real project with track and region for testing
testProject = new KGProject('Test Project') testProject = new KGProject('Test Project');
testProject.setBpm(120) testProject.setBpm(120);
testProject.setTimeSignature({ numerator: 4, denominator: 4 }) testProject.setTimeSignature({ numerator: 4, denominator: 4 });
testProject.setKeySignature('C major') testProject.setKeySignature('C major');
testProject.setMaxBars(32) testProject.setMaxBars(32);
// Create test track and region // Create test track and region
testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano') testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16) testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
// Set up the project hierarchy // Set up the project hierarchy
testTrack.addRegion(testRegion) testTrack.addRegion(testRegion);
testProject.setTracks([testTrack]) testProject.setTracks([testTrack]);
// Initialize KGCore with test project // Initialize KGCore with test project
const core = KGCore.instance() const core = KGCore.instance();
await core.initialize() await core.initialize();
core.setCurrentProject(testProject) core.setCurrentProject(testProject);
// Clear command history // Clear command history
KGCommandHistory.instance().clear() KGCommandHistory.instance().clear();
// Initialize store with project // Initialize store with project
const { loadProject } = useProjectStore.getState() const { loadProject } = useProjectStore.getState();
await loadProject(testProject) await loadProject(testProject);
}) });
describe('Note Command Integration', () => { describe('Note Command Integration', () => {
it('should execute CreateNoteCommand and update both core model and store', async () => { it('should execute CreateNoteCommand and update both core model and store', async () => {
const regionId = testRegion.getId() const regionId = testRegion.getId();
const initialNoteCount = testRegion.getNotes().length const initialNoteCount = testRegion.getNotes().length;
// Create and execute command // Create and execute command
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
// Execute command through command history (simulates real usage) // Execute command through command history (simulates real usage)
act(() => { act(() => {
commandHistory.executeCommand(createCommand) commandHistory.executeCommand(createCommand);
}) });
// Verify core model was updated // Verify core model was updated
const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1) expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1);
const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId()) const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId());
expect(createdNote).toBeDefined() expect(createdNote).toBeDefined();
expect(createdNote!.getPitch()).toBe(60) expect(createdNote!.getPitch()).toBe(60);
expect(createdNote!.getStartBeat()).toBe(0) expect(createdNote!.getStartBeat()).toBe(0);
expect(createdNote!.getEndBeat()).toBe(1) expect(createdNote!.getEndBeat()).toBe(1);
// Verify command history state // Verify command history state
expect(commandHistory.canUndo()).toBe(true) expect(commandHistory.canUndo()).toBe(true);
expect(commandHistory.canRedo()).toBe(false) expect(commandHistory.canRedo()).toBe(false);
expect(commandHistory.getUndoDescription()).toBe('Create note C4') expect(commandHistory.getUndoDescription()).toBe('Create note C4');
// Verify store undo/redo state is updated // Verify store undo/redo state is updated
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.canUndo).toBe(true) expect(storeState.canUndo).toBe(true);
expect(storeState.canRedo).toBe(false) expect(storeState.canRedo).toBe(false);
}) });
it('should execute undo and restore previous state', async () => { it('should execute undo and restore previous state', async () => {
const regionId = testRegion.getId() const regionId = testRegion.getId();
const initialNoteCount = testRegion.getNotes().length const initialNoteCount = testRegion.getNotes().length;
// Create and execute command // Create and execute command
const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120) // E4 const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120); // E4
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
act(() => { act(() => {
commandHistory.executeCommand(createCommand) commandHistory.executeCommand(createCommand);
}) });
// Verify note was created // Verify note was created
const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1) expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1);
// Execute undo // Execute undo
act(() => { act(() => {
const undoSuccess = commandHistory.undo() const undoSuccess = commandHistory.undo();
expect(undoSuccess).toBe(true) expect(undoSuccess).toBe(true);
}) });
// Verify core model was restored // Verify core model was restored
const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount) expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount);
// Verify the specific note was removed // Verify the specific note was removed
const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId()) const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId());
expect(noteExists).toBe(false) expect(noteExists).toBe(false);
// Verify command history state // Verify command history state
expect(commandHistory.canUndo()).toBe(false) expect(commandHistory.canUndo()).toBe(false);
expect(commandHistory.canRedo()).toBe(true) expect(commandHistory.canRedo()).toBe(true);
expect(commandHistory.getRedoDescription()).toBe('Create note E4') expect(commandHistory.getRedoDescription()).toBe('Create note E4');
}) });
it('should execute redo and restore forward state', async () => { it('should execute redo and restore forward state', async () => {
const regionId = testRegion.getId() const regionId = testRegion.getId();
const initialNoteCount = testRegion.getNotes().length const initialNoteCount = testRegion.getNotes().length;
// Create, execute, and undo a command // Create, execute, and undo a command
const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110) // G4 const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110); // G4
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
act(() => { act(() => {
commandHistory.executeCommand(createCommand) commandHistory.executeCommand(createCommand);
commandHistory.undo() commandHistory.undo();
}) });
// Verify we're back to initial state // Verify we're back to initial state
expect(testRegion.getNotes()).toHaveLength(initialNoteCount) expect(testRegion.getNotes()).toHaveLength(initialNoteCount);
// Execute redo // Execute redo
act(() => { act(() => {
const redoSuccess = commandHistory.redo() const redoSuccess = commandHistory.redo();
expect(redoSuccess).toBe(true) expect(redoSuccess).toBe(true);
}) });
// Verify core model was restored to post-create state // Verify core model was restored to post-create state
const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1) expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1);
// Verify the specific note was recreated // Verify the specific note was recreated
const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId()) const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId());
expect(recreatedNote).toBeDefined() expect(recreatedNote).toBeDefined();
expect(recreatedNote!.getPitch()).toBe(67) expect(recreatedNote!.getPitch()).toBe(67);
// Verify command history state // Verify command history state
expect(commandHistory.canUndo()).toBe(true) expect(commandHistory.canUndo()).toBe(true);
expect(commandHistory.canRedo()).toBe(false) expect(commandHistory.canRedo()).toBe(false);
}) });
}) });
describe('Multiple Command Integration', () => { describe('Multiple Command Integration', () => {
it('should execute multiple commands and maintain history integrity', async () => { it('should execute multiple commands and maintain history integrity', async () => {
const regionId = testRegion.getId() const regionId = testRegion.getId();
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
// Execute multiple note creation commands // Execute multiple note creation commands
const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100) // C4 const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100); // C4
const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100) // E4 const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100); // E4
const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100) // G4 const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100); // G4
act(() => { act(() => {
commandHistory.executeCommand(command1) commandHistory.executeCommand(command1);
commandHistory.executeCommand(command2) commandHistory.executeCommand(command2);
commandHistory.executeCommand(command3) commandHistory.executeCommand(command3);
}) });
// Verify all notes were created // Verify all notes were created
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(region.getNotes()).toHaveLength(3) expect(region.getNotes()).toHaveLength(3);
// Verify command history // Verify command history
expect(commandHistory.canUndo()).toBe(true) expect(commandHistory.canUndo()).toBe(true);
expect(commandHistory.getUndoDescription()).toBe('Create note G4') expect(commandHistory.getUndoDescription()).toBe('Create note G4');
// Undo middle command by undoing twice // Undo middle command by undoing twice
act(() => { act(() => {
commandHistory.undo() // Remove G4 commandHistory.undo(); // Remove G4
commandHistory.undo() // Remove E4 commandHistory.undo(); // Remove E4
}) });
// Verify only first note remains // Verify only first note remains
expect(region.getNotes()).toHaveLength(1) expect(region.getNotes()).toHaveLength(1);
expect(region.getNotes()[0].getPitch()).toBe(60) // C4 expect(region.getNotes()[0].getPitch()).toBe(60); // C4
// Verify redo state // Verify redo state
expect(commandHistory.canRedo()).toBe(true) expect(commandHistory.canRedo()).toBe(true);
expect(commandHistory.getRedoDescription()).toBe('Create note E4') expect(commandHistory.getRedoDescription()).toBe('Create note E4');
}) });
it('should handle command execution with different command types', async () => { it('should handle command execution with different command types', async () => {
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
const initialTrackCount = testProject.getTracks().length const initialTrackCount = testProject.getTracks().length;
// Execute track addition command // Execute track addition command
const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass') const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass');
act(() => { act(() => {
commandHistory.executeCommand(addTrackCommand) commandHistory.executeCommand(addTrackCommand);
}) });
// Verify track was added to core model // Verify track was added to core model
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1) expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1);
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack;
expect(newTrack.getName()).toBe('Bass Track') expect(newTrack.getName()).toBe('Bass Track');
expect(newTrack.getInstrument()).toBe('acoustic_bass') expect(newTrack.getInstrument()).toBe('acoustic_bass');
// Add a note to the original region // Add a note to the original region
const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100) // C3 const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100); // C3
act(() => { act(() => {
commandHistory.executeCommand(createNoteCommand) commandHistory.executeCommand(createNoteCommand);
}) });
// Verify both commands are in history // Verify both commands are in history
expect(commandHistory.canUndo()).toBe(true) expect(commandHistory.canUndo()).toBe(true);
expect(commandHistory.getUndoDescription()).toBe('Create note C3') expect(commandHistory.getUndoDescription()).toBe('Create note C3');
// Undo both commands // Undo both commands
act(() => { act(() => {
commandHistory.undo() // Undo note creation commandHistory.undo(); // Undo note creation
commandHistory.undo() // Undo track addition commandHistory.undo(); // Undo track addition
}) });
// Verify both operations were undone // Verify both operations were undone
expect(testProject.getTracks()).toHaveLength(initialTrackCount) expect(testProject.getTracks()).toHaveLength(initialTrackCount);
const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(originalRegion.getNotes()).toHaveLength(0) expect(originalRegion.getNotes()).toHaveLength(0);
}) });
}) });
describe('Error Handling Integration', () => { describe('Error Handling Integration', () => {
it('should handle command execution errors gracefully', async () => { it('should handle command execution errors gracefully', async () => {
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
// Try to create note in non-existent region // Try to create note in non-existent region
const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100) const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100);
// Execute command - should not throw but should not add to history // Execute command - should not throw but should not add to history
act(() => { act(() => {
commandHistory.executeCommand(invalidCommand) commandHistory.executeCommand(invalidCommand);
}) });
// Verify command was not added to history due to execution failure // Verify command was not added to history due to execution failure
expect(commandHistory.canUndo()).toBe(false) expect(commandHistory.canUndo()).toBe(false);
expect(commandHistory.getUndoDescription()).toBeNull() expect(commandHistory.getUndoDescription()).toBeNull();
// Verify project state unchanged // Verify project state unchanged
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
expect(region.getNotes()).toHaveLength(0) expect(region.getNotes()).toHaveLength(0);
}) });
it('should handle undo failures gracefully', async () => { it('should handle undo failures gracefully', async () => {
const regionId = testRegion.getId() const regionId = testRegion.getId();
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
// Create a command that will succeed initially // Create a command that will succeed initially
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
act(() => { act(() => {
commandHistory.executeCommand(createCommand) commandHistory.executeCommand(createCommand);
}) });
// Manually remove the region to cause undo to fail // Manually remove the region to cause undo to fail
testTrack.removeRegion(testRegion.getId()) testTrack.removeRegion(testRegion.getId());
// Try to undo - should fail gracefully // Try to undo - should fail gracefully
act(() => { act(() => {
const undoSuccess = commandHistory.undo() const undoSuccess = commandHistory.undo();
expect(undoSuccess).toBe(false) expect(undoSuccess).toBe(false);
}) });
// Verify command is still in undo stack after failed undo // Verify command is still in undo stack after failed undo
expect(commandHistory.canUndo()).toBe(true) expect(commandHistory.canUndo()).toBe(true);
}) });
}) });
describe('Store Integration', () => { describe('Store Integration', () => {
it('should keep store undo/redo state synchronized with command history', async () => { it('should keep store undo/redo state synchronized with command history', async () => {
const regionId = testRegion.getId() const regionId = testRegion.getId();
const commandHistory = KGCommandHistory.instance() const commandHistory = KGCommandHistory.instance();
const { syncUndoRedoState } = useProjectStore.getState() const { syncUndoRedoState } = useProjectStore.getState();
// Initial state // Initial state
expect(useProjectStore.getState().canUndo).toBe(false) expect(useProjectStore.getState().canUndo).toBe(false);
expect(useProjectStore.getState().canRedo).toBe(false) expect(useProjectStore.getState().canRedo).toBe(false);
// Execute command // Execute command
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100) const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
act(() => { act(() => {
commandHistory.executeCommand(createCommand) commandHistory.executeCommand(createCommand);
syncUndoRedoState() // Simulate store sync syncUndoRedoState(); // Simulate store sync
}) });
// Verify store state updated // Verify store state updated
let storeState = useProjectStore.getState() let storeState = useProjectStore.getState();
expect(storeState.canUndo).toBe(true) expect(storeState.canUndo).toBe(true);
expect(storeState.canRedo).toBe(false) expect(storeState.canRedo).toBe(false);
expect(storeState.undoDescription).toBe('Create note C4') expect(storeState.undoDescription).toBe('Create note C4');
expect(storeState.redoDescription).toBeNull() expect(storeState.redoDescription).toBeNull();
// Execute undo // Execute undo
act(() => { act(() => {
commandHistory.undo() commandHistory.undo();
syncUndoRedoState() // Simulate store sync syncUndoRedoState(); // Simulate store sync
}) });
// Verify store state updated after undo // Verify store state updated after undo
storeState = useProjectStore.getState() storeState = useProjectStore.getState();
expect(storeState.canUndo).toBe(false) expect(storeState.canUndo).toBe(false);
expect(storeState.canRedo).toBe(true) expect(storeState.canRedo).toBe(true);
expect(storeState.undoDescription).toBeNull() expect(storeState.undoDescription).toBeNull();
expect(storeState.redoDescription).toBe('Create note C4') expect(storeState.redoDescription).toBe('Create note C4');
}) });
}) });
}) });
@@ -2,445 +2,481 @@
* Integration tests for project store synchronization with core models * Integration tests for project store synchronization with core models
* Tests the critical data flow: Store Actions Core Models UI State Updates * Tests the critical data flow: Store Actions Core Models UI State Updates
*/ */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react' import { act, renderHook } from '@testing-library/react';
// Import core classes // Import core classes
import { KGCore } from '../../../core/KGCore' import { KGCore } from '../../../core/KGCore';
import { KGProject } from '../../../core/KGProject' import { KGProject } from '../../../core/KGProject';
import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack' import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack';
import { KGMidiRegion } from '../../../core/region/KGMidiRegion' import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
import { KGMidiNote } from '../../../core/midi/KGMidiNote' import { KGMidiNote } from '../../../core/midi/KGMidiNote';
import { KGMidiInput } from '../../../core/midi-input/KGMidiInput';
// Import store // Import store
import { useProjectStore } from '../../../stores/projectStore' import { useProjectStore } from '../../../stores/projectStore';
// Import test utilities and mocks // Import test utilities and mocks
import '../../utils/setup-integration-tests' import '../../utils/setup-integration-tests';
import { mockAudioInterface } from '../../mocks/audio-interface' import { mockAudioInterface } from '../../mocks/audio-interface';
describe('Project Store Synchronization Integration Tests', () => { describe('Project Store Synchronization Integration Tests', () => {
let testProject: KGProject let testProject: KGProject;
beforeEach(async () => { beforeEach(async () => {
// Create a real project for testing // Create a real project for testing
testProject = new KGProject('Sync Test Project') testProject = new KGProject('Sync Test Project');
testProject.setBpm(120) testProject.setBpm(120);
testProject.setTimeSignature({ numerator: 4, denominator: 4 }) testProject.setTimeSignature({ numerator: 4, denominator: 4 });
testProject.setKeySignature('C major') testProject.setKeySignature('C major');
testProject.setMaxBars(32) testProject.setMaxBars(32);
// Initialize KGCore // Initialize KGCore
const core = KGCore.instance() const core = KGCore.instance();
await core.initialize() await core.initialize();
core.setCurrentProject(testProject) core.setCurrentProject(testProject);
// Reset store state // Reset store state
const store = useProjectStore.getState() const store = useProjectStore.getState();
await store.loadProject(testProject) await store.loadProject(testProject);
}) });
afterEach(() => { afterEach(() => {
vi.clearAllMocks() vi.clearAllMocks();
}) });
describe('Project Properties Synchronization', () => { describe('Project Properties Synchronization', () => {
it('should sync BPM changes between store and core model', async () => { it('should sync BPM changes between store and core model', async () => {
const { setBpm } = useProjectStore.getState() const { setBpm } = useProjectStore.getState();
const newBpm = 140 const newBpm = 140;
// Execute store action // Execute store action
act(() => { act(() => {
setBpm(newBpm) setBpm(newBpm);
}) });
// Verify core model was updated // Verify core model was updated
expect(testProject.getBpm()).toBe(newBpm) expect(testProject.getBpm()).toBe(newBpm);
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.bpm).toBe(newBpm) expect(storeState.bpm).toBe(newBpm);
// Verify CSS custom property was updated // Verify CSS custom property was updated
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
expect(cssValue).toBeTruthy() // CSS should be updated by store action expect(cssValue).toBeTruthy(); // CSS should be updated by store action
}) });
it('should sync time signature changes and update CSS properties', async () => { it('should sync time signature changes and update CSS properties', async () => {
const { setTimeSignature } = useProjectStore.getState() const { setTimeSignature } = useProjectStore.getState();
const newTimeSignature = { numerator: 3, denominator: 4 } const newTimeSignature = { numerator: 3, denominator: 4 };
act(() => { act(() => {
setTimeSignature(newTimeSignature) setTimeSignature(newTimeSignature);
}) });
// Verify core model was updated // Verify core model was updated
expect(testProject.getTimeSignature()).toEqual(newTimeSignature) expect(testProject.getTimeSignature()).toEqual(newTimeSignature);
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.timeSignature).toEqual(newTimeSignature) expect(storeState.timeSignature).toEqual(newTimeSignature);
// Verify CSS custom property was updated for UI calculations // Verify CSS custom property was updated for UI calculations
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
expect(cssValue.trim()).toBe('3') expect(cssValue.trim()).toBe('3');
}) });
it('should sync max bars changes and update layout CSS', async () => { it('should sync max bars changes and update layout CSS', async () => {
const { setMaxBars } = useProjectStore.getState() const { setMaxBars } = useProjectStore.getState();
const newMaxBars = 64 const newMaxBars = 64;
act(() => { act(() => {
setMaxBars(newMaxBars) setMaxBars(newMaxBars);
}) });
// Verify core model was updated // Verify core model was updated
expect(testProject.getMaxBars()).toBe(newMaxBars) expect(testProject.getMaxBars()).toBe(newMaxBars);
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.maxBars).toBe(newMaxBars) expect(storeState.maxBars).toBe(newMaxBars);
// Verify CSS custom property was updated for layout // Verify CSS custom property was updated for layout
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars') const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
expect(cssValue.trim()).toBe('64') expect(cssValue.trim()).toBe('64');
}) });
it('should sync key signature changes', async () => { it('should sync key signature changes', async () => {
const { setKeySignature } = useProjectStore.getState() const { setKeySignature } = useProjectStore.getState();
const newKeySignature = 'G major' const newKeySignature = 'G major';
act(() => { act(() => {
setKeySignature(newKeySignature) setKeySignature(newKeySignature);
}) });
// Verify core model was updated // Verify core model was updated
expect(testProject.getKeySignature()).toBe(newKeySignature) expect(testProject.getKeySignature()).toBe(newKeySignature);
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.keySignature).toBe(newKeySignature) expect(storeState.keySignature).toBe(newKeySignature);
}) });
}) });
describe('Track Management Synchronization', () => { describe('Track Management Synchronization', () => {
it('should sync track addition with core model and audio interface', async () => { it('should sync track addition with core model and audio interface', async () => {
const { addTrack } = useProjectStore.getState() const { addTrack } = useProjectStore.getState();
const initialTrackCount = testProject.getTracks().length const initialTrackCount = testProject.getTracks().length;
// Execute store action // Execute store action
await act(async () => { await act(async () => {
await addTrack() await addTrack();
}) });
// Verify core model was updated // Verify core model was updated
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1) expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1);
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack;
expect(newTrack).toBeInstanceOf(KGMidiTrack) expect(newTrack).toBeInstanceOf(KGMidiTrack);
expect(newTrack.getName()).toContain('Track') expect(newTrack.getName()).toContain('Track');
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.tracks).toHaveLength(initialTrackCount + 1) expect(storeState.tracks).toHaveLength(initialTrackCount + 1);
// Verify audio interface was notified // Verify audio interface was notified
expect(mockAudioInterface.createTrackBus).toHaveBeenCalled() expect(mockAudioInterface.createTrackBus).toHaveBeenCalled();
}) });
it('should sync track removal with core model and audio interface', async () => { it('should sync track removal with core model and audio interface', async () => {
// First add a track // First add a track
const { addTrack, removeTrack } = useProjectStore.getState() const { addTrack, removeTrack } = useProjectStore.getState();
await act(async () => { await act(async () => {
await addTrack() await addTrack();
}) });
const trackCountAfterAdd = testProject.getTracks().length const trackCountAfterAdd = testProject.getTracks().length;
const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1] const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1];
// Remove the track // Remove the track
await act(async () => { await act(async () => {
await removeTrack(trackCountAfterAdd - 1) // Remove last track await removeTrack(trackCountAfterAdd - 1); // Remove last track
}) });
// Verify core model was updated // Verify core model was updated
expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1) expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1);
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1) expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1);
// Verify audio interface was notified // Verify audio interface was notified
expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId()) expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId());
}) });
it('should sync track instrument changes with audio interface', async () => { it('should sync track instrument changes with audio interface', async () => {
// Add a track first // Add a track first
const { addTrack, setTrackInstrument } = useProjectStore.getState() const { addTrack, setTrackInstrument } = useProjectStore.getState();
await act(async () => { await act(async () => {
await addTrack() await addTrack();
}) });
const trackIndex = testProject.getTracks().length - 1 const trackIndex = testProject.getTracks().length - 1;
const track = testProject.getTracks()[trackIndex] as KGMidiTrack const track = testProject.getTracks()[trackIndex] as KGMidiTrack;
const newInstrument: InstrumentType = 'electric_bass' const newInstrument: InstrumentType = 'electric_bass';
// Change track instrument // Change track instrument
await act(async () => { await act(async () => {
await setTrackInstrument(trackIndex, newInstrument) await setTrackInstrument(trackIndex, newInstrument);
}) });
// Verify core model was updated // Verify core model was updated
expect(track.getInstrument()).toBe(newInstrument) expect(track.getInstrument()).toBe(newInstrument);
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack;
expect(storeTrack.getInstrument()).toBe(newInstrument) expect(storeTrack.getInstrument()).toBe(newInstrument);
// Verify audio interface was notified // Verify audio interface was notified
expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument) expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument);
}) });
it('should sync track reordering with core model', async () => { it('should sync track reordering with core model', async () => {
const { addTrack, reorderTracks } = useProjectStore.getState() const { addTrack, reorderTracks } = useProjectStore.getState();
// Add two tracks // Add two tracks
await act(async () => { await act(async () => {
await addTrack() // Track at index 0 await addTrack(); // Track at index 0
await addTrack() // Track at index 1 await addTrack(); // Track at index 1
}) });
const track0Before = testProject.getTracks()[0] const track0Before = testProject.getTracks()[0];
const track1Before = testProject.getTracks()[1] const track1Before = testProject.getTracks()[1];
// Reorder tracks (move track 0 to position 1) // Reorder tracks (move track 0 to position 1)
act(() => { act(() => {
reorderTracks(0, 1) reorderTracks(0, 1);
}) });
// Verify core model track order changed // Verify core model track order changed
const track0After = testProject.getTracks()[0] const track0After = testProject.getTracks()[0];
const track1After = testProject.getTracks()[1] const track1After = testProject.getTracks()[1];
expect(track0After.getId()).toBe(track1Before.getId()) expect(track0After.getId()).toBe(track1Before.getId());
expect(track1After.getId()).toBe(track0Before.getId()) expect(track1After.getId()).toBe(track0Before.getId());
// Verify store state reflects change // Verify store state reflects change
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.tracks[0].getId()).toBe(track1Before.getId()) expect(storeState.tracks[0].getId()).toBe(track1Before.getId());
expect(storeState.tracks[1].getId()).toBe(track0Before.getId()) expect(storeState.tracks[1].getId()).toBe(track0Before.getId());
}) });
}) });
describe('Playback State Synchronization', () => { describe('Playback State Synchronization', () => {
it('should sync playhead position with formatted time string', async () => { it('should sync playhead position with formatted time string', async () => {
const { setPlayheadPosition } = useProjectStore.getState() const { setPlayheadPosition } = useProjectStore.getState();
const newPosition = 8.5 // beats const newPosition = 8.5; // beats
act(() => { act(() => {
setPlayheadPosition(newPosition) setPlayheadPosition(newPosition);
}) });
// Verify store state updated // Verify store state updated
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.playheadPosition).toBe(newPosition) expect(storeState.playheadPosition).toBe(newPosition);
// Verify formatted time string was updated // Verify formatted time string was updated
expect(storeState.currentTime).toBeDefined() expect(storeState.currentTime).toBeDefined();
expect(storeState.currentTime).toContain('|') // Should contain BBB:B | mm:ss:mmm format expect(storeState.currentTime).toContain('|'); // Should contain BBB:B | mm:ss:mmm format
}) });
it('should sync playback state changes', async () => { it('should sync playback state changes', async () => {
const { startPlaying, stopPlaying } = useProjectStore.getState() const { startPlaying, stopPlaying } = useProjectStore.getState();
// Start playing // Start playing
await act(async () => { await act(async () => {
await startPlaying() await startPlaying();
}) });
// Verify store state updated // Verify store state updated
let storeState = useProjectStore.getState() let storeState = useProjectStore.getState();
expect(storeState.isPlaying).toBe(true) expect(storeState.isPlaying).toBe(true);
// Verify audio interface was called // Verify audio interface was called
expect(mockAudioInterface.startPlayback).toHaveBeenCalled() expect(mockAudioInterface.startPlayback).toHaveBeenCalled();
// Stop playing // Stop playing
await act(async () => { await act(async () => {
await stopPlaying() await stopPlaying();
}) });
// Verify store state updated // Verify store state updated
storeState = useProjectStore.getState() storeState = useProjectStore.getState();
expect(storeState.isPlaying).toBe(false) expect(storeState.isPlaying).toBe(false);
// Verify audio interface was called // Verify audio interface was called
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled() expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
}) });
})
it('should start looped recording one bar before the loop start on the first pass', async () => {
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
testTrack.addRegion(testRegion);
testProject.setTracks([testTrack]);
testProject.setIsLooping(true);
testProject.setLoopingRange([4, 7]);
await act(async () => {
await useProjectStore.getState().loadProject(testProject);
});
const core = KGCore.instance();
const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined);
const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks');
const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState();
act(() => {
setActiveRegionId(testRegion.getId());
setPlayheadPosition(18);
});
await act(async () => {
await startRecording();
});
const storeState = useProjectStore.getState();
expect(storeState.playheadPosition).toBe(12);
expect(storeState.recordingOriginalPlayhead).toBe(18);
expect(storeState.isRecording).toBe(true);
expect(storeState.isPlaying).toBe(true);
expect(recordingCallbacksSpy).toHaveBeenCalled();
expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true });
});
});
describe('Selection State Synchronization', () => { describe('Selection State Synchronization', () => {
it('should sync selection state with core piano roll state', async () => { it('should sync selection state with core piano roll state', async () => {
const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState() const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState();
// Add a track and region for testing // Add a track and region for testing
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano') const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16) const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
testTrack.addRegion(testRegion) testTrack.addRegion(testRegion);
testProject.setTracks([...testProject.getTracks(), testTrack]) testProject.setTracks([...testProject.getTracks(), testTrack]);
// Set active region // Set active region
act(() => { act(() => {
setActiveRegionId(testRegion.getId()) setActiveRegionId(testRegion.getId());
}) });
// Verify store state updated // Verify store state updated
let storeState = useProjectStore.getState() let storeState = useProjectStore.getState();
expect(storeState.activeRegionId).toBe(testRegion.getId()) expect(storeState.activeRegionId).toBe(testRegion.getId());
// Simulate core selection changes and sync // Simulate core selection changes and sync
act(() => { act(() => {
syncSelectionFromCore() syncSelectionFromCore();
}) });
// Verify store selection state is synchronized // Verify store selection state is synchronized
storeState = useProjectStore.getState() storeState = useProjectStore.getState();
expect(storeState.selectedNoteIds).toBeDefined() expect(storeState.selectedNoteIds).toBeDefined();
expect(storeState.selectedRegionIds).toBeDefined() expect(storeState.selectedRegionIds).toBeDefined();
}) });
it('should clear all selections and sync state', async () => { it('should clear all selections and sync state', async () => {
const { clearAllSelections, setSelectedTrack } = useProjectStore.getState() const { clearAllSelections, setSelectedTrack } = useProjectStore.getState();
// Set some initial selection state // Set some initial selection state
act(() => { act(() => {
setSelectedTrack('test-track-id') setSelectedTrack('test-track-id');
}) });
// Verify selection was set // Verify selection was set
let storeState = useProjectStore.getState() let storeState = useProjectStore.getState();
expect(storeState.selectedTrackId).toBe('test-track-id') expect(storeState.selectedTrackId).toBe('test-track-id');
// Clear all selections // Clear all selections
act(() => { act(() => {
clearAllSelections() clearAllSelections();
}) });
// Verify all selections were cleared // Verify all selections were cleared
storeState = useProjectStore.getState() storeState = useProjectStore.getState();
expect(storeState.selectedTrackId).toBeNull() expect(storeState.selectedTrackId).toBeNull();
expect(storeState.selectedNoteIds).toEqual([]) expect(storeState.selectedNoteIds).toEqual([]);
expect(storeState.selectedRegionIds).toEqual([]) expect(storeState.selectedRegionIds).toEqual([]);
}) });
}) });
describe('Piano Roll State Integration', () => { describe('Piano Roll State Integration', () => {
it('should sync piano roll visibility and active region', async () => { it('should sync piano roll visibility and active region', async () => {
const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState() const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
// Add test region // Add test region
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano') const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16) const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16);
testTrack.addRegion(testRegion) testTrack.addRegion(testRegion);
testProject.setTracks([...testProject.getTracks(), testTrack]) testProject.setTracks([...testProject.getTracks(), testTrack]);
// Show piano roll with active region // Show piano roll with active region
act(() => { act(() => {
setActiveRegionId(testRegion.getId()) setActiveRegionId(testRegion.getId());
setShowPianoRoll(true) setShowPianoRoll(true);
}) });
// Verify store state updated // Verify store state updated
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.showPianoRoll).toBe(true) expect(storeState.showPianoRoll).toBe(true);
expect(storeState.activeRegionId).toBe(testRegion.getId()) expect(storeState.activeRegionId).toBe(testRegion.getId());
}) });
}) });
describe('Error Handling and Edge Cases', () => { describe('Error Handling and Edge Cases', () => {
it('should handle invalid track operations gracefully', async () => { it('should handle invalid track operations gracefully', async () => {
const { removeTrack } = useProjectStore.getState() const { removeTrack } = useProjectStore.getState();
const initialTrackCount = testProject.getTracks().length const initialTrackCount = testProject.getTracks().length;
// Try to remove non-existent track // Try to remove non-existent track
await act(async () => { await act(async () => {
await removeTrack(999) // Invalid index await removeTrack(999); // Invalid index
}) });
// Verify project state unchanged // Verify project state unchanged
expect(testProject.getTracks()).toHaveLength(initialTrackCount) expect(testProject.getTracks()).toHaveLength(initialTrackCount);
// Verify store state unchanged // Verify store state unchanged
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.tracks).toHaveLength(initialTrackCount) expect(storeState.tracks).toHaveLength(initialTrackCount);
}) });
it('should handle concurrent state updates correctly', async () => { it('should handle concurrent state updates correctly', async () => {
const { setBpm, setMaxBars } = useProjectStore.getState() const { setBpm, setMaxBars } = useProjectStore.getState();
// Execute multiple state updates concurrently // Execute multiple state updates concurrently
await act(async () => { await act(async () => {
setBpm(140) setBpm(140);
setMaxBars(64) setMaxBars(64);
}) });
// Verify both updates were applied to core model // Verify both updates were applied to core model
expect(testProject.getBpm()).toBe(140) expect(testProject.getBpm()).toBe(140);
expect(testProject.getMaxBars()).toBe(64) expect(testProject.getMaxBars()).toBe(64);
// Verify store state is consistent // Verify store state is consistent
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.bpm).toBe(140) expect(storeState.bpm).toBe(140);
expect(storeState.maxBars).toBe(64) expect(storeState.maxBars).toBe(64);
}) });
}) });
describe('Project Loading Integration', () => { describe('Project Loading Integration', () => {
it('should completely sync store state when loading new project', async () => { it('should completely sync store state when loading new project', async () => {
const { loadProject } = useProjectStore.getState() const { loadProject } = useProjectStore.getState();
// Create a new project with specific properties // Create a new project with specific properties
const newProject = new KGProject('New Loaded Project') const newProject = new KGProject('New Loaded Project');
newProject.setBpm(160) newProject.setBpm(160);
newProject.setTimeSignature({ numerator: 6, denominator: 8 }) newProject.setTimeSignature({ numerator: 6, denominator: 8 });
newProject.setKeySignature('D major') newProject.setKeySignature('D major');
newProject.setMaxBars(48) newProject.setMaxBars(48);
// Add a track with region and notes // Add a track with region and notes
const track = new KGMidiTrack('Loaded Track', 0, 'violin') const track = new KGMidiTrack('Loaded Track', 0, 'violin');
const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8) const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8);
const note = new KGMidiNote('test-note', 0, 1, 64, 100) const note = new KGMidiNote('test-note', 0, 1, 64, 100);
region.addNote(note) region.addNote(note);
track.addRegion(region) track.addRegion(region);
newProject.setTracks([track]) newProject.setTracks([track]);
// Load the new project // Load the new project
await act(async () => { await act(async () => {
await loadProject(newProject) await loadProject(newProject);
}) });
// Verify store state completely matches new project // Verify store state completely matches new project
const storeState = useProjectStore.getState() const storeState = useProjectStore.getState();
expect(storeState.projectName).toBe('New Loaded Project') expect(storeState.projectName).toBe('New Loaded Project');
expect(storeState.bpm).toBe(160) expect(storeState.bpm).toBe(160);
expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 }) expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 });
expect(storeState.keySignature).toBe('D major') expect(storeState.keySignature).toBe('D major');
expect(storeState.maxBars).toBe(48) expect(storeState.maxBars).toBe(48);
expect(storeState.tracks).toHaveLength(1) expect(storeState.tracks).toHaveLength(1);
// Verify core model is updated // Verify core model is updated
const core = KGCore.instance() const core = KGCore.instance();
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project') expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project');
// Verify CSS properties were updated // Verify CSS properties were updated
const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator') const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
expect(timeSignatureCSS.trim()).toBe('6') expect(timeSignatureCSS.trim()).toBe('6');
const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars') const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
expect(maxBarsCSS.trim()).toBe('48') expect(maxBarsCSS.trim()).toBe('48');
}) });
}) });
}) });
@@ -1,62 +1,62 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest';
import { plainToInstance } from 'class-transformer' import { plainToInstance } from 'class-transformer';
import { convertRegionToABCNotation } from '../../../util/abcNotationUtil' import { convertRegionToABCNotation } from '../../../util/abcNotationUtil';
import { KGMidiRegion } from '../../../core/region/KGMidiRegion' import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
import { KGMidiTrack } from '../../../core/track/KGMidiTrack' import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
import { KGCore } from '../../../core/KGCore' import { KGCore } from '../../../core/KGCore';
import { KGProject } from '../../../core/KGProject' import { KGProject } from '../../../core/KGProject';
// Import the test fixture // Import the test fixture
import joyProjectData from '../../fixtures/joy-project.json' import joyProjectData from '../../fixtures/joy-project.json';
// Helper function to load project using real class-transformer deserialization (same as UI) // Helper function to load project using real class-transformer deserialization (same as UI)
function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject { function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject {
// Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport) // Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport)
const deserializedResult = plainToInstance(KGProject, projectData) const deserializedResult = plainToInstance(KGProject, projectData);
// Handle case where plainToInstance might return an array (same as UI) // Handle case where plainToInstance might return an array (same as UI)
const deserializedProject = Array.isArray(deserializedResult) const deserializedProject = Array.isArray(deserializedResult)
? deserializedResult[0] || null ? deserializedResult[0] || null
: deserializedResult : deserializedResult;
if (!deserializedProject) { if (!deserializedProject) {
throw new Error("Failed to deserialize project data") throw new Error("Failed to deserialize project data");
} }
return deserializedProject return deserializedProject;
} }
describe('abcNotationUtil - Integration Tests with Real Project Data', () => { describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
let joyProject: KGProject let joyProject: KGProject;
beforeEach(() => { beforeEach(() => {
// Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport // Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport
// First parse as JSON (simulating file.text() -> JSON.parse()) // First parse as JSON (simulating file.text() -> JSON.parse())
const fileContent = JSON.stringify(joyProjectData) const fileContent = JSON.stringify(joyProjectData);
const projectData = JSON.parse(fileContent) const projectData = JSON.parse(fileContent);
// Then deserialize using class-transformer (same as UI) // Then deserialize using class-transformer (same as UI)
joyProject = loadProjectFromJSON(projectData) joyProject = loadProjectFromJSON(projectData);
// Mock KGCore to return the loaded project (same as production flow) // Mock KGCore to return the loaded project (same as production flow)
vi.spyOn(KGCore, 'instance').mockReturnValue({ vi.spyOn(KGCore, 'instance').mockReturnValue({
getCurrentProject: () => joyProject getCurrentProject: () => joyProject
} as unknown as KGCore) } as unknown as KGCore);
}) });
describe('convertRegionToABCNotation with real project data', () => { describe('convertRegionToABCNotation with real project data', () => {
it('should convert joy project melody region to exact ABC notation', () => { it('should convert joy project melody region to exact ABC notation', () => {
// Get the melody track and region using real project structure // Get the melody track and region using real project structure
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
expect(melodyTrack.getName()).toBe('Melody') expect(melodyTrack.getName()).toBe('Melody');
expect(melodyTrack.getRegions()).toHaveLength(1) expect(melodyTrack.getRegions()).toHaveLength(1);
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
expect(melodyRegion.getName()).toBe('Melody Region 1') expect(melodyRegion.getName()).toBe('Melody Region 1');
expect(melodyRegion.getNotes()).toHaveLength(30) // Verify we have all 30 notes expect(melodyRegion.getNotes()).toHaveLength(30); // Verify we have all 30 notes
// Convert to ABC notation using real region data // Convert to ABC notation using real region data
const result = convertRegionToABCNotation(melodyRegion, 0, 32) const result = convertRegionToABCNotation(melodyRegion, 0, 32);
// Expected ABC notation output (exactly as provided) // Expected ABC notation output (exactly as provided)
const expectedABCNotation = `X:1 const expectedABCNotation = `X:1
@@ -65,161 +65,161 @@ M:4/4
L:1/4 L:1/4
Q:1/4=125 Q:1/4=125
K:C K:C
E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |` E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |`;
// Verify exact match // Verify exact match
expect(result).toBe(expectedABCNotation) expect(result).toBe(expectedABCNotation);
}) });
it('should handle empty pad chord region from real project', () => { it('should handle empty pad chord region from real project', () => {
// Get the pad chord track and region // Get the pad chord track and region
const padTrack = joyProject.getTracks()[1] as KGMidiTrack const padTrack = joyProject.getTracks()[1] as KGMidiTrack;
expect(padTrack.getName()).toBe('Pad Chord') expect(padTrack.getName()).toBe('Pad Chord');
expect(padTrack.getInstrument()).toBe('pad_1_new_age') expect(padTrack.getInstrument()).toBe('pad_1_new_age');
const padRegion = padTrack.getRegions()[0] as KGMidiRegion const padRegion = padTrack.getRegions()[0] as KGMidiRegion;
expect(padRegion.getName()).toBe('Pad Chord Region 1') expect(padRegion.getName()).toBe('Pad Chord Region 1');
expect(padRegion.getNotes()).toHaveLength(0) // Empty region expect(padRegion.getNotes()).toHaveLength(0); // Empty region
// Convert empty region to ABC notation // Convert empty region to ABC notation
const result = convertRegionToABCNotation(padRegion, 0, 32) const result = convertRegionToABCNotation(padRegion, 0, 32);
// Verify it contains rest notation and proper headers // Verify it contains rest notation and proper headers
expect(result).toBeDefined() expect(result).toBeDefined();
expect(result).toContain('T:Pad Chord Region 1') expect(result).toContain('T:Pad Chord Region 1');
expect(result).toContain('M:4/4') expect(result).toContain('M:4/4');
expect(result).toContain('Q:1/4=125') expect(result).toContain('Q:1/4=125');
expect(result).toContain('K:C') expect(result).toContain('K:C');
expect(result).toContain('z') // Should contain rest notation expect(result).toContain('z'); // Should contain rest notation
}) });
it('should use correct project settings from real project data', () => { it('should use correct project settings from real project data', () => {
// Verify project settings are loaded correctly // Verify project settings are loaded correctly
expect(joyProject.getName()).toBe('joy') expect(joyProject.getName()).toBe('joy');
expect(joyProject.getBpm()).toBe(125) expect(joyProject.getBpm()).toBe(125);
expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 }) expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 });
expect(joyProject.getKeySignature()).toBe('C major') expect(joyProject.getKeySignature()).toBe('C major');
expect(joyProject.getMaxBars()).toBe(32) expect(joyProject.getMaxBars()).toBe(32);
// These settings should be reflected in ABC notation headers // These settings should be reflected in ABC notation headers
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
const result = convertRegionToABCNotation(melodyRegion, 0, 32) const result = convertRegionToABCNotation(melodyRegion, 0, 32);
expect(result).toContain('Q:1/4=125') // BPM expect(result).toContain('Q:1/4=125'); // BPM
expect(result).toContain('M:4/4') // Time signature expect(result).toContain('M:4/4'); // Time signature
expect(result).toContain('K:C') // Key signature expect(result).toContain('K:C'); // Key signature
}) });
it('should handle real note timing and pitches correctly', () => { it('should handle real note timing and pitches correctly', () => {
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
const notes = melodyRegion.getNotes() const notes = melodyRegion.getNotes();
// Verify some key notes from the real data // Verify some key notes from the real data
expect(notes[0].getPitch()).toBe(64) // First note is E (64) expect(notes[0].getPitch()).toBe(64); // First note is E (64)
expect(notes[0].getStartBeat()).toBe(0) expect(notes[0].getStartBeat()).toBe(0);
expect(notes[0].getEndBeat()).toBe(1) expect(notes[0].getEndBeat()).toBe(1);
expect(notes[3].getPitch()).toBe(67) // Fourth note is G (67) expect(notes[3].getPitch()).toBe(67); // Fourth note is G (67)
expect(notes[3].getStartBeat()).toBe(3) expect(notes[3].getStartBeat()).toBe(3);
expect(notes[3].getEndBeat()).toBe(4) expect(notes[3].getEndBeat()).toBe(4);
// Verify fractional timing note (beat 12-13.5) // Verify fractional timing note (beat 12-13.5)
const fractionalNote = notes.find(note => note.getEndBeat() === 13.5) const fractionalNote = notes.find(note => note.getEndBeat() === 13.5);
expect(fractionalNote).toBeDefined() expect(fractionalNote).toBeDefined();
expect(fractionalNote!.getPitch()).toBe(64) // E expect(fractionalNote!.getPitch()).toBe(64); // E
expect(fractionalNote!.getStartBeat()).toBe(12) expect(fractionalNote!.getStartBeat()).toBe(12);
// Convert and verify these real timings are reflected in ABC notation // Convert and verify these real timings are reflected in ABC notation
const result = convertRegionToABCNotation(melodyRegion, 0, 32) const result = convertRegionToABCNotation(melodyRegion, 0, 32);
expect(result).toContain('E3/2 D1/2') // Fractional timing should appear in ABC expect(result).toContain('E3/2 D1/2'); // Fractional timing should appear in ABC
}) });
it('should handle multiple tracks with different instruments', () => { it('should handle multiple tracks with different instruments', () => {
const tracks = joyProject.getTracks() const tracks = joyProject.getTracks();
expect(tracks).toHaveLength(2) expect(tracks).toHaveLength(2);
// Verify track properties from real project // Verify track properties from real project
const melodyTrack = tracks[0] as KGMidiTrack const melodyTrack = tracks[0] as KGMidiTrack;
expect(melodyTrack.getName()).toBe('Melody') expect(melodyTrack.getName()).toBe('Melody');
expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano') expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano');
expect(melodyTrack.getVolume()).toBe(0.8) expect(melodyTrack.getVolume()).toBe(0.8);
const padTrack = tracks[1] as KGMidiTrack const padTrack = tracks[1] as KGMidiTrack;
expect(padTrack.getName()).toBe('Pad Chord') expect(padTrack.getName()).toBe('Pad Chord');
expect(padTrack.getInstrument()).toBe('pad_1_new_age') expect(padTrack.getInstrument()).toBe('pad_1_new_age');
expect(padTrack.getVolume()).toBe(1) expect(padTrack.getVolume()).toBe(1);
// Both tracks should be processable for ABC notation // Both tracks should be processable for ABC notation
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
const padRegion = padTrack.getRegions()[0] as KGMidiRegion const padRegion = padTrack.getRegions()[0] as KGMidiRegion;
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32) const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32);
const padABC = convertRegionToABCNotation(padRegion, 0, 32) const padABC = convertRegionToABCNotation(padRegion, 0, 32);
expect(melodyABC).toContain('T:Melody Region 1') expect(melodyABC).toContain('T:Melody Region 1');
expect(padABC).toContain('T:Pad Chord Region 1') expect(padABC).toContain('T:Pad Chord Region 1');
}) });
it('should verify complete deserialization hierarchy', () => { it('should verify complete deserialization hierarchy', () => {
// Test that class-transformer properly restored the entire object hierarchy // Test that class-transformer properly restored the entire object hierarchy
expect(joyProject).toBeInstanceOf(KGProject) expect(joyProject).toBeInstanceOf(KGProject);
const tracks = joyProject.getTracks() const tracks = joyProject.getTracks();
tracks.forEach(track => { tracks.forEach(track => {
expect(track).toBeInstanceOf(KGMidiTrack) expect(track).toBeInstanceOf(KGMidiTrack);
const regions = track.getRegions() const regions = track.getRegions();
regions.forEach(region => { regions.forEach(region => {
expect(region).toBeInstanceOf(KGMidiRegion) expect(region).toBeInstanceOf(KGMidiRegion);
const notes = (region as KGMidiRegion).getNotes() const notes = (region as KGMidiRegion).getNotes();
notes.forEach(note => { notes.forEach(note => {
// Notes should have proper methods and properties // Notes should have proper methods and properties
expect(typeof note.getId()).toBe('string') expect(typeof note.getId()).toBe('string');
expect(typeof note.getPitch()).toBe('number') expect(typeof note.getPitch()).toBe('number');
expect(typeof note.getStartBeat()).toBe('number') expect(typeof note.getStartBeat()).toBe('number');
expect(typeof note.getEndBeat()).toBe('number') expect(typeof note.getEndBeat()).toBe('number');
expect(typeof note.getVelocity()).toBe('number') expect(typeof note.getVelocity()).toBe('number');
}) });
}) });
}) });
// Verify type identifiers are preserved // Verify type identifiers are preserved
tracks.forEach(track => { tracks.forEach(track => {
expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack') expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack');
}) });
}) });
}) });
describe('edge cases with real project data', () => { describe('edge cases with real project data', () => {
it('should handle partial region conversion', () => { it('should handle partial region conversion', () => {
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
// Test converting only first 8 beats (2 bars) // Test converting only first 8 beats (2 bars)
const result = convertRegionToABCNotation(melodyRegion, 0, 8) const result = convertRegionToABCNotation(melodyRegion, 0, 8);
expect(result).toBeDefined() expect(result).toBeDefined();
expect(result).toContain('T:Melody Region 1') expect(result).toContain('T:Melody Region 1');
// Should only contain the first 2 bars of music // Should only contain the first 2 bars of music
expect(result).not.toContain('D3/2 C1/2 C2') // This appears later in the song expect(result).not.toContain('D3/2 C1/2 C2'); // This appears later in the song
}) });
it('should handle mid-region start point', () => { it('should handle mid-region start point', () => {
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
// Test converting from beat 16 to 24 (second half of melody) // Test converting from beat 16 to 24 (second half of melody)
const result = convertRegionToABCNotation(melodyRegion, 16, 24) const result = convertRegionToABCNotation(melodyRegion, 16, 24);
expect(result).toBeDefined() expect(result).toBeDefined();
expect(result).toContain('T:Melody Region 1') expect(result).toContain('T:Melody Region 1');
// Should start from the second repetition // Should start from the second repetition
const lines = result.split('\n') const lines = result.split('\n');
const musicLine = lines[lines.length - 1] // Last line contains the music const musicLine = lines[lines.length - 1]; // Last line contains the music
expect(musicLine).toBeTruthy() expect(musicLine).toBeTruthy();
}) });
}) });
}) });
+3 -3
View File
@@ -2,7 +2,7 @@
* Mock implementation of KGAudioInterface for integration tests * Mock implementation of KGAudioInterface for integration tests
* Provides interface compatibility while avoiding actual audio operations * Provides interface compatibility while avoiding actual audio operations
*/ */
import { vi } from 'vitest' import { vi } from 'vitest';
export const mockAudioInterface = { export const mockAudioInterface = {
// Audio context management // Audio context management
@@ -34,7 +34,7 @@ export const mockAudioInterface = {
// Singleton pattern // Singleton pattern
getInstance: vi.fn().mockReturnThis(), getInstance: vi.fn().mockReturnThis(),
} };
// Mock the class constructor // Mock the class constructor
export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface) export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface);
+20 -17
View File
@@ -2,54 +2,57 @@
* Mock implementation of IndexedDB for integration tests * Mock implementation of IndexedDB for integration tests
* Provides in-memory storage that mimics IndexedDB interface * Provides in-memory storage that mimics IndexedDB interface
*/ */
import { vi } from 'vitest' import { vi } from 'vitest';
// In-memory storage for tests // In-memory storage for tests
const mockStorage = new Map<string, any>() // eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockStorage = new Map<string, any>();
export const mockIndexedDB = { export const mockIndexedDB = {
openDB: vi.fn().mockImplementation(() => { openDB: vi.fn().mockImplementation(() => {
return Promise.resolve({ return Promise.resolve({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => { put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => {
const actualKey = key || data.id || 'default' const actualKey = key || data.id || 'default';
mockStorage.set(`${storeName}:${actualKey}`, data) mockStorage.set(`${storeName}:${actualKey}`, data);
return Promise.resolve(actualKey) return Promise.resolve(actualKey);
}), }),
get: vi.fn().mockImplementation((storeName: string, key: string) => { get: vi.fn().mockImplementation((storeName: string, key: string) => {
return Promise.resolve(mockStorage.get(`${storeName}:${key}`)) return Promise.resolve(mockStorage.get(`${storeName}:${key}`));
}), }),
getAll: vi.fn().mockImplementation((storeName: string) => { getAll: vi.fn().mockImplementation((storeName: string) => {
const results: any[] = [] // eslint-disable-next-line @typescript-eslint/no-explicit-any
const results: any[] = [];
for (const [key, value] of mockStorage.entries()) { for (const [key, value] of mockStorage.entries()) {
if (key.startsWith(`${storeName}:`)) { if (key.startsWith(`${storeName}:`)) {
results.push(value) results.push(value);
} }
} }
return Promise.resolve(results) return Promise.resolve(results);
}), }),
delete: vi.fn().mockImplementation((storeName: string, key: string) => { delete: vi.fn().mockImplementation((storeName: string, key: string) => {
mockStorage.delete(`${storeName}:${key}`) mockStorage.delete(`${storeName}:${key}`);
return Promise.resolve() return Promise.resolve();
}), }),
clear: vi.fn().mockImplementation((storeName: string) => { clear: vi.fn().mockImplementation((storeName: string) => {
for (const key of mockStorage.keys()) { for (const key of mockStorage.keys()) {
if (key.startsWith(`${storeName}:`)) { if (key.startsWith(`${storeName}:`)) {
mockStorage.delete(key) mockStorage.delete(key);
} }
} }
return Promise.resolve() return Promise.resolve();
}), }),
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
}) });
}), }),
} };
// Helper function to clear mock storage between tests // Helper function to clear mock storage between tests
export const clearMockStorage = () => { export const clearMockStorage = () => {
mockStorage.clear() mockStorage.clear();
} };
+4 -4
View File
@@ -2,7 +2,7 @@
* Mock implementation of Tone.js for integration tests * Mock implementation of Tone.js for integration tests
* Provides interface compatibility while avoiding actual audio operations * Provides interface compatibility while avoiding actual audio operations
*/ */
import { vi } from 'vitest' import { vi } from 'vitest';
// Mock Sampler class // Mock Sampler class
export const mockSampler = { export const mockSampler = {
@@ -16,7 +16,7 @@ export const mockSampler = {
disconnect: vi.fn().mockReturnThis(), disconnect: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(),
get: vi.fn().mockReturnValue({}), get: vi.fn().mockReturnValue({}),
} };
// Mock Transport // Mock Transport
export const mockTransport = { export const mockTransport = {
@@ -30,7 +30,7 @@ export const mockTransport = {
schedule: vi.fn(), schedule: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
cancel: vi.fn(), cancel: vi.fn(),
} };
// Mock Tone namespace // Mock Tone namespace
export const mockTone = { export const mockTone = {
@@ -55,4 +55,4 @@ export const mockTone = {
state: 'running', state: 'running',
resume: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined),
}, },
} };
+35 -13
View File
@@ -1,4 +1,4 @@
import { vi } from 'vitest' import { vi } from 'vitest';
/** /**
* Mock implementation of Tone.js for testing * Mock implementation of Tone.js for testing
@@ -18,14 +18,14 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
connect: vi.fn(), connect: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
toDestination: vi.fn() toDestination: vi.fn()
})) }));
// Mock Transport object // Mock Transport object
export const MockTransport = { export const MockTransport = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
pause: vi.fn(), pause: vi.fn(),
position: '0:0:0', position: 0,
bpm: { bpm: {
value: 120, value: 120,
rampTo: vi.fn() rampTo: vi.fn()
@@ -34,9 +34,22 @@ export const MockTransport = {
state: 'stopped', state: 'stopped',
scheduleOnce: vi.fn(), scheduleOnce: vi.fn(),
scheduleRepeat: vi.fn(), scheduleRepeat: vi.fn(),
schedule: vi.fn().mockReturnValue(1),
cancel: vi.fn(), cancel: vi.fn(),
clear: vi.fn() clear: vi.fn()
} ,
setLoopPoints: vi.fn(),
loop: false,
PPQ: 192,
getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192)
};
export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({
callback,
interval,
start: vi.fn(),
dispose: vi.fn()
}));
// Mock Destination // Mock Destination
export const MockDestination = { export const MockDestination = {
@@ -46,7 +59,7 @@ export const MockDestination = {
mute: false, mute: false,
connect: vi.fn(), connect: vi.fn(),
disconnect: vi.fn() disconnect: vi.fn()
} };
// Mock ToneAudioBuffer // Mock ToneAudioBuffer
export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({ export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
@@ -55,7 +68,7 @@ export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
get: vi.fn(), get: vi.fn(),
set: vi.fn(), set: vi.fn(),
load: vi.fn().mockResolvedValue(undefined) load: vi.fn().mockResolvedValue(undefined)
})) }));
// Mock Gain node // Mock Gain node
export const MockGain = vi.fn().mockImplementation(() => ({ export const MockGain = vi.fn().mockImplementation(() => ({
@@ -68,7 +81,7 @@ export const MockGain = vi.fn().mockImplementation(() => ({
connect: vi.fn(), connect: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
dispose: vi.fn() dispose: vi.fn()
})) }));
// Mock Meter // Mock Meter
export const MockMeter = vi.fn().mockImplementation(() => ({ export const MockMeter = vi.fn().mockImplementation(() => ({
@@ -76,11 +89,12 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
connect: vi.fn(), connect: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
dispose: vi.fn() dispose: vi.fn()
})) }));
// Complete Tone.js mock // Complete Tone.js mock
export const ToneMock = { export const ToneMock = {
Sampler: MockSampler, Sampler: MockSampler,
Loop: MockLoop,
Transport: MockTransport, Transport: MockTransport,
Destination: MockDestination, Destination: MockDestination,
ToneAudioBuffer: MockToneAudioBuffer, ToneAudioBuffer: MockToneAudioBuffer,
@@ -93,7 +107,14 @@ export const ToneMock = {
state: 'running', state: 'running',
resume: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined),
suspend: vi.fn().mockResolvedValue(undefined), suspend: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined) close: vi.fn().mockResolvedValue(undefined),
lookAhead: 0.05,
setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => {
return window.setTimeout(fn, timeoutSeconds * 1000);
}),
clearTimeout: vi.fn().mockImplementation((id: number) => {
window.clearTimeout(id);
})
}), }),
// Time utilities // Time utilities
@@ -106,10 +127,11 @@ export const ToneMock = {
Frequency: vi.fn().mockImplementation((freq) => ({ Frequency: vi.fn().mockImplementation((freq) => ({
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440), toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440) valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
})) })),
} now: vi.fn().mockImplementation(() => Date.now() / 1000)
};
// Setup the global mock // Setup the global mock
export const setupToneMocks = () => { export const setupToneMocks = () => {
vi.doMock('tone', () => ToneMock) vi.doMock('tone', () => ToneMock);
} };
+18 -18
View File
@@ -1,13 +1,13 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom';
import 'reflect-metadata' // Required for class-transformer decorators import 'reflect-metadata'; // Required for class-transformer decorators
import { beforeAll, afterEach, vi } from 'vitest' import { beforeAll, afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react' import { cleanup } from '@testing-library/react';
// Import our custom Tone.js mocks // Import our custom Tone.js mocks
import { setupToneMocks } from './mocks/tone' import { setupToneMocks } from './mocks/tone';
// Setup global mocks // Setup global mocks
setupToneMocks() setupToneMocks();
// Mock KGCore globally to prevent store initialization issues // Mock KGCore globally to prevent store initialization issues
vi.mock('../core/KGCore', () => ({ vi.mock('../core/KGCore', () => ({
@@ -24,22 +24,22 @@ vi.mock('../core/KGCore', () => ({
executeCommand: vi.fn() executeCommand: vi.fn()
}) })
} }
})) }));
// Global test setup for all unit tests // Global test setup for all unit tests
// Clean up after each test // Clean up after each test
afterEach(() => { afterEach(() => {
cleanup() cleanup();
vi.clearAllMocks() vi.clearAllMocks();
}) });
// Setup before all tests // Setup before all tests
beforeAll(() => { beforeAll(() => {
// Mock console methods to reduce noise in tests // Mock console methods to reduce noise in tests
vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock window.matchMedia (needed for some UI components) // Mock window.matchMedia (needed for some UI components)
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
@@ -54,16 +54,16 @@ beforeAll(() => {
removeEventListener: vi.fn(), removeEventListener: vi.fn(),
dispatchEvent: vi.fn(), dispatchEvent: vi.fn(),
})), })),
}) });
// Mock ResizeObserver (might be needed for some components) // Mock ResizeObserver (might be needed for some components)
global.ResizeObserver = vi.fn().mockImplementation(() => ({ global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(), observe: vi.fn(),
unobserve: vi.fn(), unobserve: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
})) }));
// Mock URL.createObjectURL (might be needed for file operations) // Mock URL.createObjectURL (might be needed for file operations)
global.URL.createObjectURL = vi.fn(() => 'mocked-url') global.URL.createObjectURL = vi.fn(() => 'mocked-url');
global.URL.revokeObjectURL = vi.fn() global.URL.revokeObjectURL = vi.fn();
}) });
+27 -27
View File
@@ -1,7 +1,7 @@
import { KGMidiNote } from '../../core/midi/KGMidiNote' import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGProject } from '../../core/KGProject' import { KGProject } from '../../core/KGProject';
import { KGMidiTrack } from '../../core/track/KGMidiTrack' import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion' import { KGMidiRegion } from '../../core/region/KGMidiRegion';
/** /**
* Test data factories for creating mock objects * Test data factories for creating mock objects
@@ -22,7 +22,7 @@ export const createMockMidiNote = (overrides: Partial<{
pitch: 60, // Middle C pitch: 60, // Middle C
velocity: 80, velocity: 80,
...overrides ...overrides
} };
return new KGMidiNote( return new KGMidiNote(
defaults.id, defaults.id,
@@ -30,8 +30,8 @@ export const createMockMidiNote = (overrides: Partial<{
defaults.endBeat, defaults.endBeat,
defaults.pitch, defaults.pitch,
defaults.velocity defaults.velocity
) );
} };
export const createMockMidiRegion = (overrides: Partial<{ export const createMockMidiRegion = (overrides: Partial<{
id: string id: string
@@ -50,7 +50,7 @@ export const createMockMidiRegion = (overrides: Partial<{
startFromBeat: 0, startFromBeat: 0,
length: 4, length: 4,
...overrides ...overrides
} };
const region = new KGMidiRegion( const region = new KGMidiRegion(
defaults.id, defaults.id,
@@ -59,15 +59,15 @@ export const createMockMidiRegion = (overrides: Partial<{
defaults.name, defaults.name,
defaults.startFromBeat, defaults.startFromBeat,
defaults.length defaults.length
) );
// Add notes if provided // Add notes if provided
if (overrides.notes) { if (overrides.notes) {
overrides.notes.forEach(note => region.addNote(note)) overrides.notes.forEach(note => region.addNote(note));
} }
return region return region;
} };
export const createMockMidiTrack = (overrides: Partial<{ export const createMockMidiTrack = (overrides: Partial<{
name: string name: string
@@ -82,22 +82,22 @@ export const createMockMidiTrack = (overrides: Partial<{
instrument: 'acoustic_grand_piano' as const, instrument: 'acoustic_grand_piano' as const,
volume: 0.8, volume: 0.8,
...overrides ...overrides
} };
const track = new KGMidiTrack( const track = new KGMidiTrack(
defaults.name, defaults.name,
defaults.id, defaults.id,
defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP, defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP,
defaults.volume defaults.volume
) );
// Add regions if provided // Add regions if provided
if (overrides.regions) { if (overrides.regions) {
track.setRegions(overrides.regions) track.setRegions(overrides.regions);
} }
return track return track;
} };
export const createMockProject = (overrides: Partial<{ export const createMockProject = (overrides: Partial<{
name: string name: string
@@ -111,7 +111,7 @@ export const createMockProject = (overrides: Partial<{
timeSignature: { numerator: 4, denominator: 4 }, timeSignature: { numerator: 4, denominator: 4 },
tracks: [], tracks: [],
...overrides ...overrides
} };
const project = new KGProject( const project = new KGProject(
defaults.name, defaults.name,
@@ -126,34 +126,34 @@ export const createMockProject = (overrides: Partial<{
1, // barWidthMultiplier 1, // barWidthMultiplier
defaults.tracks, // tracks defaults.tracks, // tracks
5 // projectStructureVersion 5 // projectStructureVersion
) );
return project return project;
} };
// Common test scenarios // Common test scenarios
export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => { export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => {
const notes = [ const notes = [
createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }), createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }),
createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }), createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }),
] ];
const region = createMockMidiRegion({ const region = createMockMidiRegion({
id: 'region-1', id: 'region-1',
trackId: 'track-1', trackId: 'track-1',
notes notes
}) });
const track = createMockMidiTrack({ const track = createMockMidiTrack({
id: 1, id: 1,
name: 'Track 1', name: 'Track 1',
regions: [region] regions: [region]
}) });
const project = createMockProject({ const project = createMockProject({
name: 'Basic Test Project', name: 'Basic Test Project',
tracks: [track] tracks: [track]
}) });
return { project, track, region } return { project, track, region };
} };
+31 -31
View File
@@ -2,43 +2,43 @@
* Integration test setup utilities * Integration test setup utilities
* Common setup and teardown for integration tests * Common setup and teardown for integration tests
*/ */
import { beforeEach, afterEach, vi } from 'vitest' import { beforeEach, afterEach, vi } from 'vitest';
import { mockAudioInterface } from '../mocks/audio-interface' import { mockAudioInterface } from '../mocks/audio-interface';
import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db' import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db';
import { mockTone } from '../mocks/tone-js' import { mockTone } from '../mocks/tone-js';
import { KGCore } from '../../core/KGCore' import { KGCore } from '../../core/KGCore';
import { KGProject } from '../../core/KGProject' import { KGProject } from '../../core/KGProject';
// Initialize KGCore with a default project at module load time // Initialize KGCore with a default project at module load time
// This prevents errors when projectStore module initializes and tries to access currentProject // This prevents errors when projectStore module initializes and tries to access currentProject
// This runs synchronously when the module is imported, before any tests // This runs synchronously when the module is imported, before any tests
const initializeKGCore = async () => { const initializeKGCore = async () => {
const defaultProject = new KGProject('Test Setup Project') const defaultProject = new KGProject('Test Setup Project');
const core = KGCore.instance() const core = KGCore.instance();
await core.initialize() await core.initialize();
core.setCurrentProject(defaultProject) core.setCurrentProject(defaultProject);
} };
// Run initialization immediately at module load // Run initialization immediately at module load
await initializeKGCore() await initializeKGCore();
// Global setup for integration tests // Global setup for integration tests
beforeEach(() => { beforeEach(() => {
// Clear all mocks // Clear all mocks
vi.clearAllMocks() vi.clearAllMocks();
// Clear mock storage // Clear mock storage
clearMockStorage() clearMockStorage();
// Mock external dependencies while keeping internal components real // Mock external dependencies while keeping internal components real
vi.doMock('../../audio/KGAudioInterface', () => ({ vi.doMock('../../audio/KGAudioInterface', () => ({
KGAudioInterface: mockAudioInterface, KGAudioInterface: mockAudioInterface,
default: mockAudioInterface, default: mockAudioInterface,
})) }));
vi.doMock('idb', () => mockIndexedDB) vi.doMock('idb', () => mockIndexedDB);
vi.doMock('tone', () => mockTone) vi.doMock('tone', () => mockTone);
// Mock browser APIs that might be used // Mock browser APIs that might be used
Object.defineProperty(window, 'AudioContext', { Object.defineProperty(window, 'AudioContext', {
@@ -47,19 +47,19 @@ beforeEach(() => {
state: 'running', state: 'running',
resume: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined),
})), })),
}) });
Object.defineProperty(window, 'webkitAudioContext', { Object.defineProperty(window, 'webkitAudioContext', {
writable: true, writable: true,
value: window.AudioContext, value: window.AudioContext,
}) });
}) });
afterEach(() => { afterEach(() => {
// Clean up after each test // Clean up after each test
vi.restoreAllMocks() vi.restoreAllMocks();
clearMockStorage() clearMockStorage();
}) });
// Helper functions for integration tests // Helper functions for integration tests
export const createMockProject = () => { export const createMockProject = () => {
@@ -73,8 +73,8 @@ export const createMockProject = () => {
keySignature: 'C major', keySignature: 'C major',
maxBars: 32, maxBars: 32,
tracks: [], tracks: [],
} };
} };
export const createMockTrack = (name = 'Test Track') => { export const createMockTrack = (name = 'Test Track') => {
return { return {
@@ -83,8 +83,8 @@ export const createMockTrack = (name = 'Test Track') => {
instrument: 'acoustic_grand_piano', instrument: 'acoustic_grand_piano',
volume: 0.8, volume: 0.8,
regions: [], regions: [],
} };
} };
export const createMockRegion = (name = 'Test Region') => { export const createMockRegion = (name = 'Test Region') => {
return { return {
@@ -93,8 +93,8 @@ export const createMockRegion = (name = 'Test Region') => {
startBeat: 0, startBeat: 0,
endBeat: 4, endBeat: 4,
notes: [], notes: [],
} };
} };
export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => { export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
return { return {
@@ -103,5 +103,5 @@ export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
startBeat, startBeat,
endBeat, endBeat,
velocity: 100, velocity: 100,
} };
} };
+6 -6
View File
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react';
import { render, type RenderOptions } from '@testing-library/react' import { render, type RenderOptions } from '@testing-library/react';
// Custom render function that includes any providers your app needs // Custom render function that includes any providers your app needs
// This can be extended later with Zustand store providers, etc. // This can be extended later with Zustand store providers, etc.
@@ -13,10 +13,10 @@ const customRender = (
// If you need to wrap components with providers (like Zustand store), // If you need to wrap components with providers (like Zustand store),
// you can create an AllTheProviders wrapper here // you can create an AllTheProviders wrapper here
return render(ui, options) return render(ui, options);
} };
// Re-export everything from testing-library/react // Re-export everything from testing-library/react
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react' export * from '@testing-library/react';
export { customRender as render } export { customRender as render };
+65
View File
@@ -0,0 +1,65 @@
export interface ConfirmOptions {
confirmLabel?: string;
cancelLabel?: string;
}
export interface PromptOptions {
confirmLabel?: string;
cancelLabel?: string;
placeholder?: string;
}
export interface TimeSigResult {
numerator: number;
denominator: number;
}
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
export function registerDialogFns(
alertFn: (message: string) => Promise<void>,
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
) {
_showAlertFn = alertFn;
_showConfirmFn = confirmFn;
_showPromptFn = promptFn;
_showTimeSigFn = timeSigFn;
}
export function showAlert(message: string): Promise<void> {
if (!_showAlertFn) {
window.alert(message);
return Promise.resolve();
}
return _showAlertFn(message);
}
export function showConfirm(message: string, options?: ConfirmOptions): Promise<boolean> {
if (!_showConfirmFn) {
return Promise.resolve(window.confirm(message));
}
return _showConfirmFn(message, options);
}
export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> {
if (!_showPromptFn) {
return Promise.resolve(window.prompt(message, defaultValue));
}
return _showPromptFn(message, defaultValue, options);
}
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
if (!_showTimeSigFn) {
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
if (!raw) return Promise.resolve(null);
const [n, d] = raw.split('/').map(Number);
if (!n || !d) return Promise.resolve(null);
return Promise.resolve({ numerator: n, denominator: d });
}
return _showTimeSigFn(message, defaultValue);
}
+113 -113
View File
@@ -1,186 +1,186 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { import {
beatsToBar, beatsToBar,
pitchToNoteNameString, pitchToNoteNameString,
pitchToNoteName, pitchToNoteName,
pianoRollIndexToPitch, pianoRollIndexToPitch,
noteNameToPitch noteNameToPitch
} from './midiUtil' } from './midiUtil';
describe('midiUtil', () => { describe('midiUtil', () => {
describe('beatsToBar', () => { describe('beatsToBar', () => {
it('should convert beats to bar position object', () => { it('should convert beats to bar position object', () => {
const result1 = beatsToBar(0, { numerator: 4, denominator: 4 }) const result1 = beatsToBar(0, { numerator: 4, denominator: 4 });
expect(result1.bar).toBe(0) expect(result1.bar).toBe(0);
expect(result1.beatInBar).toBe(0) expect(result1.beatInBar).toBe(0);
const result2 = beatsToBar(4, { numerator: 4, denominator: 4 }) const result2 = beatsToBar(4, { numerator: 4, denominator: 4 });
expect(result2.bar).toBe(1) expect(result2.bar).toBe(1);
expect(result2.beatInBar).toBe(0) expect(result2.beatInBar).toBe(0);
const result3 = beatsToBar(8, { numerator: 4, denominator: 4 }) const result3 = beatsToBar(8, { numerator: 4, denominator: 4 });
expect(result3.bar).toBe(2) expect(result3.bar).toBe(2);
expect(result3.beatInBar).toBe(0) expect(result3.beatInBar).toBe(0);
}) });
it('should handle different time signatures', () => { it('should handle different time signatures', () => {
const result1 = beatsToBar(0, { numerator: 3, denominator: 4 }) const result1 = beatsToBar(0, { numerator: 3, denominator: 4 });
expect(result1.bar).toBe(0) expect(result1.bar).toBe(0);
expect(result1.beatInBar).toBe(0) expect(result1.beatInBar).toBe(0);
const result2 = beatsToBar(3, { numerator: 3, denominator: 4 }) const result2 = beatsToBar(3, { numerator: 3, denominator: 4 });
expect(result2.bar).toBe(1) expect(result2.bar).toBe(1);
expect(result2.beatInBar).toBe(0) expect(result2.beatInBar).toBe(0);
}) });
it('should handle fractional beats', () => { it('should handle fractional beats', () => {
const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 }) const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 });
expect(result1.bar).toBe(0) expect(result1.bar).toBe(0);
expect(result1.beatInBar).toBe(2.5) expect(result1.beatInBar).toBe(2.5);
const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 }) const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 });
expect(result2.bar).toBe(1) expect(result2.bar).toBe(1);
expect(result2.beatInBar).toBe(0.5) expect(result2.beatInBar).toBe(0.5);
}) });
}) });
describe('pitchToNoteNameString', () => { describe('pitchToNoteNameString', () => {
it('should convert MIDI pitch to note name with octave', () => { it('should convert MIDI pitch to note name with octave', () => {
expect(pitchToNoteNameString(60)).toBe('C4') // Middle C expect(pitchToNoteNameString(60)).toBe('C4'); // Middle C
expect(pitchToNoteNameString(61)).toBe('C#4') // C# above middle C expect(pitchToNoteNameString(61)).toBe('C#4'); // C# above middle C
expect(pitchToNoteNameString(59)).toBe('B3') // B below middle C expect(pitchToNoteNameString(59)).toBe('B3'); // B below middle C
expect(pitchToNoteNameString(72)).toBe('C5') // C one octave above middle C expect(pitchToNoteNameString(72)).toBe('C5'); // C one octave above middle C
expect(pitchToNoteNameString(48)).toBe('C3') // C one octave below middle C expect(pitchToNoteNameString(48)).toBe('C3'); // C one octave below middle C
}) });
it('should handle edge cases', () => { it('should handle edge cases', () => {
expect(pitchToNoteNameString(0)).toBe('C-1') // Lowest MIDI note expect(pitchToNoteNameString(0)).toBe('C-1'); // Lowest MIDI note
expect(pitchToNoteNameString(127)).toBe('G9') // Highest MIDI note expect(pitchToNoteNameString(127)).toBe('G9'); // Highest MIDI note
}) });
it('should handle all chromatic notes', () => { it('should handle all chromatic notes', () => {
const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4'] const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4'];
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]) expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]);
} }
}) });
}) });
describe('pitchToNoteName', () => { describe('pitchToNoteName', () => {
it('should convert pitch to note name object', () => { it('should convert pitch to note name object', () => {
const result60 = pitchToNoteName(60) // Middle C const result60 = pitchToNoteName(60); // Middle C
expect(result60.note).toBe('C') expect(result60.note).toBe('C');
expect(result60.octave).toBe(4) expect(result60.octave).toBe(4);
const result61 = pitchToNoteName(61) // C# const result61 = pitchToNoteName(61); // C#
expect(result61.note).toBe('C#') expect(result61.note).toBe('C#');
expect(result61.octave).toBe(4) expect(result61.octave).toBe(4);
}) });
it('should wrap around for different octaves', () => { it('should wrap around for different octaves', () => {
const result60 = pitchToNoteName(60) const result60 = pitchToNoteName(60);
const result72 = pitchToNoteName(72) const result72 = pitchToNoteName(72);
const result84 = pitchToNoteName(84) const result84 = pitchToNoteName(84);
expect(result60.note).toBe('C') expect(result60.note).toBe('C');
expect(result72.note).toBe('C') expect(result72.note).toBe('C');
expect(result84.note).toBe('C') expect(result84.note).toBe('C');
expect(result60.octave).toBe(4) expect(result60.octave).toBe(4);
expect(result72.octave).toBe(5) expect(result72.octave).toBe(5);
expect(result84.octave).toBe(6) expect(result84.octave).toBe(6);
}) });
}) });
describe('pianoRollIndexToPitch', () => { describe('pianoRollIndexToPitch', () => {
it('should convert piano roll row index to MIDI pitch', () => { it('should convert piano roll row index to MIDI pitch', () => {
// This function likely maps visual rows to MIDI pitches // This function likely maps visual rows to MIDI pitches
// The exact mapping depends on your implementation // The exact mapping depends on your implementation
const result = pianoRollIndexToPitch(10) const result = pianoRollIndexToPitch(10);
expect(typeof result).toBe('number') expect(typeof result).toBe('number');
expect(result).toBeGreaterThanOrEqual(0) expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(127) expect(result).toBeLessThanOrEqual(127);
}) });
it('should return different pitches for different indices', () => { it('should return different pitches for different indices', () => {
const pitch1 = pianoRollIndexToPitch(0) const pitch1 = pianoRollIndexToPitch(0);
const pitch2 = pianoRollIndexToPitch(1) const pitch2 = pianoRollIndexToPitch(1);
expect(pitch1).not.toBe(pitch2) expect(pitch1).not.toBe(pitch2);
}) });
}) });
describe('noteNameToPitch', () => { describe('noteNameToPitch', () => {
it('should convert note names to MIDI pitch', () => { it('should convert note names to MIDI pitch', () => {
expect(noteNameToPitch('C4')).toBe(60) // Middle C expect(noteNameToPitch('C4')).toBe(60); // Middle C
expect(noteNameToPitch('C#4')).toBe(61) // C# above middle C expect(noteNameToPitch('C#4')).toBe(61); // C# above middle C
expect(noteNameToPitch('D4')).toBe(62) // D above middle C expect(noteNameToPitch('D4')).toBe(62); // D above middle C
}) });
it('should handle different octaves', () => { it('should handle different octaves', () => {
expect(noteNameToPitch('C3')).toBe(48) // C below middle C expect(noteNameToPitch('C3')).toBe(48); // C below middle C
expect(noteNameToPitch('C5')).toBe(72) // C above middle C expect(noteNameToPitch('C5')).toBe(72); // C above middle C
}) });
it('should handle sharps', () => { it('should handle sharps', () => {
expect(noteNameToPitch('C#4')).toBe(61) expect(noteNameToPitch('C#4')).toBe(61);
expect(noteNameToPitch('F#4')).toBe(66) expect(noteNameToPitch('F#4')).toBe(66);
expect(noteNameToPitch('G#4')).toBe(68) expect(noteNameToPitch('G#4')).toBe(68);
}) });
it('should handle invalid note names', () => { it('should handle invalid note names', () => {
expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4') // Flats not supported expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4') // Invalid note expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C') // Missing octave expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave
}) });
}) });
describe('edge cases and error handling', () => { describe('edge cases and error handling', () => {
it('should handle negative values gracefully', () => { it('should handle negative values gracefully', () => {
expect(() => pitchToNoteNameString(-1)).not.toThrow() expect(() => pitchToNoteNameString(-1)).not.toThrow();
expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow() expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow();
}) });
it('should handle very large values', () => { it('should handle very large values', () => {
expect(() => pitchToNoteNameString(200)).not.toThrow() expect(() => pitchToNoteNameString(200)).not.toThrow();
expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow() expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow();
}) });
it('should handle zero values', () => { it('should handle zero values', () => {
expect(pitchToNoteNameString(0)).toBeDefined() expect(pitchToNoteNameString(0)).toBeDefined();
expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined() expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined();
}) });
}) });
describe('mathematical consistency', () => { describe('mathematical consistency', () => {
it('should maintain pitch relationships', () => { it('should maintain pitch relationships', () => {
// One octave = 12 semitones - note names should be the same // One octave = 12 semitones - note names should be the same
const baseNote = pitchToNoteName(60) const baseNote = pitchToNoteName(60);
const octaveNote = pitchToNoteName(72) const octaveNote = pitchToNoteName(72);
expect(baseNote.note).toBe(octaveNote.note) // Both should be 'C' expect(baseNote.note).toBe(octaveNote.note); // Both should be 'C'
expect(octaveNote.octave).toBe(baseNote.octave + 1) // Octave should be one higher expect(octaveNote.octave).toBe(baseNote.octave + 1); // Octave should be one higher
}) });
it('should maintain beat-to-bar relationships', () => { it('should maintain beat-to-bar relationships', () => {
const timeSignature = { numerator: 4, denominator: 4 } const timeSignature = { numerator: 4, denominator: 4 };
// Should increment bar by 1 for each complete measure // Should increment bar by 1 for each complete measure
for (let beat = 0; beat < 20; beat += 4) { for (let beat = 0; beat < 20; beat += 4) {
const expectedBar = Math.floor(beat / 4) const expectedBar = Math.floor(beat / 4);
const result = beatsToBar(beat, timeSignature) const result = beatsToBar(beat, timeSignature);
expect(result.bar).toBe(expectedBar) expect(result.bar).toBe(expectedBar);
expect(result.beatInBar).toBe(0) // Should be at start of bar expect(result.beatInBar).toBe(0); // Should be at start of bar
} }
}) });
it('should maintain note name to pitch conversion consistency', () => { it('should maintain note name to pitch conversion consistency', () => {
// Converting pitch to note name and back should be consistent // Converting pitch to note name and back should be consistent
const originalPitch = 60 const originalPitch = 60;
const noteObj = pitchToNoteName(originalPitch) const noteObj = pitchToNoteName(originalPitch);
const noteName = `${noteObj.note}${noteObj.octave}` const noteName = `${noteObj.note}${noteObj.octave}`;
const convertedPitch = noteNameToPitch(noteName) const convertedPitch = noteNameToPitch(noteName);
expect(convertedPitch).toBe(originalPitch) expect(convertedPitch).toBe(originalPitch);
}) });
}) });
}) });
+1 -1
View File
@@ -1,7 +1,7 @@
import { KGProjectStorage } from '../core/io/KGProjectStorage'; import { KGProjectStorage } from '../core/io/KGProjectStorage';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
import { RESERVED_PROJECT_NAME } from './projectNameUtil'; import { RESERVED_PROJECT_NAME } from './projectNameUtil';
import { showAlert } from '../components/common/DialogProvider'; import { showAlert } from './dialogUtil';
/** /**
* Save project utility function. * Save project utility function.
+292 -292
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest';
import { import {
getRootNoteFromKeySignature, getRootNoteFromKeySignature,
noteNameToPitchClass, noteNameToPitchClass,
@@ -9,208 +9,208 @@ import {
getMatchingChordsForPitch, getMatchingChordsForPitch,
generatePianoGridBackground, generatePianoGridBackground,
validateFunctionalChordsJSON validateFunctionalChordsJSON
} from './scaleUtil' } from './scaleUtil';
import { KGCore } from '../core/KGCore' import { KGCore } from '../core/KGCore';
import type { KeySignature } from '../core/KGProject' import type { KeySignature } from '../core/KGProject';
import functionalChordsData from '../../public/resources/modes/functional_chords.json' import functionalChordsData from '../../public/resources/modes/functional_chords.json';
describe('scaleUtil', () => { describe('scaleUtil', () => {
// Setup: Mock KGCore with real chord data // Setup: Mock KGCore with real chord data
beforeEach(() => { beforeEach(() => {
// Use real functional chords data from JSON file (includes name, steps, and chord data) // Use real functional chords data from JSON file (includes name, steps, and chord data)
KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData;
}) });
describe('getRootNoteFromKeySignature', () => { describe('getRootNoteFromKeySignature', () => {
it('should extract root note from C major', () => { it('should extract root note from C major', () => {
expect(getRootNoteFromKeySignature('C major')).toBe('C') expect(getRootNoteFromKeySignature('C major')).toBe('C');
}) });
it('should extract root note from F# minor', () => { it('should extract root note from F# minor', () => {
expect(getRootNoteFromKeySignature('F# minor')).toBe('F#') expect(getRootNoteFromKeySignature('F# minor')).toBe('F#');
}) });
it('should extract root note from Bb major', () => { it('should extract root note from Bb major', () => {
expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb') expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb');
}) });
it('should handle Db major', () => { it('should handle Db major', () => {
expect(getRootNoteFromKeySignature('Db major')).toBe('Db') expect(getRootNoteFromKeySignature('Db major')).toBe('Db');
}) });
it('should default to C for invalid format', () => { it('should default to C for invalid format', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C') expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format')) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'));
warnSpy.mockRestore() warnSpy.mockRestore();
}) });
}) });
describe('noteNameToPitchClass', () => { describe('noteNameToPitchClass', () => {
it('should convert C to 0', () => { it('should convert C to 0', () => {
expect(noteNameToPitchClass('C')).toBe(0) expect(noteNameToPitchClass('C')).toBe(0);
}) });
it('should convert C# to 1', () => { it('should convert C# to 1', () => {
expect(noteNameToPitchClass('C#')).toBe(1) expect(noteNameToPitchClass('C#')).toBe(1);
}) });
it('should convert Db to 1', () => { it('should convert Db to 1', () => {
expect(noteNameToPitchClass('Db')).toBe(1) expect(noteNameToPitchClass('Db')).toBe(1);
}) });
it('should convert D to 2', () => { it('should convert D to 2', () => {
expect(noteNameToPitchClass('D')).toBe(2) expect(noteNameToPitchClass('D')).toBe(2);
}) });
it('should convert E to 4', () => { it('should convert E to 4', () => {
expect(noteNameToPitchClass('E')).toBe(4) expect(noteNameToPitchClass('E')).toBe(4);
}) });
it('should convert F to 5', () => { it('should convert F to 5', () => {
expect(noteNameToPitchClass('F')).toBe(5) expect(noteNameToPitchClass('F')).toBe(5);
}) });
it('should convert F# to 6', () => { it('should convert F# to 6', () => {
expect(noteNameToPitchClass('F#')).toBe(6) expect(noteNameToPitchClass('F#')).toBe(6);
}) });
it('should convert G to 7', () => { it('should convert G to 7', () => {
expect(noteNameToPitchClass('G')).toBe(7) expect(noteNameToPitchClass('G')).toBe(7);
}) });
it('should convert A to 9', () => { it('should convert A to 9', () => {
expect(noteNameToPitchClass('A')).toBe(9) expect(noteNameToPitchClass('A')).toBe(9);
}) });
it('should convert Bb to 10', () => { it('should convert Bb to 10', () => {
expect(noteNameToPitchClass('Bb')).toBe(10) expect(noteNameToPitchClass('Bb')).toBe(10);
}) });
it('should convert B to 11', () => { it('should convert B to 11', () => {
expect(noteNameToPitchClass('B')).toBe(11) expect(noteNameToPitchClass('B')).toBe(11);
}) });
it('should default to 0 for invalid note', () => { it('should default to 0 for invalid note', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(noteNameToPitchClass('X')).toBe(0) expect(noteNameToPitchClass('X')).toBe(0);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name')) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'));
warnSpy.mockRestore() warnSpy.mockRestore();
}) });
}) });
describe('getModeSteps', () => { describe('getModeSteps', () => {
it('should return ionian steps', () => { it('should return ionian steps', () => {
expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]) expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]);
}) });
it('should return dorian steps', () => { it('should return dorian steps', () => {
expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]) expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]);
}) });
it('should default to ionian for invalid mode', () => { it('should default to ionian for invalid mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]) expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found')) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'));
warnSpy.mockRestore() warnSpy.mockRestore();
}) });
}) });
describe('getScalePitchClasses', () => { describe('getScalePitchClasses', () => {
it('should return C major scale pitch classes', () => { it('should return C major scale pitch classes', () => {
const steps = [2, 2, 1, 2, 2, 2, 1] const steps = [2, 2, 1, 2, 2, 2, 1];
const result = getScalePitchClasses('C', steps) const result = getScalePitchClasses('C', steps);
expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]) // C D E F G A B expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]); // C D E F G A B
}) });
it('should return D major scale pitch classes', () => { it('should return D major scale pitch classes', () => {
const steps = [2, 2, 1, 2, 2, 2, 1] const steps = [2, 2, 1, 2, 2, 2, 1];
const result = getScalePitchClasses('D', steps) const result = getScalePitchClasses('D', steps);
expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]) // D E F# G A B C# expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]); // D E F# G A B C#
}) });
it('should return F# dorian scale pitch classes', () => { it('should return F# dorian scale pitch classes', () => {
const steps = [2, 1, 2, 2, 2, 1, 2] const steps = [2, 1, 2, 2, 2, 1, 2];
const result = getScalePitchClasses('F#', steps) const result = getScalePitchClasses('F#', steps);
expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]) // F# G# A B C# D# E expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]); // F# G# A B C# D# E
}) });
}) });
describe('getSuitableChords', () => { describe('getSuitableChords', () => {
it('should return tonic chords for C major ionian', () => { it('should return tonic chords for C major ionian', () => {
const result = getSuitableChords('C major', 'ionian', 'T') const result = getSuitableChords('C major', 'ionian', 'T');
expect(result).toHaveProperty('I') expect(result).toHaveProperty('I');
expect(result).toHaveProperty('vi') expect(result).toHaveProperty('vi');
expect(result).toHaveProperty('iii') expect(result).toHaveProperty('iii');
expect(result['I']).toEqual(['C', 'E', 'G']) expect(result['I']).toEqual(['C', 'E', 'G']);
expect(result['vi']).toEqual(['A', 'C', 'E']) expect(result['vi']).toEqual(['A', 'C', 'E']);
}) });
it('should return subdominant chords for C major ionian', () => { it('should return subdominant chords for C major ionian', () => {
const result = getSuitableChords('C major', 'ionian', 'S') const result = getSuitableChords('C major', 'ionian', 'S');
expect(result).toHaveProperty('IV') expect(result).toHaveProperty('IV');
expect(result).toHaveProperty('ii') expect(result).toHaveProperty('ii');
expect(result['IV']).toEqual(['F', 'A', 'C']) expect(result['IV']).toEqual(['F', 'A', 'C']);
expect(result['ii']).toEqual(['D', 'F', 'A']) expect(result['ii']).toEqual(['D', 'F', 'A']);
}) });
it('should return dominant chords for C major ionian', () => { it('should return dominant chords for C major ionian', () => {
const result = getSuitableChords('C major', 'ionian', 'D') const result = getSuitableChords('C major', 'ionian', 'D');
expect(result).toHaveProperty('V') expect(result).toHaveProperty('V');
expect(result).toHaveProperty('V7') expect(result).toHaveProperty('V7');
expect(result['V']).toEqual(['G', 'B', 'D']) expect(result['V']).toEqual(['G', 'B', 'D']);
expect(result['V7']).toEqual(['G', 'B', 'D', 'F']) expect(result['V7']).toEqual(['G', 'B', 'D', 'F']);
}) });
it('should transpose chords for D major ionian', () => { it('should transpose chords for D major ionian', () => {
const result = getSuitableChords('D major', 'ionian', 'T') const result = getSuitableChords('D major', 'ionian', 'T');
expect(result['I']).toEqual(['D', 'F#', 'A']) expect(result['I']).toEqual(['D', 'F#', 'A']);
expect(result['vi']).toEqual(['B', 'D', 'F#']) expect(result['vi']).toEqual(['B', 'D', 'F#']);
}) });
it('should transpose chords for F# major ionian', () => { it('should transpose chords for F# major ionian', () => {
const result = getSuitableChords('F# major', 'ionian', 'T') const result = getSuitableChords('F# major', 'ionian', 'T');
expect(result['I']).toEqual(['F#', 'A#', 'C#']) expect(result['I']).toEqual(['F#', 'A#', 'C#']);
}) });
it('should return empty object for invalid mode', () => { it('should return empty object for invalid mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = getSuitableChords('C major', 'invalid', 'T') const result = getSuitableChords('C major', 'invalid', 'T');
expect(result).toEqual({}) expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found')) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'));
warnSpy.mockRestore() warnSpy.mockRestore();
}) });
}) });
describe('getChordNotesInKey', () => { describe('getChordNotesInKey', () => {
it('should return I chord notes in C major ionian', () => { it('should return I chord notes in C major ionian', () => {
const result = getChordNotesInKey('I', 'C major', 'ionian') const result = getChordNotesInKey('I', 'C major', 'ionian');
expect(result).toEqual(['C', 'E', 'G']) expect(result).toEqual(['C', 'E', 'G']);
}) });
it('should return V7 chord notes in C major ionian', () => { it('should return V7 chord notes in C major ionian', () => {
const result = getChordNotesInKey('V7', 'C major', 'ionian') const result = getChordNotesInKey('V7', 'C major', 'ionian');
expect(result).toEqual(['G', 'B', 'D', 'F']) expect(result).toEqual(['G', 'B', 'D', 'F']);
}) });
it('should transpose to D major', () => { it('should transpose to D major', () => {
const result = getChordNotesInKey('I', 'D major', 'ionian') const result = getChordNotesInKey('I', 'D major', 'ionian');
expect(result).toEqual(['D', 'F#', 'A']) expect(result).toEqual(['D', 'F#', 'A']);
}) });
it('should return empty array for invalid chord symbol', () => { it('should return empty array for invalid chord symbol', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = getChordNotesInKey('invalid', 'C major', 'ionian') const result = getChordNotesInKey('invalid', 'C major', 'ionian');
expect(result).toEqual([]) expect(result).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found')) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'));
warnSpy.mockRestore() warnSpy.mockRestore();
}) });
}) });
describe('getMatchingChordsForPitch', () => { describe('getMatchingChordsForPitch', () => {
describe('valid inputs', () => { describe('valid inputs', () => {
it('should return matching chords for C (pitch 60) in C major ionian tonic', () => { it('should return matching chords for C (pitch 60) in C major ionian tonic', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
// C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"] // C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"]
// Pitch 60 = C (pitch class 0) // Pitch 60 = C (pitch class 0)
@@ -222,11 +222,11 @@ describe('scaleUtil', () => {
[0, 4, 7], // I chord (C-E-G): C is root [0, 4, 7], // I chord (C-E-G): C is root
[-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied [-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied
[-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied [-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied
]) ]);
}) });
it('should return matching chords for E (pitch 64) in C major ionian tonic', () => { it('should return matching chords for E (pitch 64) in C major ionian tonic', () => {
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T') const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T');
// Pitch 64 = E (pitch class 4) // Pitch 64 = E (pitch class 4)
// Expected matches prioritized by position: // Expected matches prioritized by position:
@@ -239,19 +239,19 @@ describe('scaleUtil', () => {
[4, 7, 12], // I⁶ chord (E-G-C): E is root [4, 7, 12], // I⁶ chord (E-G-C): E is root
[0, 4, 7], // I chord (C-E-G): E is 2nd [0, 4, 7], // I chord (C-E-G): E is 2nd
[-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied [-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied
]) ]);
}) });
it('should prioritize root matches over other positions', () => { it('should prioritize root matches over other positions', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') // C const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C
// First chord should have C as root (position 0) // First chord should have C as root (position 0)
if (result.length > 0) { if (result.length > 0) {
expect(result[0][0] % 12).toBe(0) // First note of first chord should be C expect(result[0][0] % 12).toBe(0); // First note of first chord should be C
} }
}) });
it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => { it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => {
const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S') // F const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S'); // F
// Pitch 65 = F (pitch class 5) // Pitch 65 = F (pitch class 5)
// C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"] // C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"]
@@ -263,11 +263,11 @@ describe('scaleUtil', () => {
[5, 9, 12], // IV chord (F-A-C): F is root, offset applied [5, 9, 12], // IV chord (F-A-C): F is root, offset applied
[2, 5, 9], // ii chord (D-F-A): F is 2nd [2, 5, 9], // ii chord (D-F-A): F is 2nd
[-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied [-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied
]) ]);
}) });
it('should return matching chords for G (pitch 67) in C major ionian dominant', () => { it('should return matching chords for G (pitch 67) in C major ionian dominant', () => {
const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D') // G const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D'); // G
// Pitch 67 = G (pitch class 7) // Pitch 67 = G (pitch class 7)
// C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"] // C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"]
@@ -277,122 +277,122 @@ describe('scaleUtil', () => {
expect(result).toEqual([ expect(result).toEqual([
[7, 11, 14], // V chord (G-B-D): G is root, offset applied [7, 11, 14], // V chord (G-B-D): G is root, offset applied
[7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied [7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied
]) ]);
}) });
it('should work with different modes (dorian)', () => { it('should work with different modes (dorian)', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T') const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T');
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0);
}) });
it('should transpose correctly for D major', () => { it('should transpose correctly for D major', () => {
const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T') // D const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T'); // D
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0);
expect(result.some(chord => chord.includes(2))).toBe(true) // Contains D (pitch class 2) expect(result.some(chord => chord.includes(2))).toBe(true); // Contains D (pitch class 2)
}) });
it('should handle all MIDI pitch ranges (low)', () => { it('should handle all MIDI pitch ranges (low)', () => {
const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T') // C1 const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T'); // C1
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true);
}) });
it('should handle all MIDI pitch ranges (high)', () => { it('should handle all MIDI pitch ranges (high)', () => {
const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T') // C8 const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T'); // C8
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true);
}) });
}) });
describe('edge cases', () => { describe('edge cases', () => {
it('should return empty array for invalid mode', () => { it('should return empty array for invalid mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T') const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T');
expect(result).toEqual([]) expect(result).toEqual([]);
warnSpy.mockRestore() warnSpy.mockRestore();
}) });
it('should handle pitch class calculations correctly', () => { it('should handle pitch class calculations correctly', () => {
// Test that pitch 60 (C4) and pitch 72 (C5) both match C chords // Test that pitch 60 (C4) and pitch 72 (C5) both match C chords
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T') const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
expect(result1.length).toBe(result2.length) // Same chords match expect(result1.length).toBe(result2.length); // Same chords match
}) });
it('should return pitch classes in ascending order', () => { it('should return pitch classes in ascending order', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
result.forEach(chord => { result.forEach(chord => {
for (let i = 1; i < chord.length; i++) { for (let i = 1; i < chord.length; i++) {
expect(chord[i]).toBeGreaterThan(chord[i - 1]) expect(chord[i]).toBeGreaterThan(chord[i - 1]);
} }
}) });
}) });
it('should apply octave offset correctly for pitch classes >= 12', () => { it('should apply octave offset correctly for pitch classes >= 12', () => {
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
// All pitch classes should be < 12 after offset // All pitch classes should be < 12 after offset
result.forEach(chord => { result.forEach(chord => {
chord.forEach(pitchClass => { chord.forEach(pitchClass => {
expect(pitchClass).toBeLessThan(24) // Allowing for extended range expect(pitchClass).toBeLessThan(24); // Allowing for extended range
}) });
}) });
}) });
}) });
describe('pitch class calculations', () => { describe('pitch class calculations', () => {
it('should correctly convert hover pitch to pitch class', () => { it('should correctly convert hover pitch to pitch class', () => {
// C4 (60) should match same chords as C5 (72) // C4 (60) should match same chords as C5 (72)
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T') const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
expect(result1).toEqual(result2) expect(result1).toEqual(result2);
}) });
it('should maintain ascending pitch order in results', () => { it('should maintain ascending pitch order in results', () => {
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T') const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T');
result.forEach(chord => { result.forEach(chord => {
for (let i = 1; i < chord.length; i++) { for (let i = 1; i < chord.length; i++) {
expect(chord[i]).toBeGreaterThan(chord[i - 1]) expect(chord[i]).toBeGreaterThan(chord[i - 1]);
} }
}) });
}) });
}) });
describe('integration with helper functions', () => { describe('integration with helper functions', () => {
it('should work with getSuitableChords', () => { it('should work with getSuitableChords', () => {
const suitableChords = getSuitableChords('C major', 'ionian', 'T') const suitableChords = getSuitableChords('C major', 'ionian', 'T');
expect(Object.keys(suitableChords).length).toBeGreaterThan(0) expect(Object.keys(suitableChords).length).toBeGreaterThan(0);
const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
expect(matchingChords.length).toBeGreaterThan(0) expect(matchingChords.length).toBeGreaterThan(0);
}) });
it('should work with noteNameToPitchClass', () => { it('should work with noteNameToPitchClass', () => {
const cPitch = noteNameToPitchClass('C') const cPitch = noteNameToPitchClass('C');
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true) expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true);
}) });
it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => { it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => {
// Verify the data has the expected structure // Verify the data has the expected structure
const data = KGCore.FUNCTIONAL_CHORDS_DATA const data = KGCore.FUNCTIONAL_CHORDS_DATA;
expect(data).toHaveProperty('ionian') expect(data).toHaveProperty('ionian');
expect(data['ionian']).toHaveProperty('T') expect(data['ionian']).toHaveProperty('T');
expect(data['ionian']).toHaveProperty('chords') expect(data['ionian']).toHaveProperty('chords');
}) });
}) });
}) });
describe('generatePianoGridBackground', () => { describe('generatePianoGridBackground', () => {
it('should generate CSS background for C major ionian', () => { it('should generate CSS background for C major ionian', () => {
const result = generatePianoGridBackground('ionian', 'C major') const result = generatePianoGridBackground('ionian', 'C major');
expect(result).toContain('linear-gradient') expect(result).toContain('linear-gradient');
expect(typeof result).toBe('string') expect(typeof result).toBe('string');
}) });
it('should generate different backgrounds for different modes', () => { it('should generate different backgrounds for different modes', () => {
const ionian = generatePianoGridBackground('ionian', 'C major') const ionian = generatePianoGridBackground('ionian', 'C major');
const dorian = generatePianoGridBackground('dorian', 'C major') const dorian = generatePianoGridBackground('dorian', 'C major');
expect(ionian).not.toBe(dorian) expect(ionian).not.toBe(dorian);
}) });
}) });
describe('validateFunctionalChordsJSON', () => { describe('validateFunctionalChordsJSON', () => {
const validJSON = `{ const validJSON = `{
@@ -434,14 +434,14 @@ describe('scaleUtil', () => {
"♭VII": ["Bb", "D", "F"] "♭VII": ["Bb", "D", "F"]
} }
} }
}` }`;
describe('valid JSON', () => { describe('valid JSON', () => {
it('should validate correct JSON with ionian and aeolian', () => { it('should validate correct JSON with ionian and aeolian', () => {
const result = validateFunctionalChordsJSON(validJSON) const result = validateFunctionalChordsJSON(validJSON);
expect(result.valid).toBe(true) expect(result.valid).toBe(true);
expect(result.errors).toEqual([]) expect(result.errors).toEqual([]);
}) });
it('should accept empty T/S/D arrays', () => { it('should accept empty T/S/D arrays', () => {
const json = `{ const json = `{
@@ -453,10 +453,10 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(true) expect(result.valid).toBe(true);
}) });
it('should accept mode names with underscores, dashes, and spaces', () => { it('should accept mode names with underscores, dashes, and spaces', () => {
const json = `{ const json = `{
@@ -468,10 +468,10 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(true) expect(result.valid).toBe(true);
}) });
it('should accept notes with sharp and flat', () => { it('should accept notes with sharp and flat', () => {
const json = `{ const json = `{
@@ -485,10 +485,10 @@ describe('scaleUtil', () => {
"I": ["C#", "Eb", "F#"] "I": ["C#", "Eb", "F#"]
} }
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(true) expect(result.valid).toBe(true);
}) });
it('should accept chord symbols with special characters', () => { it('should accept chord symbols with special characters', () => {
const json = `{ const json = `{
@@ -505,24 +505,24 @@ describe('scaleUtil', () => {
"♭II": ["Db", "F", "Ab"] "♭II": ["Db", "F", "Ab"]
} }
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(true) expect(result.valid).toBe(true);
}) });
}) });
describe('invalid JSON', () => { describe('invalid JSON', () => {
it('should reject malformed JSON', () => { it('should reject malformed JSON', () => {
const result = validateFunctionalChordsJSON('{ invalid json }') const result = validateFunctionalChordsJSON('{ invalid json }');
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors).toContain('Invalid JSON format') expect(result.errors).toContain('Invalid JSON format');
}) });
it('should reject JSON array as root', () => { it('should reject JSON array as root', () => {
const result = validateFunctionalChordsJSON('[]') const result = validateFunctionalChordsJSON('[]');
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors).toContain('Root must be an object') expect(result.errors).toContain('Root must be an object');
}) });
it('should reject missing ionian mode', () => { it('should reject missing ionian mode', () => {
const json = `{ const json = `{
@@ -534,11 +534,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing required mode: "ionian"') expect(result.errors).toContain('Missing required mode: "ionian"');
}) });
it('should reject invalid mode name', () => { it('should reject invalid mode name', () => {
const json = `{ const json = `{
@@ -550,11 +550,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true) expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true);
}) });
it('should reject steps with wrong number of elements', () => { it('should reject steps with wrong number of elements', () => {
const json = `{ const json = `{
@@ -566,11 +566,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true) expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true);
}) });
it('should reject steps that do not sum to 12', () => { it('should reject steps that do not sum to 12', () => {
const json = `{ const json = `{
@@ -582,11 +582,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true) expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true);
}) });
it('should reject non-integer steps', () => { it('should reject non-integer steps', () => {
const json = `{ const json = `{
@@ -598,11 +598,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true) expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true);
}) });
it('should reject invalid chord symbol in T/S/D', () => { it('should reject invalid chord symbol in T/S/D', () => {
const json = `{ const json = `{
@@ -616,11 +616,11 @@ describe('scaleUtil', () => {
"invalid123": ["C", "E", "G"] "invalid123": ["C", "E", "G"]
} }
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true) expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true);
}) });
it('should reject chord referenced but not defined', () => { it('should reject chord referenced but not defined', () => {
const json = `{ const json = `{
@@ -632,11 +632,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true) expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true);
}) });
it('should reject invalid note name', () => { it('should reject invalid note name', () => {
const json = `{ const json = `{
@@ -650,11 +650,11 @@ describe('scaleUtil', () => {
"I": ["C", "X", "G"] "I": ["C", "X", "G"]
} }
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true) expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
}) });
it('should reject note with invalid accidental', () => { it('should reject note with invalid accidental', () => {
const json = `{ const json = `{
@@ -668,11 +668,11 @@ describe('scaleUtil', () => {
"I": ["C##", "E", "G"] "I": ["C##", "E", "G"]
} }
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true) expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
}) });
it('should reject lowercase note names', () => { it('should reject lowercase note names', () => {
const json = `{ const json = `{
@@ -686,12 +686,12 @@ describe('scaleUtil', () => {
"I": ["c", "e", "g"] "I": ["c", "e", "g"]
} }
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true) expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
}) });
}) });
describe('error messages', () => { describe('error messages', () => {
it('should provide descriptive error messages for multiple errors', () => { it('should provide descriptive error messages for multiple errors', () => {
@@ -704,11 +704,11 @@ describe('scaleUtil', () => {
"D": [], "D": [],
"chords": {} "chords": {}
} }
}` }`;
const result = validateFunctionalChordsJSON(json) const result = validateFunctionalChordsJSON(json);
expect(result.valid).toBe(false) expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(1) expect(result.errors.length).toBeGreaterThan(1);
}) });
}) });
}) });
}) });
+162 -162
View File
@@ -1,316 +1,316 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil' import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil';
import { TIME_CONSTANTS } from '../constants/coreConstants' import { TIME_CONSTANTS } from '../constants/coreConstants';
describe('timeUtil', () => { describe('timeUtil', () => {
describe('parseTimeSignature', () => { describe('parseTimeSignature', () => {
it('should parse valid time signatures', () => { it('should parse valid time signatures', () => {
expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 }) expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 });
expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 }) expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 });
expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 }) expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 });
expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 }) expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 });
expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 }) expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 });
}) });
it('should handle whitespace around input', () => { it('should handle whitespace around input', () => {
expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 }) expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 });
expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 }) expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 });
expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 }) expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 });
}) });
it('should return null for invalid formats', () => { it('should return null for invalid formats', () => {
expect(parseTimeSignature('4')).toBeNull() expect(parseTimeSignature('4')).toBeNull();
expect(parseTimeSignature('4/4/4')).toBeNull() expect(parseTimeSignature('4/4/4')).toBeNull();
expect(parseTimeSignature('4-4')).toBeNull() expect(parseTimeSignature('4-4')).toBeNull();
expect(parseTimeSignature('4:4')).toBeNull() expect(parseTimeSignature('4:4')).toBeNull();
expect(parseTimeSignature('')).toBeNull() expect(parseTimeSignature('')).toBeNull();
expect(parseTimeSignature('/')).toBeNull() expect(parseTimeSignature('/')).toBeNull();
expect(parseTimeSignature('4/')).toBeNull() expect(parseTimeSignature('4/')).toBeNull();
expect(parseTimeSignature('/4')).toBeNull() expect(parseTimeSignature('/4')).toBeNull();
}) });
it('should return null for non-numeric values', () => { it('should return null for non-numeric values', () => {
expect(parseTimeSignature('a/4')).toBeNull() expect(parseTimeSignature('a/4')).toBeNull();
expect(parseTimeSignature('4/b')).toBeNull() expect(parseTimeSignature('4/b')).toBeNull();
expect(parseTimeSignature('x/y')).toBeNull() expect(parseTimeSignature('x/y')).toBeNull();
// Note: parseInt('4.5') returns 4, so these will parse as integers // Note: parseInt('4.5') returns 4, so these will parse as integers
// Testing the actual behavior of parseInt // Testing the actual behavior of parseInt
expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 }) expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 });
expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 }) expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 });
}) });
it('should return null for numerators not in available list', () => { it('should return null for numerators not in available list', () => {
// Assuming TIME_CONSTANTS has specific available numerators // Assuming TIME_CONSTANTS has specific available numerators
expect(parseTimeSignature('99/4')).toBeNull() expect(parseTimeSignature('99/4')).toBeNull();
expect(parseTimeSignature('0/4')).toBeNull() expect(parseTimeSignature('0/4')).toBeNull();
expect(parseTimeSignature('-1/4')).toBeNull() expect(parseTimeSignature('-1/4')).toBeNull();
}) });
it('should return null for denominators not in available list', () => { it('should return null for denominators not in available list', () => {
// Assuming TIME_CONSTANTS has specific available denominators // Assuming TIME_CONSTANTS has specific available denominators
expect(parseTimeSignature('4/99')).toBeNull() expect(parseTimeSignature('4/99')).toBeNull();
expect(parseTimeSignature('4/0')).toBeNull() expect(parseTimeSignature('4/0')).toBeNull();
expect(parseTimeSignature('4/-1')).toBeNull() expect(parseTimeSignature('4/-1')).toBeNull();
}) });
it('should validate against TIME_CONSTANTS available values', () => { it('should validate against TIME_CONSTANTS available values', () => {
// Test that function actually uses TIME_CONSTANTS for validation // Test that function actually uses TIME_CONSTANTS for validation
const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0] const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0];
const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0] const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0];
const invalidNumerator = 999 // Assuming this is not in the available list const invalidNumerator = 999; // Assuming this is not in the available list
const invalidDenominator = 999 // Assuming this is not in the available list const invalidDenominator = 999; // Assuming this is not in the available list
expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull() expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull();
expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull() expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull();
expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull() expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull();
}) });
}) });
describe('getTimeSignatureErrorMessage', () => { describe('getTimeSignatureErrorMessage', () => {
it('should return a formatted error message with available options', () => { it('should return a formatted error message with available options', () => {
const message = getTimeSignatureErrorMessage() const message = getTimeSignatureErrorMessage();
expect(message).toContain('Invalid time signature format') expect(message).toContain('Invalid time signature format');
expect(message).toContain('numerator/denominator') expect(message).toContain('numerator/denominator');
expect(message).toContain('Available numerators:') expect(message).toContain('Available numerators:');
expect(message).toContain('Available denominators:') expect(message).toContain('Available denominators:');
expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8') expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8');
}) });
it('should include actual available values from TIME_CONSTANTS', () => { it('should include actual available values from TIME_CONSTANTS', () => {
const message = getTimeSignatureErrorMessage() const message = getTimeSignatureErrorMessage();
// Check that it includes values from TIME_CONSTANTS // Check that it includes values from TIME_CONSTANTS
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => { TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => {
expect(message).toContain(numerator.toString()) expect(message).toContain(numerator.toString());
}) });
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.forEach(denominator => { TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.forEach(denominator => {
expect(message).toContain(denominator.toString()) expect(message).toContain(denominator.toString());
}) });
}) });
it('should be a consistent message format', () => { it('should be a consistent message format', () => {
const message1 = getTimeSignatureErrorMessage() const message1 = getTimeSignatureErrorMessage();
const message2 = getTimeSignatureErrorMessage() const message2 = getTimeSignatureErrorMessage();
expect(message1).toBe(message2) expect(message1).toBe(message2);
}) });
}) });
describe('beatsToTimeString', () => { describe('beatsToTimeString', () => {
it('should format beats to BBB:B | mm:ss:mmm format', () => { it('should format beats to BBB:B | mm:ss:mmm format', () => {
// Test basic 4/4 time signature // Test basic 4/4 time signature
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
.toBe('001:1 | 00:00:000') .toBe('001:1 | 00:00:000');
expect(beatsToTimeString(4, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(4, 120, { numerator: 4, denominator: 4 }))
.toBe('002:1 | 00:02:000') .toBe('002:1 | 00:02:000');
expect(beatsToTimeString(8, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(8, 120, { numerator: 4, denominator: 4 }))
.toBe('003:1 | 00:04:000') .toBe('003:1 | 00:04:000');
}) });
it('should handle different time signatures correctly', () => { it('should handle different time signatures correctly', () => {
// 3/4 time signature - 3 beats per bar // 3/4 time signature - 3 beats per bar
expect(beatsToTimeString(0, 120, { numerator: 3, denominator: 4 })) expect(beatsToTimeString(0, 120, { numerator: 3, denominator: 4 }))
.toBe('001:1 | 00:00:000') .toBe('001:1 | 00:00:000');
expect(beatsToTimeString(3, 120, { numerator: 3, denominator: 4 })) expect(beatsToTimeString(3, 120, { numerator: 3, denominator: 4 }))
.toBe('002:1 | 00:01:500') .toBe('002:1 | 00:01:500');
expect(beatsToTimeString(6, 120, { numerator: 3, denominator: 4 })) expect(beatsToTimeString(6, 120, { numerator: 3, denominator: 4 }))
.toBe('003:1 | 00:03:000') .toBe('003:1 | 00:03:000');
// 6/8 time signature - 6 beats per bar // 6/8 time signature - 6 beats per bar
expect(beatsToTimeString(0, 120, { numerator: 6, denominator: 8 })) expect(beatsToTimeString(0, 120, { numerator: 6, denominator: 8 }))
.toBe('001:1 | 00:00:000') .toBe('001:1 | 00:00:000');
expect(beatsToTimeString(6, 120, { numerator: 6, denominator: 8 })) expect(beatsToTimeString(6, 120, { numerator: 6, denominator: 8 }))
.toBe('002:1 | 00:03:000') .toBe('002:1 | 00:03:000');
}) });
it('should handle different BPM values correctly', () => { it('should handle different BPM values correctly', () => {
// 60 BPM - 1 beat per second // 60 BPM - 1 beat per second
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
.toBe('001:2 | 00:01:000') .toBe('001:2 | 00:01:000');
expect(beatsToTimeString(4, 60, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(4, 60, { numerator: 4, denominator: 4 }))
.toBe('002:1 | 00:04:000') .toBe('002:1 | 00:04:000');
// 240 BPM - 4 beats per second // 240 BPM - 4 beats per second
expect(beatsToTimeString(1, 240, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(1, 240, { numerator: 4, denominator: 4 }))
.toBe('001:2 | 00:00:250') .toBe('001:2 | 00:00:250');
expect(beatsToTimeString(4, 240, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(4, 240, { numerator: 4, denominator: 4 }))
.toBe('002:1 | 00:01:000') .toBe('002:1 | 00:01:000');
}) });
it('should handle fractional beats correctly', () => { it('should handle fractional beats correctly', () => {
expect(beatsToTimeString(1.5, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(1.5, 120, { numerator: 4, denominator: 4 }))
.toBe('001:2 | 00:00:750') .toBe('001:2 | 00:00:750');
expect(beatsToTimeString(2.25, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(2.25, 120, { numerator: 4, denominator: 4 }))
.toBe('001:3 | 00:01:125') .toBe('001:3 | 00:01:125');
expect(beatsToTimeString(4.75, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(4.75, 120, { numerator: 4, denominator: 4 }))
.toBe('002:1 | 00:02:375') .toBe('002:1 | 00:02:375');
}) });
it('should pad numbers correctly in output format', () => { it('should pad numbers correctly in output format', () => {
// Test bar padding (BBB format) // Test bar padding (BBB format)
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
.toMatch(/^001:/) .toMatch(/^001:/);
expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 }))
.toMatch(/^011:/) // Bar 11 .toMatch(/^011:/); // Bar 11
expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 }))
.toMatch(/^100:/) // Bar 100 .toMatch(/^100:/); // Bar 100
// Test time padding (mm:ss:mmm format) // Test time padding (mm:ss:mmm format)
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
.toMatch(/\| 00:01:000$/) .toMatch(/\| 00:01:000$/);
expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 })) expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 }))
.toMatch(/\| 01:15:000$/) // 1 minute 15 seconds .toMatch(/\| 01:15:000$/); // 1 minute 15 seconds
}) });
it('should handle large beat values', () => { it('should handle large beat values', () => {
const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 }) const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 });
expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/) expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/);
}) });
it('should handle edge case of zero BPM gracefully', () => { it('should handle edge case of zero BPM gracefully', () => {
// This might cause division by zero, should handle gracefully // This might cause division by zero, should handle gracefully
expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 })) expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 }))
.not.toThrow() .not.toThrow();
}) });
it('should handle negative beats', () => { it('should handle negative beats', () => {
// Edge case - negative beats may produce negative values in time format // Edge case - negative beats may produce negative values in time format
const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 }) const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 });
expect(result).toBeDefined() expect(result).toBeDefined();
expect(typeof result).toBe('string') expect(typeof result).toBe('string');
// The function may produce negative time values for negative beats // The function may produce negative time values for negative beats
expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/) expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/);
}) });
}) });
describe('formatLocalDateTime', () => { describe('formatLocalDateTime', () => {
let mockDate: Date let mockDate: Date;
beforeEach(() => { beforeEach(() => {
// Use a fixed date for consistent testing // Use a fixed date for consistent testing
mockDate = new Date('2025-08-21T19:57:11.123Z') mockDate = new Date('2025-08-21T19:57:11.123Z');
}) });
it('should format date with correct structure', () => { it('should format date with correct structure', () => {
const result = formatLocalDateTime(mockDate) const result = formatLocalDateTime(mockDate);
// Should contain date parts // Should contain date parts
expect(result).toMatch(/\d{4}/) // Year expect(result).toMatch(/\d{4}/); // Year
expect(result).toMatch(/\d{2}/) // Month/day/hour/minute/second expect(result).toMatch(/\d{2}/); // Month/day/hour/minute/second
expect(result).toContain(':') // Time separator expect(result).toContain(':'); // Time separator
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/) // Timezone expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/); // Timezone
}) });
it('should use 24-hour format', () => { it('should use 24-hour format', () => {
const morningDate = new Date('2025-08-21T09:30:00Z') const morningDate = new Date('2025-08-21T09:30:00Z');
const eveningDate = new Date('2025-08-21T21:30:00Z') const eveningDate = new Date('2025-08-21T21:30:00Z');
const morningResult = formatLocalDateTime(morningDate) const morningResult = formatLocalDateTime(morningDate);
const eveningResult = formatLocalDateTime(eveningDate) const eveningResult = formatLocalDateTime(eveningDate);
// Should not contain AM/PM indicators // Should not contain AM/PM indicators
expect(morningResult).not.toMatch(/AM|PM/i) expect(morningResult).not.toMatch(/AM|PM/i);
expect(eveningResult).not.toMatch(/AM|PM/i) expect(eveningResult).not.toMatch(/AM|PM/i);
}) });
it('should include timezone information', () => { it('should include timezone information', () => {
const result = formatLocalDateTime(mockDate) const result = formatLocalDateTime(mockDate);
// Should contain some timezone indicator // Should contain some timezone indicator
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/) expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/);
}) });
it('should handle different dates consistently', () => { it('should handle different dates consistently', () => {
const dates = [ const dates = [
new Date('2025-01-01T00:00:00Z'), new Date('2025-01-01T00:00:00Z'),
new Date('2025-06-15T12:30:45Z'), new Date('2025-06-15T12:30:45Z'),
new Date('2025-12-31T23:59:59Z') new Date('2025-12-31T23:59:59Z')
] ];
dates.forEach(date => { dates.forEach(date => {
const result = formatLocalDateTime(date) const result = formatLocalDateTime(date);
expect(result).toBeDefined() expect(result).toBeDefined();
expect(typeof result).toBe('string') expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(10) expect(result.length).toBeGreaterThan(10);
}) });
}) });
it('should handle edge dates', () => { it('should handle edge dates', () => {
const edgeDates = [ const edgeDates = [
new Date('1970-01-01T00:00:00Z'), // Unix epoch new Date('1970-01-01T00:00:00Z'), // Unix epoch
new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit
new Date('2100-12-31T23:59:59Z') // Future date new Date('2100-12-31T23:59:59Z') // Future date
] ];
edgeDates.forEach(date => { edgeDates.forEach(date => {
expect(() => formatLocalDateTime(date)).not.toThrow() expect(() => formatLocalDateTime(date)).not.toThrow();
const result = formatLocalDateTime(date) const result = formatLocalDateTime(date);
expect(typeof result).toBe('string') expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0) expect(result.length).toBeGreaterThan(0);
}) });
}) });
it('should be consistent for the same date', () => { it('should be consistent for the same date', () => {
const result1 = formatLocalDateTime(mockDate) const result1 = formatLocalDateTime(mockDate);
const result2 = formatLocalDateTime(mockDate) const result2 = formatLocalDateTime(mockDate);
expect(result1).toBe(result2) expect(result1).toBe(result2);
}) });
it('should handle leap year dates', () => { it('should handle leap year dates', () => {
const leapYearDate = new Date('2024-02-29T12:00:00Z') // Leap year const leapYearDate = new Date('2024-02-29T12:00:00Z'); // Leap year
expect(() => formatLocalDateTime(leapYearDate)).not.toThrow() expect(() => formatLocalDateTime(leapYearDate)).not.toThrow();
const result = formatLocalDateTime(leapYearDate) const result = formatLocalDateTime(leapYearDate);
expect(result).toContain('2024') expect(result).toContain('2024');
expect(result).toContain('02') expect(result).toContain('02');
expect(result).toContain('29') expect(result).toContain('29');
}) });
}) });
describe('integration tests', () => { describe('integration tests', () => {
it('should work together for typical DAW workflow', () => { it('should work together for typical DAW workflow', () => {
// Parse time signature // Parse time signature
const timeSignature = parseTimeSignature('4/4') const timeSignature = parseTimeSignature('4/4');
expect(timeSignature).not.toBeNull() expect(timeSignature).not.toBeNull();
// Use parsed time signature in time formatting // Use parsed time signature in time formatting
const timeString = beatsToTimeString(16, 120, timeSignature!) const timeString = beatsToTimeString(16, 120, timeSignature!);
expect(timeString).toBe('005:1 | 00:08:000') expect(timeString).toBe('005:1 | 00:08:000');
// Format current time // Format current time
const now = new Date() const now = new Date();
const formattedTime = formatLocalDateTime(now) const formattedTime = formatLocalDateTime(now);
expect(formattedTime).toBeDefined() expect(formattedTime).toBeDefined();
}) });
it('should handle error cases gracefully in workflow', () => { it('should handle error cases gracefully in workflow', () => {
// Invalid time signature should not break workflow // Invalid time signature should not break workflow
const invalidTimeSignature = parseTimeSignature('invalid') const invalidTimeSignature = parseTimeSignature('invalid');
expect(invalidTimeSignature).toBeNull() expect(invalidTimeSignature).toBeNull();
// Get error message for user feedback // Get error message for user feedback
const errorMessage = getTimeSignatureErrorMessage() const errorMessage = getTimeSignatureErrorMessage();
expect(errorMessage).toContain('Invalid time signature') expect(errorMessage).toContain('Invalid time signature');
// Fallback to default time signature // Fallback to default time signature
const fallbackTimeSignature = { numerator: 4, denominator: 4 } const fallbackTimeSignature = { numerator: 4, denominator: 4 };
const timeString = beatsToTimeString(8, 120, fallbackTimeSignature) const timeString = beatsToTimeString(8, 120, fallbackTimeSignature);
expect(timeString).toBe('003:1 | 00:04:000') expect(timeString).toBe('003:1 | 00:04:000');
}) });
}) });
}) });
+204 -204
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil' import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil';
import { import {
longTextWithAddNotes, longTextWithAddNotes,
longTextWithReadMusic, longTextWithReadMusic,
@@ -9,323 +9,323 @@ import {
malformedXml, malformedXml,
noXmlContent, noXmlContent,
emptyAndWhitespaceXml emptyAndWhitespaceXml
} from '../test/fixtures/xml-samples' } from '../test/fixtures/xml-samples';
describe('xmlUtil', () => { describe('xmlUtil', () => {
describe('extractXMLFromString', () => { describe('extractXMLFromString', () => {
it('should extract simple XML blocks', () => { it('should extract simple XML blocks', () => {
const input = `Here is some text with <test>content</test> and more text.` const input = `Here is some text with <test>content</test> and more text.`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toBe('<test>content</test>') expect(result[0]).toBe('<test>content</test>');
}) });
it('should extract multiple XML blocks', () => { it('should extract multiple XML blocks', () => {
const input = `<first>content1</first> some text <second>content2</second>` const input = `<first>content1</first> some text <second>content2</second>`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(2) expect(result).toHaveLength(2);
expect(result[0]).toBe('<first>content1</first>') expect(result[0]).toBe('<first>content1</first>');
expect(result[1]).toBe('<second>content2</second>') expect(result[1]).toBe('<second>content2</second>');
}) });
it('should extract XML blocks with nested elements', () => { it('should extract XML blocks with nested elements', () => {
const input = `<outer><inner>nested content</inner></outer>` const input = `<outer><inner>nested content</inner></outer>`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>') expect(result[0]).toBe('<outer><inner>nested content</inner></outer>');
}) });
it('should extract XML blocks with attributes', () => { it('should extract XML blocks with attributes', () => {
const input = `<tag attr="value" id="123">content</tag>` const input = `<tag attr="value" id="123">content</tag>`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>') expect(result[0]).toBe('<tag attr="value" id="123">content</tag>');
}) });
it('should handle multiline XML blocks', () => { it('should handle multiline XML blocks', () => {
const input = `<multiline> const input = `<multiline>
<line1>content1</line1> <line1>content1</line1>
<line2>content2</line2> <line2>content2</line2>
</multiline>` </multiline>`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toContain('<multiline>') expect(result[0]).toContain('<multiline>');
expect(result[0]).toContain('<line1>content1</line1>') expect(result[0]).toContain('<line1>content1</line1>');
expect(result[0]).toContain('<line2>content2</line2>') expect(result[0]).toContain('<line2>content2</line2>');
expect(result[0]).toContain('</multiline>') expect(result[0]).toContain('</multiline>');
}) });
it('should extract XML from complex nested content fixture', () => { it('should extract XML from complex nested content fixture', () => {
const result = extractXMLFromString(nestedXmlContent) const result = extractXMLFromString(nestedXmlContent);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toContain('<container>') expect(result[0]).toContain('<container>');
expect(result[0]).toContain('<inner_element>') expect(result[0]).toContain('<inner_element>');
expect(result[0]).toContain('<deep_nested>') expect(result[0]).toContain('<deep_nested>');
expect(result[0]).toContain('<value>Test Content</value>') expect(result[0]).toContain('<value>Test Content</value>');
expect(result[0]).toContain('</container>') expect(result[0]).toContain('</container>');
}) });
it('should extract multiple XML blocks from fixture', () => { it('should extract multiple XML blocks from fixture', () => {
const result = extractXMLFromString(multipleXmlBlocks) const result = extractXMLFromString(multipleXmlBlocks);
expect(result).toHaveLength(3) expect(result).toHaveLength(3);
expect(result[0]).toContain('<add_notes>') expect(result[0]).toContain('<add_notes>');
expect(result[0]).toContain('</add_notes>') expect(result[0]).toContain('</add_notes>');
expect(result[1]).toContain('<read_music>') expect(result[1]).toContain('<read_music>');
expect(result[1]).toContain('</read_music>') expect(result[1]).toContain('</read_music>');
expect(result[2]).toContain('<modify_tempo>') expect(result[2]).toContain('<modify_tempo>');
expect(result[2]).toContain('</modify_tempo>') expect(result[2]).toContain('</modify_tempo>');
}) });
it('should extract XML with attributes from fixture', () => { it('should extract XML with attributes from fixture', () => {
const result = extractXMLFromString(xmlWithAttributes) const result = extractXMLFromString(xmlWithAttributes);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toContain('region_id="main"') expect(result[0]).toContain('region_id="main"');
expect(result[0]).toContain('track="melody"') expect(result[0]).toContain('track="melody"');
expect(result[0]).toContain('id="1"') expect(result[0]).toContain('id="1"');
expect(result[0]).toContain('velocity="127"') expect(result[0]).toContain('velocity="127"');
}) });
it('should handle the long text with add_notes fixture (Case 1)', () => { it('should handle the long text with add_notes fixture (Case 1)', () => {
const result = extractXMLFromString(longTextWithAddNotes) const result = extractXMLFromString(longTextWithAddNotes);
expect(result).toHaveLength(2) // Contains thinking tag and add_notes block expect(result).toHaveLength(2); // Contains thinking tag and add_notes block
// Find the add_notes block // Find the add_notes block
const addNotesBlock = result.find(block => block.includes('<add_notes>')) const addNotesBlock = result.find(block => block.includes('<add_notes>'));
expect(addNotesBlock).toBeDefined() expect(addNotesBlock).toBeDefined();
expect(addNotesBlock).toContain('<notes>') expect(addNotesBlock).toContain('<notes>');
expect(addNotesBlock).toContain('<note>') expect(addNotesBlock).toContain('<note>');
expect(addNotesBlock).toContain('<pitch>C4</pitch>') expect(addNotesBlock).toContain('<pitch>C4</pitch>');
expect(addNotesBlock).toContain('<start_beat>0</start_beat>') expect(addNotesBlock).toContain('<start_beat>0</start_beat>');
expect(addNotesBlock).toContain('<length>4</length>') expect(addNotesBlock).toContain('<length>4</length>');
expect(addNotesBlock).toContain('</add_notes>') expect(addNotesBlock).toContain('</add_notes>');
// Should contain all 12 notes // Should contain all 12 notes
const noteMatches = addNotesBlock!.match(/<note>/g) const noteMatches = addNotesBlock!.match(/<note>/g);
expect(noteMatches).toHaveLength(12) expect(noteMatches).toHaveLength(12);
}) });
it('should handle the long text with read_music fixture (Case 2)', () => { it('should handle the long text with read_music fixture (Case 2)', () => {
const result = extractXMLFromString(longTextWithReadMusic) const result = extractXMLFromString(longTextWithReadMusic);
expect(result).toHaveLength(2) expect(result).toHaveLength(2);
// First XML block (inside thinking tag) // First XML block (inside thinking tag)
expect(result[0]).toContain('<read_music>') expect(result[0]).toContain('<read_music>');
expect(result[0]).toContain('<start_beat>0</start_beat>') expect(result[0]).toContain('<start_beat>0</start_beat>');
expect(result[0]).toContain('<length>32</length>') expect(result[0]).toContain('<length>32</length>');
expect(result[0]).toContain('</read_music>') expect(result[0]).toContain('</read_music>');
// Second XML block (at the end) // Second XML block (at the end)
expect(result[1]).toContain('<read_music>') expect(result[1]).toContain('<read_music>');
expect(result[1]).toContain('<start_beat>0</start_beat>') expect(result[1]).toContain('<start_beat>0</start_beat>');
expect(result[1]).toContain('<length>32</length>') expect(result[1]).toContain('<length>32</length>');
expect(result[1]).toContain('</read_music>') expect(result[1]).toContain('</read_music>');
}) });
it('should handle malformed XML gracefully', () => { it('should handle malformed XML gracefully', () => {
const result = extractXMLFromString(malformedXml) const result = extractXMLFromString(malformedXml);
// Should extract valid XML blocks (ignores malformed ones) // Should extract valid XML blocks (ignores malformed ones)
expect(result.length).toBeGreaterThanOrEqual(1) expect(result.length).toBeGreaterThanOrEqual(1);
// Find the definitely valid block // Find the definitely valid block
const validBlock = result.find(block => block.includes('<valid_tag>')) const validBlock = result.find(block => block.includes('<valid_tag>'));
expect(validBlock).toBeDefined() expect(validBlock).toBeDefined();
expect(validBlock).toContain('<content>This is valid</content>') expect(validBlock).toContain('<content>This is valid</content>');
}) });
it('should return empty array for content with no XML', () => { it('should return empty array for content with no XML', () => {
const result = extractXMLFromString(noXmlContent) const result = extractXMLFromString(noXmlContent);
expect(result).toHaveLength(0) expect(result).toHaveLength(0);
expect(result).toEqual([]) expect(result).toEqual([]);
}) });
it('should handle empty and whitespace XML', () => { it('should handle empty and whitespace XML', () => {
const result = extractXMLFromString(emptyAndWhitespaceXml) const result = extractXMLFromString(emptyAndWhitespaceXml);
expect(result).toHaveLength(3) expect(result).toHaveLength(3);
expect(result[0]).toBe('<empty_tag></empty_tag>') expect(result[0]).toBe('<empty_tag></empty_tag>');
expect(result[1]).toContain('<whitespace_tag>') expect(result[1]).toContain('<whitespace_tag>');
expect(result[1]).toContain('</whitespace_tag>') expect(result[1]).toContain('</whitespace_tag>');
expect(result[2]).toContain('<mixed_content>') expect(result[2]).toContain('<mixed_content>');
expect(result[2]).toContain('Some text with spaces') expect(result[2]).toContain('Some text with spaces');
expect(result[2]).toContain('</mixed_content>') expect(result[2]).toContain('</mixed_content>');
}) });
it('should handle XML with underscores and hyphens in tag names', () => { it('should handle XML with underscores and hyphens in tag names', () => {
const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>` const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(2) expect(result).toHaveLength(2);
expect(result[0]).toBe('<tag_name>content</tag_name>') expect(result[0]).toBe('<tag_name>content</tag_name>');
expect(result[1]).toBe('<tag-name>content</tag-name>') expect(result[1]).toBe('<tag-name>content</tag-name>');
}) });
it('should handle self-closing tags (not currently supported)', () => { it('should handle self-closing tags (not currently supported)', () => {
const input = `<self-closing /> and <normal>content</normal>` const input = `<self-closing /> and <normal>content</normal>`;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
// Current implementation doesn't support self-closing tags // Current implementation doesn't support self-closing tags
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toBe('<normal>content</normal>') expect(result[0]).toBe('<normal>content</normal>');
}) });
it('should trim whitespace around extracted XML', () => { it('should trim whitespace around extracted XML', () => {
const input = ` <tag>content</tag> ` const input = ` <tag>content</tag> `;
const result = extractXMLFromString(input) const result = extractXMLFromString(input);
expect(result).toHaveLength(1) expect(result).toHaveLength(1);
expect(result[0]).toBe('<tag>content</tag>') expect(result[0]).toBe('<tag>content</tag>');
}) });
}) });
describe('wrapXmlBlocksInContent', () => { describe('wrapXmlBlocksInContent', () => {
it('should wrap single XML block in fenced code block', () => { it('should wrap single XML block in fenced code block', () => {
const input = `Here is <test>content</test> in text.` const input = `Here is <test>content</test> in text.`;
const result = wrapXmlBlocksInContent(input) const result = wrapXmlBlocksInContent(input);
expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.') expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.');
}) });
it('should wrap multiple XML blocks', () => { it('should wrap multiple XML blocks', () => {
const input = `<first>content1</first> text <second>content2</second>` const input = `<first>content1</first> text <second>content2</second>`;
const result = wrapXmlBlocksInContent(input) const result = wrapXmlBlocksInContent(input);
expect(result).toContain('```xml\n<first>content1</first>\n```') expect(result).toContain('```xml\n<first>content1</first>\n```');
expect(result).toContain('```xml\n<second>content2</second>\n```') expect(result).toContain('```xml\n<second>content2</second>\n```');
}) });
it('should return original content when no XML blocks present', () => { it('should return original content when no XML blocks present', () => {
const input = noXmlContent const input = noXmlContent;
const result = wrapXmlBlocksInContent(input) const result = wrapXmlBlocksInContent(input);
expect(result).toBe(input) expect(result).toBe(input);
}) });
it('should handle empty input', () => { it('should handle empty input', () => {
expect(wrapXmlBlocksInContent('')).toBe('') expect(wrapXmlBlocksInContent('')).toBe('');
expect(wrapXmlBlocksInContent(null as any)).toBeNull() expect(wrapXmlBlocksInContent(null as unknown as string)).toBeNull();
expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined() expect(wrapXmlBlocksInContent(undefined as unknown as string)).toBeUndefined();
}) });
it('should wrap XML blocks from multipleXmlBlocks fixture', () => { it('should wrap XML blocks from multipleXmlBlocks fixture', () => {
const result = wrapXmlBlocksInContent(multipleXmlBlocks) const result = wrapXmlBlocksInContent(multipleXmlBlocks);
expect(result).toContain('```xml\n<add_notes>') expect(result).toContain('```xml\n<add_notes>');
expect(result).toContain('</add_notes>\n```') expect(result).toContain('</add_notes>\n```');
expect(result).toContain('```xml\n<read_music>') expect(result).toContain('```xml\n<read_music>');
expect(result).toContain('</read_music>\n```') expect(result).toContain('</read_music>\n```');
expect(result).toContain('```xml\n<modify_tempo>') expect(result).toContain('```xml\n<modify_tempo>');
expect(result).toContain('</modify_tempo>\n```') expect(result).toContain('</modify_tempo>\n```');
// Should preserve the surrounding text // Should preserve the surrounding text
expect(result).toContain('Here\'s how to add multiple musical elements:') expect(result).toContain('Here\'s how to add multiple musical elements:');
expect(result).toContain('First, let\'s add some notes:') expect(result).toContain('First, let\'s add some notes:');
expect(result).toContain('Then we can read the current music:') expect(result).toContain('Then we can read the current music:');
}) });
it('should wrap the long add_notes XML block (Case 1)', () => { it('should wrap the long add_notes XML block (Case 1)', () => {
const result = wrapXmlBlocksInContent(longTextWithAddNotes) const result = wrapXmlBlocksInContent(longTextWithAddNotes);
expect(result).toContain('```xml\n<add_notes>') expect(result).toContain('```xml\n<add_notes>');
expect(result).toContain('</add_notes>\n```') expect(result).toContain('</add_notes>\n```');
// Should preserve the thinking content and other text // Should preserve the thinking content and other text
expect(result).toContain('<thinking>') expect(result).toContain('<thinking>');
expect(result).toContain('Perfect! I can see this is a beautiful') expect(result).toContain('Perfect! I can see this is a beautiful');
expect(result).toContain('Let me start by adding the harmony') expect(result).toContain('Let me start by adding the harmony');
}) });
it('should wrap the long read_music XML blocks (Case 2)', () => { it('should wrap the long read_music XML blocks (Case 2)', () => {
const result = wrapXmlBlocksInContent(longTextWithReadMusic) const result = wrapXmlBlocksInContent(longTextWithReadMusic);
// Should contain two wrapped XML blocks // Should contain two wrapped XML blocks
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g) const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g);
expect(fencedBlocks).toHaveLength(2) expect(fencedBlocks).toHaveLength(2);
// Should preserve the thinking content and other text // Should preserve the thinking content and other text
expect(result).toContain('<thinking>') expect(result).toContain('<thinking>');
expect(result).toContain('I\'ll help you create a pad harmony track') expect(result).toContain('I\'ll help you create a pad harmony track');
expect(result).toContain('Let me first read the existing music') expect(result).toContain('Let me first read the existing music');
}) });
it('should handle nested XML correctly', () => { it('should handle nested XML correctly', () => {
const result = wrapXmlBlocksInContent(nestedXmlContent) const result = wrapXmlBlocksInContent(nestedXmlContent);
expect(result).toContain('```xml\n<container>') expect(result).toContain('```xml\n<container>');
expect(result).toContain('<inner_element>') expect(result).toContain('<inner_element>');
expect(result).toContain('<deep_nested>') expect(result).toContain('<deep_nested>');
expect(result).toContain('<value>Test Content</value>') expect(result).toContain('<value>Test Content</value>');
expect(result).toContain('</container>\n```') expect(result).toContain('</container>\n```');
}) });
it('should preserve XML block integrity when wrapping', () => { it('should preserve XML block integrity when wrapping', () => {
const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after` const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`;
const result = wrapXmlBlocksInContent(input) const result = wrapXmlBlocksInContent(input);
expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`) expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`);
}) });
it('should handle duplicate XML blocks correctly', () => { it('should handle duplicate XML blocks correctly', () => {
const input = `<same>content</same> and then <same>content</same> again` const input = `<same>content</same> and then <same>content</same> again`;
const result = wrapXmlBlocksInContent(input) const result = wrapXmlBlocksInContent(input);
// Both instances should be wrapped // Both instances should be wrapped
const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g) const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g);
expect(wrappedBlocks).toHaveLength(2) expect(wrappedBlocks).toHaveLength(2);
}) });
}) });
describe('edge cases and error handling', () => { describe('edge cases and error handling', () => {
it('should handle very large XML blocks', () => { it('should handle very large XML blocks', () => {
const largeContent = 'x'.repeat(10000) const largeContent = 'x'.repeat(10000);
const input = `<large>${largeContent}</large>` const input = `<large>${largeContent}</large>`;
const extracted = extractXMLFromString(input) const extracted = extractXMLFromString(input);
expect(extracted).toHaveLength(1) expect(extracted).toHaveLength(1);
expect(extracted[0]).toContain(largeContent) expect(extracted[0]).toContain(largeContent);
const wrapped = wrapXmlBlocksInContent(input) const wrapped = wrapXmlBlocksInContent(input);
expect(wrapped).toContain('```xml\n<large>') expect(wrapped).toContain('```xml\n<large>');
expect(wrapped).toContain('</large>\n```') expect(wrapped).toContain('</large>\n```');
}) });
it('should handle XML with special characters', () => { it('should handle XML with special characters', () => {
const input = `<tag>Content with &lt; &gt; &amp; &quot; &apos;</tag>` const input = `<tag>Content with &lt; &gt; &amp; &quot; &apos;</tag>`;
const extracted = extractXMLFromString(input) const extracted = extractXMLFromString(input);
expect(extracted).toHaveLength(1) expect(extracted).toHaveLength(1);
expect(extracted[0]).toContain('&lt; &gt; &amp; &quot; &apos;') expect(extracted[0]).toContain('&lt; &gt; &amp; &quot; &apos;');
const wrapped = wrapXmlBlocksInContent(input) const wrapped = wrapXmlBlocksInContent(input);
expect(wrapped).toContain('```xml\n<tag>Content with &lt; &gt; &amp; &quot; &apos;</tag>\n```') expect(wrapped).toContain('```xml\n<tag>Content with &lt; &gt; &amp; &quot; &apos;</tag>\n```');
}) });
it('should handle XML with CDATA sections', () => { it('should handle XML with CDATA sections', () => {
const input = `<tag><![CDATA[Some content with <special> chars]]></tag>` const input = `<tag><![CDATA[Some content with <special> chars]]></tag>`;
const extracted = extractXMLFromString(input) const extracted = extractXMLFromString(input);
expect(extracted).toHaveLength(1) expect(extracted).toHaveLength(1);
expect(extracted[0]).toContain('<![CDATA[') expect(extracted[0]).toContain('<![CDATA[');
expect(extracted[0]).toContain(']]>') expect(extracted[0]).toContain(']]>');
}) });
it('should handle mixed content with partial XML-like text', () => { it('should handle mixed content with partial XML-like text', () => {
const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid` const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid`;
const extracted = extractXMLFromString(input) const extracted = extractXMLFromString(input);
expect(extracted).toHaveLength(1) expect(extracted).toHaveLength(1);
expect(extracted[0]).toBe('<valid>content</valid>') expect(extracted[0]).toBe('<valid>content</valid>');
}) });
}) });
}) });
+1 -1
View File
@@ -1,3 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string declare const __APP_VERSION__: string;