Merge pull request #27 from KGAudioLab/feat/2026-01-22-looping

Feat/2026 01 22 looping
This commit is contained in:
Xiaohan-Tian
2026-01-23 15:37:27 -08:00
committed by GitHub
19 changed files with 614 additions and 110 deletions
+1
View File
@@ -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!
-1
View File
@@ -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.
+1
View File
@@ -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",
+5
View File
@@ -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;
+163 -75
View File
@@ -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;
@@ -19,15 +20,15 @@ interface MainContentProps {
const MainContent: React.FC<MainContentProps> = ({ const MainContent: React.FC<MainContentProps> = ({
onTrackClick = () => {} // Default to empty function if not provided onTrackClick = () => {} // Default to empty function if not provided
}) => { }) => {
const { const {
tracks, tracks,
maxBars, maxBars,
reorderTracks, reorderTracks,
updateTrack, updateTrack,
updateTrackProperties, updateTrackProperties,
timeSignature, timeSignature,
setPlayheadPosition, setPlayheadPosition,
clearAllSelections, clearAllSelections,
setSelectedTrack, setSelectedTrack,
showPianoRoll, showPianoRoll,
activeRegionId, activeRegionId,
@@ -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,75 +517,149 @@ 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);
// Calculate and set initial playhead position if (startBarIndex === null) return;
const newPosition = calculatePlayheadFromMouse(e.clientX);
if (newPosition !== null) { // Always start loop drag tracking
setPlayheadPosition(newPosition); isLoopDraggingRef.current = true;
loopDragStartBarRef.current = startBarIndex;
if (DEBUG_MODE.MAIN_CONTENT) { loopDragStartXRef.current = e.clientX;
console.log(`Bar numbers drag started - Initial position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`);
} // Capture original loop settings for undo/redo
loopDragOriginalSettingsRef.current = {
isLooping,
loopingRange: [...loopingRange] as [number, number]
};
if (DEBUG_MODE.MAIN_CONTENT) {
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);
if (newPosition !== null) { // Calculate distance moved
setPlayheadPosition(newPosition); const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current);
if (DEBUG_MODE.MAIN_CONTENT) { // Only update if moved beyond threshold
console.log(`Bar numbers drag - Position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); 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) {
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 (DEBUG_MODE.MAIN_CONTENT) {
console.log('Bar numbers drag ended'); // 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) {
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;
} }
}; };
@@ -596,25 +672,37 @@ 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' : ''}`}>
<div className="main-content-wrapper"> <div className="main-content-wrapper">
{/* Top-left spacer */} {/* Top-left spacer */}
<div className="top-left-spacer"></div> <div className="top-left-spacer"></div>
{/* Bar numbers at the top */} {/* Bar numbers at the top */}
<div <div
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>
+22 -7
View File
@@ -7,9 +7,9 @@ import { useProjectStore } from '../stores/projectStore';
import { DEBUG_MODE } from '../constants/uiConstants'; import { DEBUG_MODE } from '../constants/uiConstants';
import { TIME_CONSTANTS } from '../constants/coreConstants'; 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';
@@ -27,12 +27,13 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon'; import PianoIcon from './common/icons/PianoIcon';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
const { const {
projectName, setProjectName, projectName, setProjectName,
bpm, timeSignature, keySignature, setStatus, bpm, timeSignature, keySignature, setStatus,
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
@@ -43,7 +44,7 @@ const Toolbar: React.FC = () => {
// State for main content tools // State for main content tools
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer'); const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
// State for key signature dropdown // State for key signature dropdown
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false); const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
@@ -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>
+6
View File
@@ -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,
};
+61 -17
View File
@@ -270,16 +270,36 @@ 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();
// Start playing (non-blocking) // Start playing (non-blocking)
this.play(); // Don't await this this.play(); // Don't await this
// Set up the regular playback update timer // Set up the regular playback update timer
this.playbackStartTime = performance.now(); this.playbackStartTime = performance.now();
this.playbackStartPosition = this.playheadPosition; this.playbackStartPosition = this.playheadPosition;
this.startPlaybackUpdates(); this.startPlaybackUpdates();
} }
@@ -336,28 +356,52 @@ 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;
const maxBeats = maxBars * beatsPerBar;
if (newPosition >= maxBeats) { if (this.currentProject.getIsLooping()) {
// Clamp to max and stop // Loop mode: wrap playhead when it reaches loop end
this.setPlayheadPosition(maxBeats); const [startBar, endBarOriginal] = this.currentProject.getLoopingRange();
// Stop playback (non-blocking) const endBar = (startBar === 0 && endBarOriginal === 0) ? this.currentProject.getMaxBars() : endBarOriginal;
this.stopPlaying();
return; 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;
if (newPosition >= maxBeats) {
// Clamp to max and stop
this.setPlayheadPosition(maxBeats);
// Stop playback (non-blocking)
this.stopPlaying();
return;
}
} }
// Update playhead position // Update playhead position
this.setPlayheadPosition(newPosition); this.setPlayheadPosition(newPosition);
// TODO: Future enhancements // TODO: Future enhancements
// - 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
View File
@@ -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;
}
} }
+41 -3
View File
@@ -264,9 +264,41 @@ export class KGAudioInterface {
Tone.Transport.bpm.value = project.getBpm(); Tone.Transport.bpm.value = project.getBpm();
const timeSignature = project.getTimeSignature(); const timeSignature = project.getTimeSignature();
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator]; Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
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
+2 -1
View File
@@ -28,4 +28,5 @@ export { MoveNotesCommand } from './note/MoveNotesCommand';
export { PasteNotesCommand } from './note/PasteNotesCommand'; 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);
}
}
+3 -1
View File
@@ -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,12 +206,13 @@ 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',
copy: 'ctrl+c', copy: 'ctrl+c',
cut: 'ctrl+x', cut: 'ctrl+x',
paste: 'ctrl+v', paste: 'ctrl+v',
save: 'ctrl+s' save: 'ctrl+s'
}, },
piano_roll: { piano_roll: {
@@ -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}`);
+26
View File
@@ -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;
}
+16 -2
View File
@@ -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
}; };
+19
View File
@@ -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
+3 -1
View File
@@ -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
+49
View File
@@ -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);
};