Merge pull request #27 from KGAudioLab/feat/2026-01-22-looping
Feat/2026 01 22 looping
This commit is contained in:
@@ -10,6 +10,7 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
|
|||||||
|
|
||||||
## Latest Updates
|
## Latest Updates
|
||||||
|
|
||||||
|
- **2026.01.23**: Implemented seamless loop playback! Drag on the bar numbers to set loop range, or toggle loop mode with the Loop button in the toolbar. Loop playback uses `Tone.js`'s native looping for sample-accurate, gap-free looping.
|
||||||
- **2025.12.21**: Implemented MIDI keyboard support! You can now connect a MIDI keyboard and use it to play sounds. Please note that this feature may not work optimally in Safari and some other browsers that lack complete Web MIDI interface support.
|
- **2025.12.21**: Implemented MIDI keyboard support! You can now connect a MIDI keyboard and use it to play sounds. Please note that this feature may not work optimally in Safari and some other browsers that lack complete Web MIDI interface support.
|
||||||
- **2025.12.15**: Added Intelligent Chord Assistant with functional harmony guidance (T/S/D). Hover over piano keys to see context-aware chord suggestions and create full chords with one click!
|
- **2025.12.15**: Added Intelligent Chord Assistant with functional harmony guidance (T/S/D). Hover over piano keys to see context-aware chord suggestions and create full chords with one click!
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Great to see you again! Your LLM provider appears to be configured. You can star
|
|||||||
|
|
||||||
Tips:
|
Tips:
|
||||||
- Use `/clear` anytime to reset the chat.
|
- Use `/clear` anytime to reset the chat.
|
||||||
- Ask me to create tracks, regions, or MIDI notes, and I'll help orchestrate tool actions.
|
|
||||||
- Type `/welcome` to view this message again.
|
- Type `/welcome` to view this message again.
|
||||||
- Type `/help` to view the help message.
|
- Type `/help` to view the help message.
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"main": {
|
"main": {
|
||||||
"hold_to_create_region": "ctrl",
|
"hold_to_create_region": "ctrl",
|
||||||
"play": "space",
|
"play": "space",
|
||||||
|
"loop": "c",
|
||||||
"undo": "ctrl+z",
|
"undo": "ctrl+z",
|
||||||
"redo": "ctrl+shift+z",
|
"redo": "ctrl+shift+z",
|
||||||
"select_all": "ctrl+a",
|
"select_all": "ctrl+a",
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ body {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar-number-cell.looped {
|
||||||
|
background-color: #e1ae01;
|
||||||
|
color: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
.main-content-body {
|
.main-content-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: fit-content;
|
min-height: fit-content;
|
||||||
|
|||||||
+142
-54
@@ -8,9 +8,10 @@ import TrackInfoPanel from './track/TrackInfoPanel';
|
|||||||
import TrackGridPanel from './track/TrackGridPanel';
|
import TrackGridPanel from './track/TrackGridPanel';
|
||||||
import PianoRoll from './piano-roll/PianoRoll';
|
import PianoRoll from './piano-roll/PianoRoll';
|
||||||
import type { RegionUI } from './interfaces';
|
import type { RegionUI } from './interfaces';
|
||||||
import { DEBUG_MODE } from '../constants';
|
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants';
|
||||||
import { useRegionOperations } from '../hooks/useRegionOperations';
|
import { useRegionOperations } from '../hooks/useRegionOperations';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
|
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
onTrackClick?: () => void;
|
onTrackClick?: () => void;
|
||||||
@@ -73,9 +74,12 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Refs to track pending updates for verification
|
// Refs to track pending updates for verification
|
||||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||||
|
|
||||||
// Refs for bar numbers drag functionality
|
// Refs for bar numbers and loop range drag functionality
|
||||||
const isDraggingRef = useRef(false);
|
|
||||||
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const isLoopDraggingRef = useRef(false);
|
||||||
|
const loopDragStartBarRef = useRef<number | null>(null);
|
||||||
|
const loopDragStartXRef = useRef<number | null>(null);
|
||||||
|
const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null);
|
||||||
|
|
||||||
// Effect to verify track updates
|
// Effect to verify track updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -444,8 +448,6 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setActiveRegionId(null);
|
setActiveRegionId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add keyboard event listener for region deletion
|
* Add keyboard event listener for region deletion
|
||||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||||
@@ -515,76 +517,150 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return destinationBeatPosition;
|
return destinationBeatPosition;
|
||||||
}, [timeSignature]);
|
}, [timeSignature]);
|
||||||
|
|
||||||
// Handle mouse down to start dragging
|
// Utility function to calculate bar index from mouse coordinates (for loop range selection)
|
||||||
|
const calculateBarIndexFromMouse = useCallback((clientX: number): number | null => {
|
||||||
|
if (!barNumbersRef.current) return null;
|
||||||
|
|
||||||
|
const rect = barNumbersRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
|
||||||
|
// Calculate the width of each bar
|
||||||
|
const barWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||||
|
) || 40;
|
||||||
|
|
||||||
|
// Calculate bar index (using Math.floor for exact bar boundaries)
|
||||||
|
const barIndex = Math.floor(relativeX / barWidth);
|
||||||
|
|
||||||
|
// Clamp to valid range [0, maxBars - 1]
|
||||||
|
return Math.max(0, Math.min(barIndex, maxBars - 1));
|
||||||
|
}, [maxBars]);
|
||||||
|
|
||||||
|
// Handle mouse down to start dragging (for loop range selection)
|
||||||
const handleBarNumbersMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleBarNumbersMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Only handle left mouse button
|
// Only handle left mouse button
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
isDraggingRef.current = true;
|
// Calculate starting bar index
|
||||||
|
const startBarIndex = calculateBarIndexFromMouse(e.clientX);
|
||||||
|
if (startBarIndex === null) return;
|
||||||
|
|
||||||
// Calculate and set initial playhead position
|
// Always start loop drag tracking
|
||||||
const newPosition = calculatePlayheadFromMouse(e.clientX);
|
isLoopDraggingRef.current = true;
|
||||||
if (newPosition !== null) {
|
loopDragStartBarRef.current = startBarIndex;
|
||||||
setPlayheadPosition(newPosition);
|
loopDragStartXRef.current = e.clientX;
|
||||||
|
|
||||||
|
// Capture original loop settings for undo/redo
|
||||||
|
loopDragOriginalSettingsRef.current = {
|
||||||
|
isLooping,
|
||||||
|
loopingRange: [...loopingRange] as [number, number]
|
||||||
|
};
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Bar numbers drag started - Initial position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`);
|
console.log(`Bar numbers mouse down - Start bar: ${startBarIndex} (displayed as bar ${startBarIndex + 1})`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent text selection during drag
|
// Prevent text selection during drag
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle click on bar numbers to move playhead (when not dragging)
|
// Global mouse move and mouse up handlers for loop range drag functionality
|
||||||
const handleBarNumbersClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
// If we were dragging, don't process as a click
|
|
||||||
if (isDraggingRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPosition = calculatePlayheadFromMouse(e.clientX);
|
|
||||||
if (newPosition !== null) {
|
|
||||||
const core = KGCore.instance();
|
|
||||||
const currentPlayheadPosition = core.getPlayheadPosition();
|
|
||||||
const beatsPerBar = timeSignature.numerator;
|
|
||||||
const currentBarNumber = Math.floor(currentPlayheadPosition / beatsPerBar) + 1; // 1-indexed
|
|
||||||
const destinationBarNumber = Math.floor(newPosition / beatsPerBar) + 1; // 1-indexed
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
|
||||||
console.log(`Bar numbers click - Position: ${newPosition}`);
|
|
||||||
console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`);
|
|
||||||
console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlayheadPosition(newPosition);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global mouse move and mouse up handlers for bar numbers drag functionality
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isDraggingRef.current) return;
|
if (!isLoopDraggingRef.current) return;
|
||||||
|
if (loopDragStartBarRef.current === null || loopDragStartXRef.current === null) return;
|
||||||
|
|
||||||
const newPosition = calculatePlayheadFromMouse(e.clientX);
|
// Calculate distance moved
|
||||||
if (newPosition !== null) {
|
const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current);
|
||||||
setPlayheadPosition(newPosition);
|
|
||||||
|
// Only update if moved beyond threshold
|
||||||
|
if (distanceMoved < BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) return;
|
||||||
|
|
||||||
|
// Calculate current bar index
|
||||||
|
const currentBarIndex = calculateBarIndexFromMouse(e.clientX);
|
||||||
|
if (currentBarIndex === null) return;
|
||||||
|
|
||||||
|
// Create loop range [min, max] regardless of drag direction
|
||||||
|
const startBar = loopDragStartBarRef.current;
|
||||||
|
const loopStart = Math.min(startBar, currentBarIndex);
|
||||||
|
const loopEnd = Math.max(startBar, currentBarIndex);
|
||||||
|
const newLoopRange: [number, number] = [loopStart, loopEnd];
|
||||||
|
|
||||||
|
// Update project model
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const project = core.getCurrentProject();
|
||||||
|
project.setLoopingRange(newLoopRange);
|
||||||
|
project.setIsLooping(true); // Enable looping immediately during drag for real-time visual feedback
|
||||||
|
|
||||||
|
// Update store to trigger UI re-render
|
||||||
|
useProjectStore.setState({ loopingRange: newLoopRange, isLooping: true });
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Bar numbers drag - Position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`);
|
console.log(`Loop range drag - Range: [${loopStart}, ${loopEnd}] (bars ${loopStart + 1}-${loopEnd + 1})`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
if (isDraggingRef.current) {
|
if (isLoopDraggingRef.current) {
|
||||||
isDraggingRef.current = false;
|
if (loopDragStartXRef.current !== null) {
|
||||||
|
const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current);
|
||||||
|
|
||||||
|
// If dragged beyond threshold, execute command for undo/redo support
|
||||||
|
if (distanceMoved >= BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const currentIsLooping = core.getCurrentProject().getIsLooping();
|
||||||
|
const currentLoopingRange = core.getCurrentProject().getLoopingRange();
|
||||||
|
|
||||||
|
// Only execute command if settings actually changed from original
|
||||||
|
if (loopDragOriginalSettingsRef.current) {
|
||||||
|
const originalSettings = loopDragOriginalSettingsRef.current;
|
||||||
|
const settingsChanged =
|
||||||
|
originalSettings.isLooping !== currentIsLooping ||
|
||||||
|
originalSettings.loopingRange[0] !== currentLoopingRange[0] ||
|
||||||
|
originalSettings.loopingRange[1] !== currentLoopingRange[1];
|
||||||
|
|
||||||
|
if (settingsChanged) {
|
||||||
|
// Stop playback if currently playing (get fresh state from store)
|
||||||
|
const { isPlaying: currentIsPlaying, stopPlaying: currentStopPlaying } = useProjectStore.getState();
|
||||||
|
if (currentIsPlaying) {
|
||||||
|
currentStopPlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert to original state first (since we updated in real-time)
|
||||||
|
core.getCurrentProject().setIsLooping(originalSettings.isLooping);
|
||||||
|
core.getCurrentProject().setLoopingRange(originalSettings.loopingRange);
|
||||||
|
|
||||||
|
// Now execute command to apply new settings with undo support
|
||||||
|
const command = new ChangeLoopSettingsCommand({
|
||||||
|
isLooping: currentIsLooping,
|
||||||
|
loopingRange: currentLoopingRange
|
||||||
|
});
|
||||||
|
core.executeCommand(command);
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log('Bar numbers drag ended');
|
console.log('Loop range drag ended - Command executed for undo/redo');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single click (moved < threshold) - set playhead position
|
||||||
|
const clickPosition = calculatePlayheadFromMouse(e.clientX);
|
||||||
|
if (clickPosition !== null) {
|
||||||
|
setPlayheadPosition(clickPosition);
|
||||||
|
|
||||||
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drag state
|
||||||
|
isLoopDraggingRef.current = false;
|
||||||
|
loopDragStartBarRef.current = null;
|
||||||
|
loopDragStartXRef.current = null;
|
||||||
|
loopDragOriginalSettingsRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add global event listeners for drag functionality
|
// Add global event listeners for drag functionality
|
||||||
@@ -596,9 +672,17 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [calculatePlayheadFromMouse, setPlayheadPosition, timeSignature]);
|
}, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition]);
|
||||||
|
|
||||||
const { showInstrumentSelection } = useProjectStore();
|
const { showInstrumentSelection, isLooping, loopingRange } = useProjectStore();
|
||||||
|
|
||||||
|
// Helper function to check if a bar (0-indexed) is in the loop range
|
||||||
|
const isBarInLoopRange = (barIndex: number): boolean => {
|
||||||
|
if (!isLooping) return false;
|
||||||
|
// Loop range is [startBar, endBar] (0-indexed)
|
||||||
|
// We want to highlight bars from startBar to endBar inclusive
|
||||||
|
return barIndex >= loopingRange[0] && barIndex <= loopingRange[1];
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
|
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
|
||||||
@@ -611,10 +695,14 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
className="bar-numbers"
|
className="bar-numbers"
|
||||||
ref={barNumbersRef}
|
ref={barNumbersRef}
|
||||||
onMouseDown={handleBarNumbersMouseDown}
|
onMouseDown={handleBarNumbersMouseDown}
|
||||||
onClick={handleBarNumbersClick}
|
|
||||||
>
|
>
|
||||||
{Array.from({ length: maxBars }, (_, i) => (
|
{Array.from({ length: maxBars }, (_, i) => (
|
||||||
<div key={i} className="bar-number-cell">{i + 1}</div>
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`bar-number-cell${isBarInLoopRange(i) ? ' looped' : ''}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { TIME_CONSTANTS } from '../constants/coreConstants';
|
|||||||
import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil';
|
import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil';
|
||||||
import {
|
import {
|
||||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||||
FaPlay, FaPause, FaComments,
|
FaPlay, FaPause, FaComments, FaSync,
|
||||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||||
FaCog
|
FaCog
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
@@ -33,6 +33,7 @@ const Toolbar: React.FC = () => {
|
|||||||
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
|
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
|
||||||
currentTime, setBpm, setTimeSignature, setKeySignature,
|
currentTime, setBpm, setTimeSignature, setKeySignature,
|
||||||
maxBars, setMaxBars,
|
maxBars, setMaxBars,
|
||||||
|
isLooping, toggleLoop,
|
||||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||||
toggleChatBox, toggleSettings, cleanupProjectState,
|
toggleChatBox, toggleSettings, cleanupProjectState,
|
||||||
// Piano roll state/actions
|
// Piano roll state/actions
|
||||||
@@ -245,7 +246,7 @@ const Toolbar: React.FC = () => {
|
|||||||
const midiData = convertProjectToMidi(currentProject);
|
const midiData = convertProjectToMidi(currentProject);
|
||||||
|
|
||||||
// Create a downloadable blob
|
// Create a downloadable blob
|
||||||
const blob = new Blob([midiData], { type: 'audio/midi' });
|
const blob = new Blob([midiData.buffer as ArrayBuffer], { type: 'audio/midi' });
|
||||||
|
|
||||||
// Create a temporary download link
|
// Create a temporary download link
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -412,6 +413,13 @@ const Toolbar: React.FC = () => {
|
|||||||
setPlayheadPosition(0);
|
setPlayheadPosition(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoopToggle = () => {
|
||||||
|
toggleLoop();
|
||||||
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
|
console.log("Loop toggle clicked");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Prompt to change max bars when clicking on current-time display
|
// Prompt to change max bars when clicking on current-time display
|
||||||
const handleCurrentTimeClick = () => {
|
const handleCurrentTimeClick = () => {
|
||||||
const MIN_BARS = 16;
|
const MIN_BARS = 16;
|
||||||
@@ -740,6 +748,13 @@ 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="Loop"
|
||||||
|
className={`tool-button ${isLooping ? 'active' : ''}`}
|
||||||
|
onClick={handleLoopToggle}
|
||||||
|
>
|
||||||
|
<FaSync />
|
||||||
|
</button>
|
||||||
<div className="toolbar-separator"></div>
|
<div className="toolbar-separator"></div>
|
||||||
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
|
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
|
||||||
{/* <button title="Record"><FaCircle className="record-btn" /></button>
|
{/* <button title="Record"><FaCircle className="record-btn" /></button>
|
||||||
|
|||||||
@@ -51,3 +51,9 @@ export const PLAYING_CONSTANTS = {
|
|||||||
// Update rate for playback (10 FPS for performance evaluation)
|
// Update rate for playback (10 FPS for performance evaluation)
|
||||||
UPDATE_INTERVAL_MS: 100, // 1000ms / 10fps = 100ms
|
UPDATE_INTERVAL_MS: 100, // 1000ms / 10fps = 100ms
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bar numbers related constants
|
||||||
|
export const BAR_NUMBERS_CONSTANTS = {
|
||||||
|
// Minimum drag distance (in pixels) to consider as drag vs click
|
||||||
|
DRAG_THRESHOLD: 3,
|
||||||
|
};
|
||||||
|
|||||||
+48
-4
@@ -270,6 +270,26 @@ export class KGCore {
|
|||||||
|
|
||||||
// High-level playback control methods
|
// High-level playback control methods
|
||||||
public async startPlaying(): Promise<void> {
|
public async startPlaying(): Promise<void> {
|
||||||
|
// Handle loop mode initialization
|
||||||
|
if (this.currentProject.getIsLooping()) {
|
||||||
|
const [startBar, endBar] = this.currentProject.getLoopingRange();
|
||||||
|
|
||||||
|
// Handle [0, 0] case - set to full project
|
||||||
|
if (startBar === 0 && endBar === 0) {
|
||||||
|
const maxBars = this.currentProject.getMaxBars();
|
||||||
|
const { ChangeLoopSettingsCommand } = await import('./commands');
|
||||||
|
this.executeCommand(new ChangeLoopSettingsCommand({
|
||||||
|
loopingRange: [0, maxBars]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move playhead to loop start (use updated range if [0,0] was just set)
|
||||||
|
const updatedRange = this.currentProject.getLoopingRange();
|
||||||
|
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
||||||
|
const loopStartBeats = updatedRange[0] * beatsPerBar;
|
||||||
|
this.setPlayheadPosition(loopStartBeats);
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare playback first
|
// Prepare playback first
|
||||||
await this.preparePlay();
|
await this.preparePlay();
|
||||||
|
|
||||||
@@ -336,12 +356,36 @@ export class KGCore {
|
|||||||
|
|
||||||
const bpm = this.currentProject.getBpm();
|
const bpm = this.currentProject.getBpm();
|
||||||
const beatsPerMs = bpm / (60 * 1000);
|
const beatsPerMs = bpm / (60 * 1000);
|
||||||
const newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
|
let newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
|
||||||
|
|
||||||
// Stop playback at the end of project (maxBars)
|
// Handle looping or end-of-project
|
||||||
const maxBars = this.currentProject.getMaxBars();
|
|
||||||
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
||||||
|
|
||||||
|
if (this.currentProject.getIsLooping()) {
|
||||||
|
// Loop mode: wrap playhead when it reaches loop end
|
||||||
|
const [startBar, endBarOriginal] = this.currentProject.getLoopingRange();
|
||||||
|
const endBar = (startBar === 0 && endBarOriginal === 0) ? this.currentProject.getMaxBars() : endBarOriginal;
|
||||||
|
|
||||||
|
const loopStartBeats = startBar * beatsPerBar;
|
||||||
|
const loopEndBeats = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
||||||
|
const loopLengthBeats = loopEndBeats - loopStartBeats;
|
||||||
|
|
||||||
|
// Wrap playhead position within loop range
|
||||||
|
if (newPosition >= loopEndBeats) {
|
||||||
|
// Calculate how far we've overshot and wrap back
|
||||||
|
const overshot = newPosition - loopEndBeats;
|
||||||
|
newPosition = loopStartBeats + (overshot % loopLengthBeats);
|
||||||
|
|
||||||
|
// Reset timing reference to prevent drift accumulation
|
||||||
|
const newElapsedBeats = newPosition - loopStartBeats;
|
||||||
|
this.playbackStartTime = performance.now() - (newElapsedBeats / beatsPerMs) - playbackDelayMs;
|
||||||
|
this.playbackStartPosition = loopStartBeats;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-looping mode: stop at project end
|
||||||
|
const maxBars = this.currentProject.getMaxBars();
|
||||||
const maxBeats = maxBars * beatsPerBar;
|
const maxBeats = maxBars * beatsPerBar;
|
||||||
|
|
||||||
if (newPosition >= maxBeats) {
|
if (newPosition >= maxBeats) {
|
||||||
// Clamp to max and stop
|
// Clamp to max and stop
|
||||||
this.setPlayheadPosition(maxBeats);
|
this.setPlayheadPosition(maxBeats);
|
||||||
@@ -349,6 +393,7 @@ export class KGCore {
|
|||||||
this.stopPlaying();
|
this.stopPlaying();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update playhead position
|
// Update playhead position
|
||||||
this.setPlayheadPosition(newPosition);
|
this.setPlayheadPosition(newPosition);
|
||||||
@@ -357,7 +402,6 @@ export class KGCore {
|
|||||||
// - Sync with Tone.Transport position for more accurate timing
|
// - Sync with Tone.Transport position for more accurate timing
|
||||||
// - Handle tempo changes mid-playback
|
// - Handle tempo changes mid-playback
|
||||||
// - Account for latency compensation
|
// - Account for latency compensation
|
||||||
// - Support for loop regions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// selected items
|
// selected items
|
||||||
|
|||||||
+28
-2
@@ -35,11 +35,19 @@ export class KGProject {
|
|||||||
@WithDefault("ionian")
|
@WithDefault("ionian")
|
||||||
private selectedMode: string = "ionian";
|
private selectedMode: string = "ionian";
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(false)
|
||||||
|
private isLooping: boolean = false;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault([0, 0])
|
||||||
|
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 2;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 3;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
@@ -54,7 +62,7 @@ export class KGProject {
|
|||||||
private tracks: KGTrack[] = [];
|
private tracks: KGTrack[] = [];
|
||||||
|
|
||||||
// Constructor
|
// Constructor
|
||||||
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
|
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -62,6 +70,8 @@ export class KGProject {
|
|||||||
this.timeSignature = timeSignature;
|
this.timeSignature = timeSignature;
|
||||||
this.keySignature = keySignature;
|
this.keySignature = keySignature;
|
||||||
this.selectedMode = selectedMode;
|
this.selectedMode = selectedMode;
|
||||||
|
this.isLooping = isLooping;
|
||||||
|
this.loopingRange = loopingRange;
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
}
|
}
|
||||||
@@ -139,5 +149,21 @@ export class KGProject {
|
|||||||
public getProjectStructureVersion(): number {
|
public getProjectStructureVersion(): number {
|
||||||
return this.projectStructureVersion;
|
return this.projectStructureVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getIsLooping(): boolean {
|
||||||
|
return this.isLooping;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setIsLooping(isLooping: boolean): void {
|
||||||
|
this.isLooping = isLooping;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLoopingRange(): [number, number] {
|
||||||
|
return this.loopingRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLoopingRange(loopingRange: [number, number]): void {
|
||||||
|
this.loopingRange = loopingRange;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,38 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
||||||
|
|
||||||
|
// Configure loop settings
|
||||||
|
const isLooping = project.getIsLooping();
|
||||||
|
let scheduleStartBeat = 0;
|
||||||
|
let scheduleEndBeat = Infinity;
|
||||||
|
|
||||||
|
if (isLooping) {
|
||||||
|
const [startBar, endBarOriginal] = project.getLoopingRange();
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
|
||||||
|
// Handle [0, 0] case - use full project
|
||||||
|
const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal;
|
||||||
|
|
||||||
|
scheduleStartBeat = startBar * beatsPerBar;
|
||||||
|
scheduleEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
||||||
|
|
||||||
|
// Configure Tone.Transport loop boundaries
|
||||||
|
const loopStartTime = this.beatsToToneTime(scheduleStartBeat);
|
||||||
|
const loopEndTime = this.beatsToToneTime(scheduleEndBeat);
|
||||||
|
Tone.Transport.setLoopPoints(loopStartTime, loopEndTime);
|
||||||
|
Tone.Transport.loop = true;
|
||||||
|
|
||||||
|
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
|
||||||
|
|
||||||
|
// Adjust start position to loop start if before loop range
|
||||||
|
if (startPosition < scheduleStartBeat) {
|
||||||
|
startPosition = scheduleStartBeat;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Tone.Transport.loop = false;
|
||||||
|
console.log("Loop mode disabled");
|
||||||
|
}
|
||||||
|
|
||||||
// Set transport position (convert beats to Tone.js format)
|
// Set transport position (convert beats to Tone.js format)
|
||||||
this.setTransportPosition(startPosition);
|
this.setTransportPosition(startPosition);
|
||||||
|
|
||||||
@@ -290,8 +322,14 @@ export class KGAudioInterface {
|
|||||||
// Calculate absolute note timing in beats (note position + region start position)
|
// Calculate absolute note timing in beats (note position + region start position)
|
||||||
const regionStartBeat = region.getStartFromBeat();
|
const regionStartBeat = region.getStartFromBeat();
|
||||||
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
const noteStartBeat = note.getStartBeat() + regionStartBeat;
|
||||||
|
const noteEndBeat = note.getEndBeat() + regionStartBeat;
|
||||||
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
|
||||||
|
|
||||||
|
// Skip notes outside loop range when looping
|
||||||
|
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
|
||||||
|
return; // Skip notes outside the loop range
|
||||||
|
}
|
||||||
|
|
||||||
// Only schedule notes that start at or after the playback start position
|
// Only schedule notes that start at or after the playback start position
|
||||||
if (noteStartBeat < startPosition) {
|
if (noteStartBeat < startPosition) {
|
||||||
return; // Skip notes that would have already finished before playback starts
|
return; // Skip notes that would have already finished before playback starts
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ export { PasteNotesCommand } from './note/PasteNotesCommand';
|
|||||||
|
|
||||||
// Project commands
|
// Project commands
|
||||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
||||||
|
export { ChangeLoopSettingsCommand, type LoopSettings } from './project/ChangeLoopSettingsCommand';
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGProject } from '../../KGProject';
|
||||||
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining loop settings that can be updated
|
||||||
|
*/
|
||||||
|
export interface LoopSettings {
|
||||||
|
isLooping?: boolean;
|
||||||
|
loopingRange?: [number, number]; // [startBar, endBar] - bar indices (0-based)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command to update loop settings (isLooping and loopingRange)
|
||||||
|
* Handles updating loop mode and range with undo support
|
||||||
|
*/
|
||||||
|
export class ChangeLoopSettingsCommand extends KGCommand {
|
||||||
|
private newSettings: LoopSettings;
|
||||||
|
private originalSettings: LoopSettings = {};
|
||||||
|
private targetProject: KGProject | null = null;
|
||||||
|
private changedSettings: Set<keyof LoopSettings> = new Set();
|
||||||
|
|
||||||
|
constructor(settings: LoopSettings) {
|
||||||
|
super();
|
||||||
|
this.newSettings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
this.targetProject = core.getCurrentProject();
|
||||||
|
|
||||||
|
// Store original settings for undo
|
||||||
|
this.originalSettings = {
|
||||||
|
isLooping: this.targetProject.getIsLooping(),
|
||||||
|
loopingRange: [...this.targetProject.getLoopingRange()] as [number, number], // Create a copy
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply updates and track what actually changes
|
||||||
|
const updatedSettings: string[] = [];
|
||||||
|
|
||||||
|
// Update isLooping
|
||||||
|
if (this.newSettings.isLooping !== undefined && this.newSettings.isLooping !== this.originalSettings.isLooping) {
|
||||||
|
this.targetProject.setIsLooping(this.newSettings.isLooping);
|
||||||
|
this.changedSettings.add('isLooping');
|
||||||
|
updatedSettings.push(`isLooping: ${this.originalSettings.isLooping} → ${this.newSettings.isLooping}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loopingRange
|
||||||
|
if (this.newSettings.loopingRange !== undefined) {
|
||||||
|
const originalRange = this.originalSettings.loopingRange!;
|
||||||
|
const newRange = this.newSettings.loopingRange;
|
||||||
|
|
||||||
|
// Compare loop ranges
|
||||||
|
if (originalRange[0] !== newRange[0] || originalRange[1] !== newRange[1]) {
|
||||||
|
this.targetProject.setLoopingRange(newRange);
|
||||||
|
this.changedSettings.add('loopingRange');
|
||||||
|
updatedSettings.push(`loopingRange: [${originalRange[0]}, ${originalRange[1]}] → [${newRange[0]}, ${newRange[1]}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the store to trigger UI re-render
|
||||||
|
const storeUpdate: { isLooping?: boolean; loopingRange?: [number, number] } = {};
|
||||||
|
if (this.changedSettings.has('isLooping') && this.newSettings.isLooping !== undefined) {
|
||||||
|
storeUpdate.isLooping = this.newSettings.isLooping;
|
||||||
|
}
|
||||||
|
if (this.changedSettings.has('loopingRange') && this.newSettings.loopingRange !== undefined) {
|
||||||
|
storeUpdate.loopingRange = this.newSettings.loopingRange;
|
||||||
|
}
|
||||||
|
if (Object.keys(storeUpdate).length > 0) {
|
||||||
|
useProjectStore.setState(storeUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedSettings.length > 0) {
|
||||||
|
console.log(`Updated loop settings: ${updatedSettings.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
console.log('No changes applied to loop settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetProject) {
|
||||||
|
throw new Error('Cannot undo: no loop settings were updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only restore settings that were actually changed
|
||||||
|
const restoredSettings: string[] = [];
|
||||||
|
|
||||||
|
// Restore isLooping (only if it was changed)
|
||||||
|
if (this.changedSettings.has('isLooping') && this.originalSettings.isLooping !== undefined) {
|
||||||
|
this.targetProject.setIsLooping(this.originalSettings.isLooping);
|
||||||
|
restoredSettings.push(`isLooping: ${this.originalSettings.isLooping}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore loopingRange (only if it was changed)
|
||||||
|
if (this.changedSettings.has('loopingRange') && this.originalSettings.loopingRange !== undefined) {
|
||||||
|
this.targetProject.setLoopingRange(this.originalSettings.loopingRange);
|
||||||
|
const range = this.originalSettings.loopingRange;
|
||||||
|
restoredSettings.push(`loopingRange: [${range[0]}, ${range[1]}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the store to trigger UI re-render
|
||||||
|
const storeUpdate: { isLooping?: boolean; loopingRange?: [number, number] } = {};
|
||||||
|
if (this.changedSettings.has('isLooping') && this.originalSettings.isLooping !== undefined) {
|
||||||
|
storeUpdate.isLooping = this.originalSettings.isLooping;
|
||||||
|
}
|
||||||
|
if (this.changedSettings.has('loopingRange') && this.originalSettings.loopingRange !== undefined) {
|
||||||
|
storeUpdate.loopingRange = this.originalSettings.loopingRange;
|
||||||
|
}
|
||||||
|
if (Object.keys(storeUpdate).length > 0) {
|
||||||
|
useProjectStore.setState(storeUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Restored loop settings: ${restoredSettings.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const updatedSettings: string[] = [];
|
||||||
|
|
||||||
|
if (this.newSettings.isLooping !== undefined) {
|
||||||
|
updatedSettings.push('loop mode');
|
||||||
|
}
|
||||||
|
if (this.newSettings.loopingRange !== undefined) {
|
||||||
|
updatedSettings.push('loop range');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedSettings.length === 1) {
|
||||||
|
return `Change ${updatedSettings[0]}`;
|
||||||
|
} else if (updatedSettings.length > 1) {
|
||||||
|
return `Change loop settings (${updatedSettings.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Change loop settings`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the new settings being applied
|
||||||
|
*/
|
||||||
|
public getNewSettings(): LoopSettings {
|
||||||
|
return this.newSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original settings (only available after execute)
|
||||||
|
*/
|
||||||
|
public getOriginalSettings(): LoopSettings {
|
||||||
|
return this.originalSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the target project instance (only available after execute)
|
||||||
|
*/
|
||||||
|
public getTargetProject(): KGProject | null {
|
||||||
|
return this.targetProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the settings that were actually changed (only available after execute)
|
||||||
|
*/
|
||||||
|
public getChangedSettings(): Set<keyof LoopSettings> {
|
||||||
|
return new Set(this.changedSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ interface AppConfig {
|
|||||||
main: {
|
main: {
|
||||||
hold_to_create_region: string;
|
hold_to_create_region: string;
|
||||||
play: string;
|
play: string;
|
||||||
|
loop: string;
|
||||||
undo: string;
|
undo: string;
|
||||||
redo: string;
|
redo: string;
|
||||||
select_all: string;
|
select_all: string;
|
||||||
@@ -205,6 +206,7 @@ export class ConfigManager {
|
|||||||
main: {
|
main: {
|
||||||
hold_to_create_region: 'ctrl',
|
hold_to_create_region: 'ctrl',
|
||||||
play: 'space',
|
play: 'space',
|
||||||
|
loop: 'c',
|
||||||
undo: 'ctrl+z',
|
undo: 'ctrl+z',
|
||||||
redo: 'ctrl+shift+z',
|
redo: 'ctrl+shift+z',
|
||||||
select_all: 'ctrl+a',
|
select_all: 'ctrl+a',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { KGProject } from '../KGProject';
|
import { KGProject } from '../KGProject';
|
||||||
import { upgradeToV1 } from './upgradeToV1';
|
import { upgradeToV1 } from './upgradeToV1';
|
||||||
import { upgradeToV2 } from './upgradeToV2';
|
import { upgradeToV2 } from './upgradeToV2';
|
||||||
|
import { upgradeToV3 } from './upgradeToV3';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -28,6 +29,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV2(workingProject);
|
workingProject = upgradeToV2(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 3: {
|
||||||
|
workingProject = upgradeToV3(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,26 @@
|
|||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade a project from structure version 2 to 3.
|
||||||
|
* Adds the isLooping and loopingRange fields with default values.
|
||||||
|
*/
|
||||||
|
export function upgradeToV3(project: KGProject): KGProject {
|
||||||
|
try {
|
||||||
|
// Set default isLooping to false if not already set
|
||||||
|
const currentIsLooping = project.getIsLooping?.();
|
||||||
|
if (currentIsLooping === undefined) {
|
||||||
|
project.setIsLooping(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default loopingRange to [0, 0] if not already set
|
||||||
|
const currentLoopingRange = project.getLoopingRange?.();
|
||||||
|
if (!currentLoopingRange) {
|
||||||
|
project.setLoopingRange([0, 0]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Always set the project structure version to 3 to mark migration complete
|
||||||
|
project.setProjectStructureVersion(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
|
|||||||
* 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, projectName } = useProjectStore();
|
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName } = useProjectStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -59,6 +59,7 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
const pasteShortcut = configManager.get('hotkeys.main.paste') as string;
|
const pasteShortcut = configManager.get('hotkeys.main.paste') as string;
|
||||||
const selectAllShortcut = configManager.get('hotkeys.main.select_all') as string;
|
const selectAllShortcut = configManager.get('hotkeys.main.select_all') as string;
|
||||||
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 saveShortcut = configManager.get('hotkeys.main.save') as string;
|
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
||||||
|
|
||||||
// Check for undo shortcut
|
// Check for undo shortcut
|
||||||
@@ -136,6 +137,19 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for loop toggle shortcut
|
||||||
|
if (loopShortcut && matchesKeyboardShortcut(event, loopShortcut)) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
toggleLoop();
|
||||||
|
setStatus('Loop toggled');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Loop toggle failed:', error);
|
||||||
|
setStatus('Loop toggle failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for save shortcut
|
// Check for save shortcut
|
||||||
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -156,5 +170,5 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
};
|
};
|
||||||
}, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, projectName]); // Include dependencies for store actions
|
}, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName]); // Include dependencies for store actions
|
||||||
};
|
};
|
||||||
@@ -12,6 +12,7 @@ import { KGRegion } from '../core/region/KGRegion';
|
|||||||
import { AddTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand } from '../core/commands';
|
import { AddTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand } from '../core/commands';
|
||||||
import { ConfigManager } from '../core/config/ConfigManager';
|
import { ConfigManager } from '../core/config/ConfigManager';
|
||||||
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
||||||
|
import { toggleLoop } from '../util/loopUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* Update CSS custom property for time signature numerator
|
||||||
@@ -40,6 +41,8 @@ interface ProjectState {
|
|||||||
bpm: number;
|
bpm: number;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
|
isLooping: boolean;
|
||||||
|
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
|
||||||
playheadPosition: number; // in beats
|
playheadPosition: number; // in beats
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: string; // formatted time string
|
currentTime: string; // formatted time string
|
||||||
@@ -84,6 +87,7 @@ interface ProjectState {
|
|||||||
setPlayheadPosition: (position: number) => void;
|
setPlayheadPosition: (position: number) => void;
|
||||||
startPlaying: () => Promise<void>;
|
startPlaying: () => Promise<void>;
|
||||||
stopPlaying: () => Promise<void>;
|
stopPlaying: () => Promise<void>;
|
||||||
|
toggleLoop: () => void;
|
||||||
setBpm: (bpm: number) => void;
|
setBpm: (bpm: number) => void;
|
||||||
setMaxBars: (maxBars: number) => void;
|
setMaxBars: (maxBars: number) => void;
|
||||||
setTimeSignature: (timeSignature: TimeSignature) => void;
|
setTimeSignature: (timeSignature: TimeSignature) => void;
|
||||||
@@ -225,6 +229,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
bpm: currentProject.getBpm(),
|
bpm: currentProject.getBpm(),
|
||||||
keySignature: currentProject.getKeySignature(),
|
keySignature: currentProject.getKeySignature(),
|
||||||
selectedMode: currentProject.getSelectedMode(),
|
selectedMode: currentProject.getSelectedMode(),
|
||||||
|
isLooping: currentProject.getIsLooping(),
|
||||||
|
loopingRange: currentProject.getLoopingRange(),
|
||||||
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
||||||
isPlaying: KGCore.instance().getIsPlaying(),
|
isPlaying: KGCore.instance().getIsPlaying(),
|
||||||
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
|
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
|
||||||
@@ -519,6 +525,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
bpm,
|
bpm,
|
||||||
keySignature,
|
keySignature,
|
||||||
selectedMode: projectToLoad.getSelectedMode(),
|
selectedMode: projectToLoad.getSelectedMode(),
|
||||||
|
isLooping: projectToLoad.getIsLooping(),
|
||||||
|
loopingRange: projectToLoad.getLoopingRange(),
|
||||||
playheadPosition: 0, // Ensure store state is also updated
|
playheadPosition: 0, // Ensure store state is also updated
|
||||||
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
||||||
});
|
});
|
||||||
@@ -564,6 +572,17 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleLoop: () => {
|
||||||
|
const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get();
|
||||||
|
|
||||||
|
// Stop playback if currently playing
|
||||||
|
if (isPlaying) {
|
||||||
|
stopPlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLoop(isLooping, loopingRange, maxBars);
|
||||||
|
},
|
||||||
|
|
||||||
setBpm: (bpm: number) => {
|
setBpm: (bpm: number) => {
|
||||||
try {
|
try {
|
||||||
// Create and execute the change project property command
|
// Create and execute the change project property command
|
||||||
|
|||||||
@@ -121,8 +121,10 @@ export const createMockProject = (overrides: Partial<{
|
|||||||
defaults.timeSignature,
|
defaults.timeSignature,
|
||||||
'C major', // keySignature
|
'C major', // keySignature
|
||||||
'ionian', // selectedMode
|
'ionian', // selectedMode
|
||||||
|
false, // isLooping
|
||||||
|
[0, 0], // loopingRange
|
||||||
defaults.tracks, // tracks
|
defaults.tracks, // tracks
|
||||||
1 // projectStructureVersion
|
3 // projectStructureVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { KGCore } from '../core/KGCore';
|
||||||
|
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the loop mode on/off with proper validation.
|
||||||
|
* When enabling loop mode:
|
||||||
|
* - If loop range is [0, 0], sets it to the entire song [0, maxBars]
|
||||||
|
* - Validates that loop range is within [0, maxBars]
|
||||||
|
* Uses the command pattern for undo/redo support.
|
||||||
|
*
|
||||||
|
* @param currentIsLooping Current loop mode state
|
||||||
|
* @param currentLoopingRange Current loop range [startBar, endBar]
|
||||||
|
* @param maxBars Maximum number of bars in the project
|
||||||
|
*/
|
||||||
|
export const toggleLoop = (
|
||||||
|
currentIsLooping: boolean,
|
||||||
|
currentLoopingRange: [number, number],
|
||||||
|
maxBars: number
|
||||||
|
): void => {
|
||||||
|
const newLoopingState = !currentIsLooping;
|
||||||
|
let newLoopingRange = currentLoopingRange;
|
||||||
|
|
||||||
|
// When enabling loop, validate and set the loop range
|
||||||
|
if (newLoopingState) {
|
||||||
|
const currentRange = currentLoopingRange;
|
||||||
|
const projectMaxBars = maxBars;
|
||||||
|
|
||||||
|
// If range is [0, 0], set it to the entire song
|
||||||
|
if (currentRange[0] === 0 && currentRange[1] === 0) {
|
||||||
|
newLoopingRange = [0, projectMaxBars] as [number, number];
|
||||||
|
} else {
|
||||||
|
// Validate range is within [0, maxBars]
|
||||||
|
const validatedStart = Math.max(0, Math.min(currentRange[0], projectMaxBars));
|
||||||
|
const validatedEnd = Math.max(0, Math.min(currentRange[1], projectMaxBars));
|
||||||
|
|
||||||
|
// If range changed, update it
|
||||||
|
if (validatedStart !== currentRange[0] || validatedEnd !== currentRange[1]) {
|
||||||
|
newLoopingRange = [validatedStart, validatedEnd] as [number, number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute command for undo/redo support
|
||||||
|
const command = new ChangeLoopSettingsCommand({
|
||||||
|
isLooping: newLoopingState,
|
||||||
|
loopingRange: newLoopingRange
|
||||||
|
});
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user