From 20310ccea0dc4f4c124f3c3ac786f4c732f2c884 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:32:25 -0800 Subject: [PATCH 1/8] docs: updated welcome_again.md --- public/chat/welcome_again.md | 1 - 1 file changed, 1 deletion(-) diff --git a/public/chat/welcome_again.md b/public/chat/welcome_again.md index b9c1b32..f533d0f 100644 --- a/public/chat/welcome_again.md +++ b/public/chat/welcome_again.md @@ -4,7 +4,6 @@ Great to see you again! Your LLM provider appears to be configured. You can star Tips: - 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 `/help` to view the help message. From f367c84ee821ded4585d77f389489a02886afd00 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:36:25 -0800 Subject: [PATCH 2/8] feat: implemented UI of looping feature --- src/App.css | 5 + src/components/MainContent.tsx | 184 +++++++++++++++++++++------------ src/components/Toolbar.tsx | 64 ++++++++++-- src/constants/uiConstants.ts | 6 ++ src/core/KGProject.ts | 28 ++++- src/stores/projectStore.ts | 6 ++ src/test/utils/mock-data.ts | 2 + 7 files changed, 223 insertions(+), 72 deletions(-) diff --git a/src/App.css b/src/App.css index 6b00ca2..5bbb13e 100644 --- a/src/App.css +++ b/src/App.css @@ -268,6 +268,11 @@ body { box-sizing: border-box; } +.bar-number-cell.looped { + background-color: #e1ae01; + color: #1e1e1e; +} + .main-content-body { display: flex; min-height: fit-content; diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 90cceec..c35d5a4 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -8,7 +8,7 @@ import TrackInfoPanel from './track/TrackInfoPanel'; import TrackGridPanel from './track/TrackGridPanel'; import PianoRoll from './piano-roll/PianoRoll'; import type { RegionUI } from './interfaces'; -import { DEBUG_MODE } from '../constants'; +import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants'; import { useRegionOperations } from '../hooks/useRegionOperations'; import { regionDeleteManager } from '../util/regionDeleteUtil'; @@ -73,9 +73,11 @@ const MainContent: React.FC = ({ // Refs to track pending updates for verification const pendingUpdates = useRef>(new Map()); - // Refs for bar numbers drag functionality - const isDraggingRef = useRef(false); + // Refs for bar numbers and loop range drag functionality const barNumbersRef = useRef(null); + const isLoopDraggingRef = useRef(false); + const loopDragStartBarRef = useRef(null); + const loopDragStartXRef = useRef(null); // Effect to verify track updates useEffect(() => { @@ -515,75 +517,115 @@ const MainContent: React.FC = ({ return destinationBeatPosition; }, [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) => { // Only handle left mouse button if (e.button !== 0) return; - - isDraggingRef.current = true; - - // Calculate and set initial playhead position - const newPosition = calculatePlayheadFromMouse(e.clientX); - if (newPosition !== null) { - setPlayheadPosition(newPosition); - - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Bar numbers drag started - Initial position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); - } + + // Calculate starting bar index + const startBarIndex = calculateBarIndexFromMouse(e.clientX); + if (startBarIndex === null) return; + + // Always start loop drag tracking + isLoopDraggingRef.current = true; + loopDragStartBarRef.current = startBarIndex; + loopDragStartXRef.current = e.clientX; + + if (DEBUG_MODE.MAIN_CONTENT) { + console.log(`Bar numbers mouse down - Start bar: ${startBarIndex} (displayed as bar ${startBarIndex + 1})`); } - + // Prevent text selection during drag e.preventDefault(); }; - // Handle click on bar numbers to move playhead (when not dragging) - const handleBarNumbersClick = (e: React.MouseEvent) => { - // 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 + // Global mouse move and mouse up handlers for loop range drag functionality useEffect(() => { const handleMouseMove = (e: MouseEvent) => { - if (!isDraggingRef.current) return; - - const newPosition = calculatePlayheadFromMouse(e.clientX); - if (newPosition !== null) { - setPlayheadPosition(newPosition); - - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Bar numbers drag - Position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); - } + if (!isLoopDraggingRef.current) return; + if (loopDragStartBarRef.current === null || loopDragStartXRef.current === null) return; + + // Calculate distance moved + const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current); + + // 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) { + console.log(`Loop range drag - Range: [${loopStart}, ${loopEnd}] (bars ${loopStart + 1}-${loopEnd + 1})`); } }; - const handleMouseUp = () => { - if (isDraggingRef.current) { - isDraggingRef.current = false; - - if (DEBUG_MODE.MAIN_CONTENT) { - console.log('Bar numbers drag ended'); + const handleMouseUp = (e: MouseEvent) => { + if (isLoopDraggingRef.current) { + if (loopDragStartXRef.current !== null) { + const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current); + + // If dragged beyond threshold, enable looping + if (distanceMoved >= BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) { + const core = KGCore.instance(); + const project = core.getCurrentProject(); + project.setIsLooping(true); + useProjectStore.setState({ isLooping: true }); + + if (DEBUG_MODE.MAIN_CONTENT) { + console.log('Loop range drag ended - Looping auto-enabled'); + } + } 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; } }; @@ -596,25 +638,37 @@ const MainContent: React.FC = ({ document.removeEventListener('mousemove', handleMouseMove); 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 (
{/* Top-left spacer */}
- + {/* Bar numbers at the top */} -
{Array.from({ length: maxBars }, (_, i) => ( -
{i + 1}
+
+ {i + 1} +
))}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index b21e984..2b09402 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -7,9 +7,9 @@ import { useProjectStore } from '../stores/projectStore'; import { DEBUG_MODE } from '../constants/uiConstants'; import { TIME_CONSTANTS } from '../constants/coreConstants'; import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil'; -import { - FaUndo, FaRedo, FaMousePointer, FaStepBackward, - FaPlay, FaPause, FaComments, +import { + FaUndo, FaRedo, FaMousePointer, FaStepBackward, + FaPlay, FaPause, FaComments, FaSync, FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus, FaCog } from 'react-icons/fa'; @@ -27,12 +27,13 @@ import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; const Toolbar: React.FC = () => { - const { - projectName, setProjectName, + const { + projectName, setProjectName, bpm, timeSignature, keySignature, setStatus, isPlaying, startPlaying, stopPlaying, setPlayheadPosition, currentTime, setBpm, setTimeSignature, setKeySignature, maxBars, setMaxBars, + isLooping, loopingRange, canUndo, canRedo, undoDescription, redoDescription, undo, redo, toggleChatBox, toggleSettings, cleanupProjectState, // Piano roll state/actions @@ -43,7 +44,7 @@ const Toolbar: React.FC = () => { // State for main content tools const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer'); - + // State for key signature dropdown const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false); @@ -412,6 +413,50 @@ const Toolbar: React.FC = () => { setPlayheadPosition(0); }; + const handleLoopToggle = () => { + const core = KGCore.instance(); + const project = core.getCurrentProject(); + const newLoopingState = !isLooping; + let newLoopingRange = loopingRange; + + // When enabling loop, validate and set the loop range + if (newLoopingState) { + const currentRange = loopingRange; + 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]; + if (DEBUG_MODE.TOOLBAR) { + console.log("Loop range auto-set to entire song:", newLoopingRange); + } + } 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]; + if (DEBUG_MODE.TOOLBAR) { + console.log("Loop range clamped to valid range:", newLoopingRange); + } + } + } + } + + // Update project model + project.setIsLooping(newLoopingState); + project.setLoopingRange(newLoopingRange); + + // Update store to trigger UI re-render + useProjectStore.setState({ isLooping: newLoopingState, loopingRange: newLoopingRange }); + + if (DEBUG_MODE.TOOLBAR) { + console.log("Loop toggle clicked, isLooping:", newLoopingState, "range:", newLoopingRange); + } + }; + // Prompt to change max bars when clicking on current-time display const handleCurrentTimeClick = () => { const MIN_BARS = 16; @@ -740,6 +785,13 @@ const Toolbar: React.FC = () => { ) : ( )} +
{/* diff --git a/src/constants/uiConstants.ts b/src/constants/uiConstants.ts index 3cf7702..6eb9ded 100644 --- a/src/constants/uiConstants.ts +++ b/src/constants/uiConstants.ts @@ -51,3 +51,9 @@ export const PLAYING_CONSTANTS = { // Update rate for playback (10 FPS for performance evaluation) 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, +}; diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 6cf07a8..76a24bd 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -35,6 +35,14 @@ export class KGProject { @WithDefault("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() @WithDefault(0) private projectStructureVersion: number = 0; @@ -54,7 +62,7 @@ export class KGProject { private tracks: KGTrack[] = []; // 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.maxBars = maxBars; this.currentBars = currentBars; @@ -62,6 +70,8 @@ export class KGProject { this.timeSignature = timeSignature; this.keySignature = keySignature; this.selectedMode = selectedMode; + this.isLooping = isLooping; + this.loopingRange = loopingRange; this.tracks = tracks; this.projectStructureVersion = projectStructureVersion; } @@ -139,5 +149,21 @@ export class KGProject { public getProjectStructureVersion(): number { 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; + } } diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 4804ec3..a4e5df4 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -40,6 +40,8 @@ interface ProjectState { bpm: number; keySignature: KeySignature; selectedMode: string; + isLooping: boolean; + loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based) playheadPosition: number; // in beats isPlaying: boolean; currentTime: string; // formatted time string @@ -225,6 +227,8 @@ export const useProjectStore = create((set, get) => { bpm: currentProject.getBpm(), keySignature: currentProject.getKeySignature(), selectedMode: currentProject.getSelectedMode(), + isLooping: currentProject.getIsLooping(), + loopingRange: currentProject.getLoopingRange(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), @@ -519,6 +523,8 @@ export const useProjectStore = create((set, get) => { bpm, keySignature, selectedMode: projectToLoad.getSelectedMode(), + isLooping: projectToLoad.getIsLooping(), + loopingRange: projectToLoad.getLoopingRange(), playheadPosition: 0, // Ensure store state is also updated currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display }); diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts index 247021e..bf3062f 100644 --- a/src/test/utils/mock-data.ts +++ b/src/test/utils/mock-data.ts @@ -121,6 +121,8 @@ export const createMockProject = (overrides: Partial<{ defaults.timeSignature, 'C major', // keySignature 'ionian', // selectedMode + false, // isLooping + [0, 0], // loopingRange defaults.tracks, // tracks 1 // projectStructureVersion ) From b80e7e84b818a1d6985f609c84ddb27a5ba10214 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:50:09 -0800 Subject: [PATCH 3/8] feat: implemented change loop setting command --- src/components/MainContent.tsx | 42 ++++- src/components/Toolbar.tsx | 14 +- src/core/commands/index.ts | 3 +- .../project/ChangeLoopSettingsCommand.ts | 163 ++++++++++++++++++ 4 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 src/core/commands/project/ChangeLoopSettingsCommand.ts diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index c35d5a4..b905f6b 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -11,6 +11,7 @@ import type { RegionUI } from './interfaces'; import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants'; import { useRegionOperations } from '../hooks/useRegionOperations'; import { regionDeleteManager } from '../util/regionDeleteUtil'; +import { ChangeLoopSettingsCommand } from '../core/commands'; interface MainContentProps { onTrackClick?: () => void; @@ -78,6 +79,7 @@ const MainContent: React.FC = ({ const isLoopDraggingRef = useRef(false); const loopDragStartBarRef = useRef(null); const loopDragStartXRef = useRef(null); + const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null); // Effect to verify track updates useEffect(() => { @@ -550,6 +552,12 @@ const MainContent: React.FC = ({ loopDragStartBarRef.current = startBarIndex; loopDragStartXRef.current = e.clientX; + // 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})`); } @@ -599,15 +607,36 @@ const MainContent: React.FC = ({ if (loopDragStartXRef.current !== null) { const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current); - // If dragged beyond threshold, enable looping + // If dragged beyond threshold, execute command for undo/redo support if (distanceMoved >= BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) { const core = KGCore.instance(); - const project = core.getCurrentProject(); - project.setIsLooping(true); - useProjectStore.setState({ isLooping: true }); + const currentIsLooping = core.getCurrentProject().getIsLooping(); + const currentLoopingRange = core.getCurrentProject().getLoopingRange(); - if (DEBUG_MODE.MAIN_CONTENT) { - console.log('Loop range drag ended - Looping auto-enabled'); + // 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) { + // 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 @@ -626,6 +655,7 @@ const MainContent: React.FC = ({ isLoopDraggingRef.current = false; loopDragStartBarRef.current = null; loopDragStartXRef.current = null; + loopDragOriginalSettingsRef.current = null; } }; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 2b09402..a07e3a2 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -14,6 +14,7 @@ import { FaCog } from 'react-icons/fa'; import { KGProject, type KeySignature } from '../core/KGProject'; +import { ChangeLoopSettingsCommand } from '../core/commands'; import { plainToInstance, instanceToPlain } from 'class-transformer'; import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; @@ -415,7 +416,6 @@ const Toolbar: React.FC = () => { const handleLoopToggle = () => { const core = KGCore.instance(); - const project = core.getCurrentProject(); const newLoopingState = !isLooping; let newLoopingRange = loopingRange; @@ -445,12 +445,12 @@ const Toolbar: React.FC = () => { } } - // Update project model - project.setIsLooping(newLoopingState); - project.setLoopingRange(newLoopingRange); - - // Update store to trigger UI re-render - useProjectStore.setState({ isLooping: newLoopingState, loopingRange: newLoopingRange }); + // Execute command for undo/redo support + const command = new ChangeLoopSettingsCommand({ + isLooping: newLoopingState, + loopingRange: newLoopingRange + }); + core.executeCommand(command); if (DEBUG_MODE.TOOLBAR) { console.log("Loop toggle clicked, isLooping:", newLoopingState, "range:", newLoopingRange); diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 60be091..bb1e8cd 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -28,4 +28,5 @@ export { MoveNotesCommand } from './note/MoveNotesCommand'; export { PasteNotesCommand } from './note/PasteNotesCommand'; // Project commands -export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand'; \ No newline at end of file +export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand'; +export { ChangeLoopSettingsCommand, type LoopSettings } from './project/ChangeLoopSettingsCommand'; \ No newline at end of file diff --git a/src/core/commands/project/ChangeLoopSettingsCommand.ts b/src/core/commands/project/ChangeLoopSettingsCommand.ts new file mode 100644 index 0000000..7b07a27 --- /dev/null +++ b/src/core/commands/project/ChangeLoopSettingsCommand.ts @@ -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 = 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 { + return new Set(this.changedSettings); + } +} From 78b798e138013aacf83c50f5ed04fcf6ed0ac175 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:05:22 -0800 Subject: [PATCH 4/8] feat: added a hotkey for loop button --- public/config.json | 1 + src/components/Toolbar.tsx | 43 ++--------------------- src/core/config/ConfigManager.ts | 4 ++- src/hooks/useGlobalKeyboardHandler.ts | 18 ++++++++-- src/stores/projectStore.ts | 7 ++++ src/util/loopUtil.ts | 49 +++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 43 deletions(-) create mode 100644 src/util/loopUtil.ts diff --git a/public/config.json b/public/config.json index 64a7de2..60c1a47 100644 --- a/public/config.json +++ b/public/config.json @@ -34,6 +34,7 @@ "main": { "hold_to_create_region": "ctrl", "play": "space", + "loop": "c", "undo": "ctrl+z", "redo": "ctrl+shift+z", "select_all": "ctrl+a", diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index a07e3a2..432c97c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -14,7 +14,6 @@ import { FaCog } from 'react-icons/fa'; import { KGProject, type KeySignature } from '../core/KGProject'; -import { ChangeLoopSettingsCommand } from '../core/commands'; import { plainToInstance, instanceToPlain } from 'class-transformer'; import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; @@ -34,7 +33,7 @@ const Toolbar: React.FC = () => { isPlaying, startPlaying, stopPlaying, setPlayheadPosition, currentTime, setBpm, setTimeSignature, setKeySignature, maxBars, setMaxBars, - isLooping, loopingRange, + isLooping, toggleLoop, canUndo, canRedo, undoDescription, redoDescription, undo, redo, toggleChatBox, toggleSettings, cleanupProjectState, // Piano roll state/actions @@ -415,45 +414,9 @@ const Toolbar: React.FC = () => { }; const handleLoopToggle = () => { - const core = KGCore.instance(); - const newLoopingState = !isLooping; - let newLoopingRange = loopingRange; - - // When enabling loop, validate and set the loop range - if (newLoopingState) { - const currentRange = loopingRange; - 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]; - if (DEBUG_MODE.TOOLBAR) { - console.log("Loop range auto-set to entire song:", newLoopingRange); - } - } 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]; - if (DEBUG_MODE.TOOLBAR) { - console.log("Loop range clamped to valid range:", newLoopingRange); - } - } - } - } - - // Execute command for undo/redo support - const command = new ChangeLoopSettingsCommand({ - isLooping: newLoopingState, - loopingRange: newLoopingRange - }); - core.executeCommand(command); - + toggleLoop(); if (DEBUG_MODE.TOOLBAR) { - console.log("Loop toggle clicked, isLooping:", newLoopingState, "range:", newLoopingRange); + console.log("Loop toggle clicked"); } }; diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 994635c..6ac99ed 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -40,6 +40,7 @@ interface AppConfig { main: { hold_to_create_region: string; play: string; + loop: string; undo: string; redo: string; select_all: string; @@ -205,12 +206,13 @@ export class ConfigManager { main: { hold_to_create_region: 'ctrl', play: 'space', + loop: 'c', undo: 'ctrl+z', redo: 'ctrl+shift+z', select_all: 'ctrl+a', copy: 'ctrl+c', cut: 'ctrl+x', - paste: 'ctrl+v', + paste: 'ctrl+v', save: 'ctrl+s' }, piano_roll: { diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index ac38500..3ab7ae8 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -11,7 +11,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil'; * Handles keyboard shortcuts defined in the configuration */ export const useGlobalKeyboardHandler = () => { - const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, projectName } = useProjectStore(); + const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName } = useProjectStore(); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -59,6 +59,7 @@ export const useGlobalKeyboardHandler = () => { const pasteShortcut = configManager.get('hotkeys.main.paste') as string; const selectAllShortcut = configManager.get('hotkeys.main.select_all') 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; // Check for undo shortcut @@ -136,6 +137,19 @@ export const useGlobalKeyboardHandler = () => { 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 if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { event.preventDefault(); @@ -156,5 +170,5 @@ export const useGlobalKeyboardHandler = () => { return () => { 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 }; \ No newline at end of file diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index a4e5df4..2f30d18 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -12,6 +12,7 @@ import { KGRegion } from '../core/region/KGRegion'; import { AddTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand } from '../core/commands'; import { ConfigManager } from '../core/config/ConfigManager'; import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader'; +import { toggleLoop } from '../util/loopUtil'; /** * Update CSS custom property for time signature numerator @@ -86,6 +87,7 @@ interface ProjectState { setPlayheadPosition: (position: number) => void; startPlaying: () => Promise; stopPlaying: () => Promise; + toggleLoop: () => void; setBpm: (bpm: number) => void; setMaxBars: (maxBars: number) => void; setTimeSignature: (timeSignature: TimeSignature) => void; @@ -570,6 +572,11 @@ export const useProjectStore = create((set, get) => { set({ isPlaying: false }); }, + toggleLoop: () => { + const { isLooping, loopingRange, maxBars } = get(); + toggleLoop(isLooping, loopingRange, maxBars); + }, + setBpm: (bpm: number) => { try { // Create and execute the change project property command diff --git a/src/util/loopUtil.ts b/src/util/loopUtil.ts new file mode 100644 index 0000000..c152861 --- /dev/null +++ b/src/util/loopUtil.ts @@ -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); +}; From b46442bd9dd74633410e14e1f27a698f31bb85d0 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:10:01 -0800 Subject: [PATCH 5/8] feat: added upgradeToV3 upgrader --- src/core/KGProject.ts | 2 +- .../project-upgrader/KGProjectUpgrader.ts | 5 ++++ src/core/project-upgrader/upgradeToV3.ts | 26 +++++++++++++++++++ src/test/utils/mock-data.ts | 2 +- 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/core/project-upgrader/upgradeToV3.ts diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 76a24bd..233fc2f 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -47,7 +47,7 @@ export class KGProject { @WithDefault(0) private projectStructureVersion: number = 0; - public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 2; + public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 3; @Expose() @Type(() => KGTrack, { diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index 048d7dd..d0f3bfa 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -1,6 +1,7 @@ import { KGProject } from '../KGProject'; import { upgradeToV1 } from './upgradeToV1'; import { upgradeToV2 } from './upgradeToV2'; +import { upgradeToV3 } from './upgradeToV3'; /** * 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); break; } + case 3: { + workingProject = upgradeToV3(workingProject); + break; + } default: { // If an upgrader is missing, throw to prevent loading incompatible structures throw new Error(`No upgrader found for project structure version ${nextVersion}`); diff --git a/src/core/project-upgrader/upgradeToV3.ts b/src/core/project-upgrader/upgradeToV3.ts new file mode 100644 index 0000000..07dbf35 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV3.ts @@ -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; +} diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts index bf3062f..c7607e3 100644 --- a/src/test/utils/mock-data.ts +++ b/src/test/utils/mock-data.ts @@ -124,7 +124,7 @@ export const createMockProject = (overrides: Partial<{ false, // isLooping [0, 0], // loopingRange defaults.tracks, // tracks - 1 // projectStructureVersion + 3 // projectStructureVersion ) return project From b767d2176f7f5ae19324f2cc82a71c7453e21727 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:22:51 -0800 Subject: [PATCH 6/8] feat: enhanced the note scheduling logic to support loop mode pending cases: user disabled the loop mode or updated the loop range during playback. --- src/core/KGCore.ts | 78 +++++++++++++++----- src/core/audio-interface/KGAudioInterface.ts | 44 ++++++++++- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 3ac5d67..829e9d8 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -270,16 +270,36 @@ export class KGCore { // High-level playback control methods public async startPlaying(): Promise { + // 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 await this.preparePlay(); - + // Start playing (non-blocking) this.play(); // Don't await this - + // Set up the regular playback update timer this.playbackStartTime = performance.now(); this.playbackStartPosition = this.playheadPosition; - + this.startPlaybackUpdates(); } @@ -336,28 +356,52 @@ export class KGCore { const bpm = this.currentProject.getBpm(); const beatsPerMs = bpm / (60 * 1000); - const newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs); - - // Stop playback at the end of project (maxBars) - const maxBars = this.currentProject.getMaxBars(); + let newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs); + + // Handle looping or end-of-project const beatsPerBar = this.currentProject.getTimeSignature().numerator; - const maxBeats = maxBars * beatsPerBar; - if (newPosition >= maxBeats) { - // Clamp to max and stop - this.setPlayheadPosition(maxBeats); - // Stop playback (non-blocking) - this.stopPlaying(); - return; + + 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; + + if (newPosition >= maxBeats) { + // Clamp to max and stop + this.setPlayheadPosition(maxBeats); + // Stop playback (non-blocking) + this.stopPlaying(); + return; + } } - + // Update playhead position this.setPlayheadPosition(newPosition); - + // TODO: Future enhancements // - Sync with Tone.Transport position for more accurate timing // - Handle tempo changes mid-playback // - Account for latency compensation - // - Support for loop regions } // selected items diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index 3b5bd4d..b775e98 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -264,9 +264,41 @@ export class KGAudioInterface { Tone.Transport.bpm.value = project.getBpm(); const timeSignature = project.getTimeSignature(); Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator]; - + 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) this.setTransportPosition(startPosition); @@ -290,8 +322,14 @@ export class KGAudioInterface { // Calculate absolute note timing in beats (note position + region start position) const regionStartBeat = region.getStartFromBeat(); const noteStartBeat = note.getStartBeat() + regionStartBeat; + const noteEndBeat = note.getEndBeat() + regionStartBeat; 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 if (noteStartBeat < startPosition) { return; // Skip notes that would have already finished before playback starts From 1c8361d45b499b9854e1db3330fd255d65c8008b Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:34:26 -0800 Subject: [PATCH 7/8] feat: implemented playback looping; fixed an linter error in Toolbar.tsx --- src/components/MainContent.tsx | 24 ++++++++++++++---------- src/components/Toolbar.tsx | 2 +- src/stores/projectStore.ts | 8 +++++++- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index b905f6b..76e742c 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -20,15 +20,15 @@ interface MainContentProps { const MainContent: React.FC = ({ onTrackClick = () => {} // Default to empty function if not provided }) => { - const { - tracks, - maxBars, - reorderTracks, + const { + tracks, + maxBars, + reorderTracks, updateTrack, - updateTrackProperties, - timeSignature, - setPlayheadPosition, - clearAllSelections, + updateTrackProperties, + timeSignature, + setPlayheadPosition, + clearAllSelections, setSelectedTrack, showPianoRoll, activeRegionId, @@ -448,8 +448,6 @@ const MainContent: React.FC = ({ setActiveRegionId(null); }; - - /** * Add keyboard event listener for region deletion * Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions @@ -622,6 +620,12 @@ const MainContent: React.FC = ({ 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); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 432c97c..8ebef89 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -246,7 +246,7 @@ const Toolbar: React.FC = () => { const midiData = convertProjectToMidi(currentProject); // 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 const url = URL.createObjectURL(blob); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 2f30d18..78c7a2a 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -573,7 +573,13 @@ export const useProjectStore = create((set, get) => { }, toggleLoop: () => { - const { isLooping, loopingRange, maxBars } = get(); + const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get(); + + // Stop playback if currently playing + if (isPlaying) { + stopPlaying(); + } + toggleLoop(isLooping, loopingRange, maxBars); }, From 8e8c55774f07cc30c9d87fb829b07c2832fd833f Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:36:51 -0800 Subject: [PATCH 8/8] docs: updated README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 39ea640..1fb737b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with * ## 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.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!