{/* Top-left spacer */}
- addTrack()}>+ Add track
+ addTrack()}>+ MIDI
+ addAudioTrack()}>+ Audio
{/* Bar numbers at the top */}
diff --git a/src/components/Toolbar.css b/src/components/Toolbar.css
index 6e7441c..e3a8b5c 100644
--- a/src/components/Toolbar.css
+++ b/src/components/Toolbar.css
@@ -153,3 +153,49 @@
width: 100px;
left: 0;
}
+
+/* Clickable zoom styling */
+.current-zoom {
+ font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-weight: normal;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ padding: 2px 4px;
+ border-radius: 2px;
+}
+
+.current-zoom:hover {
+ background-color: #4a4a4a;
+ color: #fff;
+}
+
+.zoom-slider-popup {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: #2d2d2d;
+ border: 1px solid #444;
+ border-radius: 3px;
+ padding: 8px 12px;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ white-space: nowrap;
+ margin-top: 2px;
+}
+
+.zoom-slider-popup input[type="range"] {
+ width: 120px;
+}
+
+.zoom-slider-label {
+ font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+ color: #e0e0e0;
+ min-width: 20px;
+ text-align: center;
+}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index b9b155f..f0fe8e8 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -12,7 +12,7 @@ import {
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
FaPlay, FaPause, FaComments, FaSync,
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
- FaCog
+ FaCog, FaMagnet
} from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject';
import { plainToInstance } from 'class-transformer';
@@ -22,18 +22,22 @@ import { regionDeleteManager } from '../util/regionDeleteUtil';
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
+import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer';
import KGDropdown from './common/KGDropdown';
import FileImportModal from './common/FileImportModal';
+import OpenProjectModal from './common/OpenProjectModal';
import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon';
const Toolbar: React.FC = () => {
const {
projectName, setProjectName,
+ savedProjectName, setSavedProjectName,
bpm, timeSignature, keySignature, setStatus,
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
currentTime, setBpm, setTimeSignature, setKeySignature,
maxBars, setMaxBars,
+ barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, cleanupProjectState,
@@ -45,6 +49,7 @@ const Toolbar: React.FC = () => {
// State for main content tools
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
+ const [isSnapping, setIsSnapping] = React.useState(true);
// State for key signature dropdown
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
@@ -54,12 +59,31 @@ const Toolbar: React.FC = () => {
// State for import modal
const [showImportModal, setShowImportModal] = React.useState(false);
+
+ // State for zoom slider popup
+ const [showZoomSlider, setShowZoomSlider] = React.useState(false);
+ const zoomSliderRef = React.useRef
(null);
+
+ // State for open project modal
+ const [showOpenProject, setShowOpenProject] = React.useState(false);
+ // Close zoom slider on click outside
+ React.useEffect(() => {
+ if (!showZoomSlider) return;
+ const handleClickOutside = (e: MouseEvent) => {
+ if (zoomSliderRef.current && !zoomSliderRef.current.contains(e.target as Node)) {
+ setShowZoomSlider(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showZoomSlider]);
+
// Key signature options
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
// Export options
- const exportOptions = ["Export to KGStudio file", "Export to MIDI file"];
+ const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"];
const handleProjectNameClick = () => {
const newName = prompt("Enter project name:", projectName);
@@ -73,25 +97,31 @@ const Toolbar: React.FC = () => {
};
// Common project loading logic extracted for reuse
- const loadProjectFromData = async (project: KGProject, sourceDescription: string) => {
+ const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string) => {
try {
// Clean up UI state first
cleanupProjectState();
-
+
// Automatically clear chat history when loading a project
clearChatHistoryAndUI();
-
+
// Load the project using the store's loadProject method
const { loadProject: storeLoadProject } = useProjectStore.getState();
- await storeLoadProject(project);
-
+ await storeLoadProject(project, savedName);
+
+ // Clean up orphan media files (only on open, not on save)
+ if (savedName) {
+ const storage = KGProjectStorage.getInstance();
+ await storage.cleanupOrphanMedia(savedName, project);
+ }
+
// Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`);
-
+
if (DEBUG_MODE.TOOLBAR) {
console.log(`project loaded successfully from ${sourceDescription}`);
}
-
+
} catch (error) {
console.error(`Error loading project from ${sourceDescription}:`, error);
setStatus(`Failed to load project: ${error}`);
@@ -99,73 +129,55 @@ const Toolbar: React.FC = () => {
}
};
+ // Core logic for creating a new project (no confirmation dialog)
+ const createNewProject = () => {
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("creating new project");
+ }
+
+ cleanupProjectState();
+ clearChatHistoryAndUI();
+
+ const newProject = new KGProject();
+ const { loadProject: storeLoadProject } = useProjectStore.getState();
+ storeLoadProject(newProject);
+
+ setStatus(`New project "${newProject.getName()}" created`);
+
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("new project created successfully");
+ }
+ };
+
// Handler functions for file operations
const handleNewProject = () => {
const confirmed = window.confirm("Are you sure you want to create a new project? Any unsaved changes will be lost.");
if (confirmed) {
- if (DEBUG_MODE.TOOLBAR) {
- console.log("user clicked new button");
- }
-
- // Clean up UI state first
- cleanupProjectState();
-
- // Automatically clear chat history when creating a new project
- clearChatHistoryAndUI();
-
- // Create a new project with default parameters
- const newProject = new KGProject();
-
- // Load the new project using the store's loadProject method
- const { loadProject: storeLoadProject } = useProjectStore.getState();
- storeLoadProject(newProject);
-
- // Default track creation is handled centrally in the store's loadProject
-
- // Update status to indicate new project created
- setStatus(`New project "${newProject.getName()}" created`);
-
- if (DEBUG_MODE.TOOLBAR) {
- console.log("new project created successfully");
- }
+ createNewProject();
}
};
- const handleLoadProject = async () => {
- const confirmed = window.confirm("Are you sure you want to load another project? Any unsaved changes will be lost.");
- if (confirmed) {
- if (DEBUG_MODE.TOOLBAR) {
- console.log("user clicked load button");
- }
-
- // Ask user for project name
- const projectNameToLoad = window.prompt("Enter the project name to load:");
-
- // Check if user input is empty or null (user cancelled)
- if (!projectNameToLoad || projectNameToLoad.trim() === '') {
- if (projectNameToLoad !== null) { // Only show error if user didn't cancel
- window.alert("Project name cannot be empty. Please enter a valid project name.");
- }
+ const handleLoadProject = () => {
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("user clicked load button");
+ }
+ setShowOpenProject(true);
+ };
+
+ const handleOpenProjectSelect = async (projectNameToLoad: string) => {
+ try {
+ const storage = KGProjectStorage.getInstance();
+ const loadedProject = await storage.load(projectNameToLoad);
+
+ if (!loadedProject) {
+ window.alert(`Project "${projectNameToLoad}" not found.`);
return;
}
-
- try {
- // Try to load the project from storage
- const storage = KGProjectStorage.getInstance();
- const loadedProject = await storage.load(projectNameToLoad.trim());
-
- if (!loadedProject) {
- window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
- return;
- }
-
- // Use common loading logic
- await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`);
-
- } catch (error) {
- console.error("Error loading project:", error);
- window.alert(`An error occurred while loading the project: ${error}`);
- }
+
+ await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
+ } catch (error) {
+ console.error("Error loading project:", error);
+ window.alert(`An error occurred while loading the project: ${error}`);
}
};
@@ -174,7 +186,12 @@ const Toolbar: React.FC = () => {
console.log("user clicked save button");
}
- await saveProject(projectName, setStatus);
+ await saveProject(projectName, savedProjectName, setStatus, (finalName) => {
+ setSavedProjectName(finalName);
+ if (finalName !== projectName) {
+ setProjectName(finalName);
+ }
+ });
};
const handleExportProject = (exportType: string) => {
@@ -186,6 +203,10 @@ const Toolbar: React.FC = () => {
handleExportKGStudio();
} else if (exportType === "Export to MIDI file") {
handleExportMIDI();
+ } else if (exportType === "Export to WAV") {
+ handleBounceToWav();
+ } else if (exportType === "Export to MP3") {
+ handleBounceToMp3();
}
setShowExportDropdown(false);
@@ -269,6 +290,38 @@ const Toolbar: React.FC = () => {
}
};
+ const handleBounceToWav = async () => {
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("bouncing to WAV");
+ }
+
+ try {
+ const currentProject = KGCore.instance().getCurrentProject();
+ await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName);
+ setStatus(`Project "${projectName}" exported as WAV file`);
+ } catch (error) {
+ console.error("Error bouncing to WAV:", error);
+ setStatus(`Error exporting WAV: ${error}`);
+ window.alert(`Failed to export project as WAV: ${error}`);
+ }
+ };
+
+ const handleBounceToMp3 = async () => {
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log("bouncing to MP3");
+ }
+
+ try {
+ const currentProject = KGCore.instance().getCurrentProject();
+ await KGOfflineRenderer.instance().bounceToMp3(currentProject, projectName);
+ setStatus(`Project "${projectName}" exported as MP3 file`);
+ } catch (error) {
+ console.error("Error bouncing to MP3:", error);
+ setStatus(`Error exporting MP3: ${error}`);
+ window.alert(`Failed to export project as MP3: ${error}`);
+ }
+ };
+
const handleImportProject = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("user clicked import button");
@@ -317,7 +370,7 @@ const Toolbar: React.FC = () => {
throw new Error('Failed to load imported project');
}
- await loadProjectFromData(loaded, `KGStudio file "${file.name}"`);
+ await loadProjectFromData(loaded, `KGStudio file "${file.name}"`, projectName);
if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio file imported successfully:", projectName);
@@ -357,7 +410,7 @@ const Toolbar: React.FC = () => {
throw new Error('Failed to load imported project from storage');
}
- await loadProjectFromData(loaded, `JSON file "${file.name}"`);
+ await loadProjectFromData(loaded, `JSON file "${file.name}"`, importedName);
if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
@@ -554,6 +607,16 @@ const Toolbar: React.FC = () => {
}
};
+ // Handle snapping toggle
+ const handleSnappingToggle = () => {
+ const newValue = !isSnapping;
+ setIsSnapping(newValue);
+ KGMainContentState.instance().setSnapping(newValue);
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log(`Snapping ${newValue ? 'enabled' : 'disabled'}`);
+ }
+ };
+
// Handle copy button click
const handleCopyClick = () => {
if (DEBUG_MODE.TOOLBAR) {
@@ -759,13 +822,20 @@ const Toolbar: React.FC = () => {
>
- handleMainToolSelect('pencil')}
>
+
+
+
@@ -792,6 +862,28 @@ const Toolbar: React.FC = () => {
+
+
setShowZoomSlider(!showZoomSlider)}
+ style={{ cursor: 'pointer' }}
+ >
+ {barWidthMultiplier}x
+
+ {showZoomSlider && (
+
+ setBarWidthMultiplier(parseInt(e.target.value))}
+ />
+ {barWidthMultiplier}x
+
+ )}
+
{currentTime}
@@ -836,6 +928,15 @@ const Toolbar: React.FC = () => {
title="Import Project"
description="Drag and drop your project file here"
/>
+
+ {showOpenProject && (
+
setShowOpenProject(false)}
+ onOpenProject={handleOpenProjectSelect}
+ currentProjectName={savedProjectName}
+ onCreateNewProject={createNewProject}
+ />
+ )}
>
);
};
diff --git a/src/components/common/LoadingOverlay.css b/src/components/common/LoadingOverlay.css
index 56de905..fbc209b 100644
--- a/src/components/common/LoadingOverlay.css
+++ b/src/components/common/LoadingOverlay.css
@@ -3,6 +3,7 @@
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(4px);
z-index: 20000;
display: flex;
align-items: center;
diff --git a/src/components/common/OpenProjectModal.css b/src/components/common/OpenProjectModal.css
new file mode 100644
index 0000000..8a3c6c8
--- /dev/null
+++ b/src/components/common/OpenProjectModal.css
@@ -0,0 +1,285 @@
+/* Overlay — blocks and blurs content below */
+.open-project-overlay {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(3px);
+ z-index: 2000;
+}
+
+/* Open Project Panel — follows PianoRoll panel pattern */
+.open-project-panel {
+ position: fixed;
+ background-color: #2d2d2d;
+ border: 1px solid #3a3a3a;
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* Header — draggable */
+.open-project-header {
+ height: 30px;
+ background-color: #1e1e1e;
+ border-bottom: 1px solid #3a3a3a;
+ display: flex;
+ align-items: center;
+ padding: 0 10px;
+ cursor: move;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.open-project-title {
+ flex: 1;
+ text-align: center;
+ font-size: 14px;
+ color: #e0e0e0;
+ text-transform: uppercase;
+}
+
+/* Filter bar — sticky at top */
+.open-project-filter {
+ padding: 10px 12px;
+ background-color: #252525;
+ border-bottom: 1px solid #3a3a3a;
+ flex-shrink: 0;
+}
+
+.open-project-filter-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.open-project-filter input {
+ flex: 1;
+ padding: 6px 10px;
+ background-color: #1e1e1e;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ color: #e0e0e0;
+ font-size: 13px;
+ outline: none;
+ box-sizing: border-box;
+}
+
+.open-project-filter input:focus {
+ border-color: #7b68ee;
+}
+
+.open-project-filter input::placeholder {
+ color: #777;
+}
+
+/* View toggle (Projects / Trash) */
+.open-project-view-toggle {
+ display: flex;
+ border: 1px solid #3a3a3a;
+ border-radius: 4px;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.open-project-toggle-btn {
+ background-color: #1e1e1e;
+ color: #999;
+ border: none;
+ padding: 5px 12px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: background-color 0.1s, color 0.1s;
+}
+
+.open-project-toggle-btn:not(:last-child) {
+ border-right: 1px solid #3a3a3a;
+}
+
+.open-project-toggle-btn:hover {
+ color: #e0e0e0;
+}
+
+.open-project-toggle-btn.active {
+ background-color: #7b68ee;
+ color: #fff;
+}
+
+/* Trash hint banner */
+.open-project-trash-hint {
+ padding: 6px 12px;
+ background-color: #3a2a1a;
+ border-bottom: 1px solid #5a3a1a;
+ color: #d4a054;
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+/* Sort header row */
+.open-project-sort-header {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ background-color: #252525;
+ border-bottom: 1px solid #3a3a3a;
+ font-size: 11px;
+ color: #999;
+ text-transform: uppercase;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+.open-project-sort-col {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 4px;
+ border-radius: 3px;
+}
+
+.open-project-sort-col:hover {
+ color: #e0e0e0;
+ background-color: #3a3a3a;
+}
+
+.open-project-sort-col.active {
+ color: #7b68ee;
+}
+
+.open-project-sort-col-name {
+ flex: 1;
+}
+
+.open-project-sort-col-created,
+.open-project-sort-col-updated {
+ width: 160px;
+ text-align: left;
+}
+
+/* Scrollable project list */
+.open-project-list {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* Individual project row */
+.open-project-item {
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ border-bottom: 1px solid #333;
+ cursor: pointer;
+ transition: background-color 0.1s;
+}
+
+.open-project-item:hover {
+ background-color: #353535;
+}
+
+.open-project-item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.open-project-item-name {
+ font-size: 14px;
+ color: #e0e0e0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.open-project-item-meta {
+ font-size: 11px;
+ color: #888;
+ margin-top: 3px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Action buttons container */
+.open-project-item-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ margin-left: 10px;
+}
+
+/* Open button */
+.open-project-item-open-btn {
+ background-color: #7b68ee;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ padding: 5px 14px;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.open-project-item-open-btn:hover {
+ background-color: #6a58d6;
+}
+
+/* Icon action buttons (duplicate, delete, restore, perm-delete) */
+.open-project-item-action-btn {
+ background: transparent;
+ border: 1px solid #555;
+ border-radius: 4px;
+ color: #bbb;
+ padding: 5px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.1s, color 0.1s, border-color 0.1s;
+}
+
+.open-project-item-action-btn:hover {
+ background-color: #444;
+ color: #e0e0e0;
+ border-color: #777;
+}
+
+.open-project-item-permdelete-btn:hover {
+ background-color: #5a2020;
+ color: #ff6666;
+ border-color: #ff4444;
+}
+
+.open-project-item-restore-btn:hover {
+ background-color: #1a3a2a;
+ color: #66cc88;
+ border-color: #44aa66;
+}
+
+/* Loading and empty states */
+.open-project-loading,
+.open-project-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ color: #888;
+ font-size: 14px;
+ padding: 40px;
+}
+
+/* Resize handle — same as PianoRoll */
+.open-project-panel .resize-handle {
+ position: absolute;
+ right: 5px;
+ bottom: 5px;
+ width: 20px;
+ height: 20px;
+ cursor: nwse-resize;
+ color: #999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
diff --git a/src/components/common/OpenProjectModal.tsx b/src/components/common/OpenProjectModal.tsx
new file mode 100644
index 0000000..5f93577
--- /dev/null
+++ b/src/components/common/OpenProjectModal.tsx
@@ -0,0 +1,442 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { createPortal } from 'react-dom';
+import { FaTimes, FaGripLines, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-icons/fa';
+import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
+import { isValidProjectName } from '../../util/projectNameUtil';
+import './OpenProjectModal.css';
+
+interface OpenProjectModalProps {
+ onClose: () => void;
+ onOpenProject: (projectName: string) => Promise;
+ currentProjectName: string | null;
+ onCreateNewProject: () => void;
+}
+
+type SortField = 'name' | 'createdAt' | 'updatedAt';
+type SortDirection = 'asc' | 'desc';
+type ViewMode = 'projects' | 'trash';
+
+const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
+
+/**
+ * Incremental fuzzy filter: characters in the filter must appear in order
+ * within the project name (case-insensitive, ignoring non-alphanumeric chars).
+ */
+const matchesFilter = (projectName: string, filter: string): boolean => {
+ if (!filter) return true;
+ const normalizedName = projectName.toLowerCase().replace(/[^a-z0-9]/g, '');
+ const normalizedFilter = filter.toLowerCase().replace(/[^a-z0-9]/g, '');
+
+ let nameIndex = 0;
+ for (let i = 0; i < normalizedFilter.length; i++) {
+ const charIndex = normalizedName.indexOf(normalizedFilter[i], nameIndex);
+ if (charIndex === -1) return false;
+ nameIndex = charIndex + 1;
+ }
+ return true;
+};
+
+const formatDate = (timestamp: number): string => {
+ if (timestamp === 0) return 'Unknown';
+ const date = new Date(timestamp);
+ return date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+};
+
+const OpenProjectModal: React.FC = ({ onClose, onOpenProject, currentProjectName, onCreateNewProject }) => {
+ const panelRef = useRef(null);
+
+ // Position & size
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [size, setSize] = useState({ width: 600, height: 400 });
+
+ // Drag & resize
+ const [isDragging, setIsDragging] = useState(false);
+ const [isResizing, setIsResizing] = useState(false);
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
+
+ // Data
+ const [projects, setProjects] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [filter, setFilter] = useState('');
+
+ // Sort
+ const [sortField, setSortField] = useState('updatedAt');
+ const [sortDirection, setSortDirection] = useState('desc');
+
+ // View mode toggle
+ const [viewMode, setViewMode] = useState('projects');
+
+ // Calculate initial position/size to fill main-content area
+ useEffect(() => {
+ const toolbarEl = document.querySelector('.toolbar');
+ const statusBarEl = document.querySelector('.status-bar');
+
+ const width = Math.max(400, window.innerWidth - 400);
+ const height = Math.max(300, window.innerHeight - 200);
+ const x = (window.innerWidth - width) / 2;
+ const y = (window.innerHeight - height) / 2;
+
+ setPosition({ x, y });
+ setSize({ width, height });
+ }, []);
+
+ // Fetch projects when view mode changes
+ const fetchProjects = useCallback(async (mode: ViewMode) => {
+ setIsLoading(true);
+ try {
+ const storage = KGProjectStorage.getInstance();
+
+ // Auto-purge old trash when switching to trash view
+ if (mode === 'trash') {
+ await storage.purgeDeletedOlderThan(THIRTY_DAYS_MS);
+ }
+
+ const list = await storage.listWithMeta(mode === 'trash');
+ setProjects(list);
+ } catch (error) {
+ console.error('Error loading project list:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchProjects(viewMode);
+ }, [viewMode, fetchProjects]);
+
+ // Escape key to close
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ // Drag & resize mouse handling
+ const handleMouseDown = (e: React.MouseEvent, action: 'drag' | 'resize') => {
+ if (action === 'drag') {
+ setIsDragging(true);
+ if (panelRef.current) {
+ const rect = panelRef.current.getBoundingClientRect();
+ setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
+ }
+ } else {
+ setIsResizing(true);
+ e.preventDefault();
+ }
+ };
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (isDragging) {
+ setPosition({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y });
+ } else if (isResizing) {
+ setSize({
+ width: Math.max(400, e.clientX - position.x),
+ height: Math.max(300, e.clientY - position.y),
+ });
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ setIsResizing(false);
+ };
+
+ if (isDragging || isResizing) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging, isResizing, dragOffset, position]);
+
+ // Sort toggle
+ const handleSortClick = useCallback((field: SortField) => {
+ if (field === sortField) {
+ setSortDirection(d => (d === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortField(field);
+ setSortDirection(field === 'name' ? 'asc' : 'desc');
+ }
+ }, [sortField]);
+
+ // Filter + sort projects
+ const filteredProjects = React.useMemo(() => {
+ const filtered = projects.filter(p => matchesFilter(p.name, filter));
+
+ filtered.sort((a, b) => {
+ let cmp: number;
+ if (sortField === 'name') {
+ cmp = a.name.localeCompare(b.name);
+ } else {
+ cmp = a[sortField] - b[sortField];
+ }
+ return sortDirection === 'asc' ? cmp : -cmp;
+ });
+
+ return filtered;
+ }, [projects, filter, sortField, sortDirection]);
+
+ // --- Action handlers ---
+
+ const handleOpen = async (projectName: string) => {
+ await onOpenProject(projectName);
+ onClose();
+ };
+
+ const handleDuplicate = async (e: React.MouseEvent, projectName: string) => {
+ e.stopPropagation();
+ const newName = window.prompt('Enter a name for the duplicated project:', projectName);
+ if (!newName || newName.trim() === '') return;
+
+ const trimmed = newName.trim();
+ if (!isValidProjectName(trimmed)) {
+ window.alert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.');
+ return;
+ }
+
+ try {
+ const storage = KGProjectStorage.getInstance();
+ const finalName = await storage.resolveUniqueName(trimmed);
+ await storage.duplicate(projectName, finalName);
+ // Open the duplicated project
+ await onOpenProject(finalName);
+ onClose();
+ } catch (error) {
+ console.error('Error duplicating project:', error);
+ window.alert(`Failed to duplicate project: ${error}`);
+ }
+ };
+
+ const handleSoftDelete = async (e: React.MouseEvent, projectName: string) => {
+ e.stopPropagation();
+ try {
+ const storage = KGProjectStorage.getInstance();
+ await storage.softDelete(projectName);
+ // Refresh list
+ await fetchProjects(viewMode);
+ } catch (error) {
+ console.error('Error deleting project:', error);
+ window.alert(`Failed to delete project: ${error}`);
+ }
+ };
+
+ const handleRestore = async (e: React.MouseEvent, projectName: string) => {
+ e.stopPropagation();
+ try {
+ const storage = KGProjectStorage.getInstance();
+ await storage.restore(projectName);
+ await fetchProjects(viewMode);
+ } catch (error) {
+ console.error('Error restoring project:', error);
+ window.alert(`Failed to restore project: ${error}`);
+ }
+ };
+
+ const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => {
+ e.stopPropagation();
+ const confirmed = window.confirm(
+ `Are you sure you want to permanently delete "${projectName}"?\n\nThis operation cannot be undone.`
+ );
+ if (!confirmed) return;
+
+ try {
+ const storage = KGProjectStorage.getInstance();
+ await storage.delete(projectName);
+ await fetchProjects(viewMode);
+
+ // If the deleted project is the currently open one, create a new project
+ if (currentProjectName && projectName === currentProjectName) {
+ onCreateNewProject();
+ }
+ } catch (error) {
+ console.error('Error permanently deleting project:', error);
+ window.alert(`Failed to permanently delete project: ${error}`);
+ }
+ };
+
+ const handleViewModeChange = (mode: ViewMode) => {
+ setViewMode(mode);
+ setFilter('');
+ };
+
+ const SortIcon: React.FC<{ field: SortField }> = ({ field }) => {
+ if (sortField !== field) return null;
+ return sortDirection === 'asc' ? : ;
+ };
+
+ const content = (
+
+
e.stopPropagation()}
+ style={{
+ left: `${position.x}px`,
+ top: `${position.y}px`,
+ width: `${size.width}px`,
+ height: `${size.height}px`,
+ zIndex: 2001,
+ }}
+ >
+ {/* Header */}
+
handleMouseDown(e, 'drag')}>
+
+
+
+
Open Project
+
+
+ {/* Filter + view toggle */}
+
+
+
setFilter(e.target.value)}
+ autoFocus
+ />
+
+ handleViewModeChange('projects')}
+ >
+ Projects
+
+ handleViewModeChange('trash')}
+ >
+ Trash
+
+
+
+
+
+ {/* Trash hint */}
+ {viewMode === 'trash' && (
+
+ Deleted projects are automatically removed after 30 days.
+
+ )}
+
+ {/* Sort header */}
+
+ handleSortClick('name')}
+ >
+ Name
+
+ handleSortClick('createdAt')}
+ >
+ Created
+
+ handleSortClick('updatedAt')}
+ >
+ Updated
+
+
+
+ {/* Project list */}
+ {isLoading ? (
+
Loading projects...
+ ) : filteredProjects.length === 0 ? (
+
+ {projects.length === 0
+ ? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects')
+ : 'No projects match the filter'}
+
+ ) : (
+
+ {filteredProjects.map((project) => (
+
handleOpen(project.name) : undefined}
+ >
+
+
{project.name}
+
+ Created {formatDate(project.createdAt)} · Updated {formatDate(project.updatedAt)}
+ {viewMode === 'trash' && project.deletedAt && (
+ <> · Deleted {formatDate(project.deletedAt)}>
+ )}
+
+
+
+ {viewMode === 'projects' ? (
+ <>
+ { e.stopPropagation(); handleOpen(project.name); }}
+ >
+ Open
+
+ handleDuplicate(e, project.name)}
+ title="Duplicate"
+ >
+
+
+ handleSoftDelete(e, project.name)}
+ title="Delete"
+ >
+
+
+ >
+ ) : (
+ <>
+ handleRestore(e, project.name)}
+ title="Restore"
+ >
+
+
+ handlePermanentDelete(e, project.name)}
+ title="Delete permanently"
+ >
+
+
+ >
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Resize handle */}
+
handleMouseDown(e, 'resize')}>
+
+
+
+
+ );
+
+ return createPortal(content, document.body);
+};
+
+export default OpenProjectModal;
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
index 3d8c0ea..b0dc2f8 100644
--- a/src/components/common/index.ts
+++ b/src/components/common/index.ts
@@ -1,4 +1,5 @@
export { default as KGDropdown } from './KGDropdown';
export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal';
-export { default as LoadingOverlay } from './LoadingOverlay';
\ No newline at end of file
+export { default as LoadingOverlay } from './LoadingOverlay';
+export { default as OpenProjectModal } from './OpenProjectModal';
\ No newline at end of file
diff --git a/src/components/track/Region.css b/src/components/track/Region.css
index c1c17d5..9175075 100644
--- a/src/components/track/Region.css
+++ b/src/components/track/Region.css
@@ -71,6 +71,32 @@
position: relative; /* Allow overlayed controls */
}
+.region-content.audio-region-content {
+ background-color: #90EE90; /* Light green for audio regions */
+}
+
+/* Audio region overrides */
+.track-region.audio-region {
+ background-color: #3a6b4a;
+ border-color: #4a8b5a;
+}
+
+.track-region.audio-region:hover {
+ box-shadow: 0 0 0 1px #6aab7a;
+}
+
+.track-region.audio-region.selected {
+ border-color: #ffffff;
+}
+
+.track-region.audio-region .region-header {
+ background-color: #4a8b5a;
+}
+
+.track-region.audio-region:hover .region-header {
+ background-color: #5a9b6a;
+}
+
/* Region pencil trigger inside content */
.region-pencil-btn {
position: absolute;
diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx
index 084e021..7930edb 100644
--- a/src/components/track/RegionItem.tsx
+++ b/src/components/track/RegionItem.tsx
@@ -4,6 +4,7 @@ import { FaPencilAlt } from 'react-icons/fa';
import type { ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
+import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState';
@@ -28,6 +29,9 @@ interface RegionItemProps {
onOpenPianoRoll?: (regionId: string) => void;
// MIDI region data for rendering notes
midiRegion?: KGMidiRegion;
+ // Audio region data for rendering waveform
+ audioRegion?: KGAudioRegion;
+ audioBuffer?: AudioBuffer;
}
const RegionItem: React.FC = ({
@@ -45,10 +49,12 @@ const RegionItem: React.FC = ({
onDragEnd,
onClick,
onOpenPianoRoll,
- midiRegion
+ midiRegion,
+ audioRegion,
+ audioBuffer
}) => {
// Get selection state and time signature from store
- const { selectedRegionIds, timeSignature } = useProjectStore();
+ const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
const isSelected = selectedRegionIds.includes(id);
const [cursor, setCursor] = useState('pointer');
const [resizeEdge, setResizeEdge] = useState('none');
@@ -204,6 +210,76 @@ const RegionItem: React.FC = ({
}
};
+ // Function to render audio waveform on canvas
+ const renderWaveformOnCanvas = () => {
+ if (!canvasRef.current || !regionContentRef.current || !audioBuffer) return;
+
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const contentRect = regionContentRef.current.getBoundingClientRect();
+ const width = contentRect.width;
+ const height = contentRect.height;
+
+ canvas.width = width;
+ canvas.height = height;
+
+ ctx.clearRect(0, 0, width, height);
+
+ // Get channel data (use first channel)
+ const channelData = audioBuffer.getChannelData(0);
+ const totalSamples = channelData.length;
+ const sampleRate = audioBuffer.sampleRate;
+
+ // Calculate visible portion based on clip offset
+ const clipStartOffsetSeconds = audioRegion ? audioRegion.getClipStartOffsetSeconds() : 0;
+ const clipStartSample = Math.floor(clipStartOffsetSeconds * sampleRate);
+
+ // Calculate visible duration from region length in beats
+ const secondsPerBeat = 60 / bpm;
+ const regionLengthBeats = audioRegion ? audioRegion.getLength() : 0;
+ const visibleDurationSeconds = regionLengthBeats * secondsPerBeat;
+ const visibleSamples = Math.floor(visibleDurationSeconds * sampleRate);
+
+ // Clamp to buffer boundaries
+ const renderStartSample = Math.max(0, Math.min(clipStartSample, totalSamples));
+ const renderEndSample = Math.min(renderStartSample + visibleSamples, totalSamples);
+ const renderSampleCount = renderEndSample - renderStartSample;
+
+ if (renderSampleCount <= 0) return;
+
+ // Downsample visible portion to canvas width
+ const samplesPerPixel = Math.max(1, Math.floor(renderSampleCount / width));
+ const centerY = height / 2;
+
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+
+ for (let x = 0; x < width; x++) {
+ const startSample = renderStartSample + Math.floor(x * samplesPerPixel);
+ const endSample = Math.min(startSample + samplesPerPixel, renderEndSample);
+
+ let min = 0;
+ let max = 0;
+ for (let i = startSample; i < endSample; i++) {
+ const val = channelData[i];
+ if (val < min) min = val;
+ if (val > max) max = val;
+ }
+
+ // Draw vertical line from min to max amplitude
+ const yMin = centerY - max * centerY;
+ const yMax = centerY - min * centerY;
+
+ ctx.moveTo(x, yMin);
+ ctx.lineTo(x, yMax);
+ }
+
+ ctx.stroke();
+ };
+
// Create a stable reference to track note changes
const notesRef = useRef('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
@@ -226,15 +302,23 @@ const RegionItem: React.FC = ({
// Set up canvas when component mounts or updates
useEffect(() => {
- renderNotesOnCanvas();
- }, [midiRegion, timeSignature, id, noteUpdateTrigger]);
+ if (audioRegion && audioBuffer) {
+ renderWaveformOnCanvas();
+ } else {
+ renderNotesOnCanvas();
+ }
+ }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
// Re-render canvas when region content size changes
useEffect(() => {
if (!regionContentRef.current) return;
const resizeObserver = new ResizeObserver(() => {
- renderNotesOnCanvas();
+ if (audioRegion && audioBuffer) {
+ renderWaveformOnCanvas();
+ } else {
+ renderNotesOnCanvas();
+ }
});
resizeObserver.observe(regionContentRef.current);
@@ -244,7 +328,7 @@ const RegionItem: React.FC = ({
resizeObserver.unobserve(regionContentRef.current);
}
};
- }, [midiRegion, timeSignature]);
+ }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent) => {
@@ -453,7 +537,7 @@ const RegionItem: React.FC = ({
return (
= ({
data-is-dragging={isDragging}
>
- {name}
+ {audioRegion ? audioRegion.getAudioFileName() : name}
-
-
{
- e.preventDefault();
- e.stopPropagation();
- }}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- if (DEBUG_MODE.REGION_ITEM) {
- console.log(`Pencil clicked: open piano roll for region ${id}`);
- }
- if (onOpenPianoRoll) {
- onOpenPianoRoll(id);
- } else if (onClick) {
- onClick(id);
- }
- }}
- aria-label="Open piano roll"
- >
-
-
+
+ {!audioRegion && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (DEBUG_MODE.REGION_ITEM) {
+ console.log(`Pencil clicked: open piano roll for region ${id}`);
+ }
+ if (onOpenPianoRoll) {
+ onOpenPianoRoll(id);
+ } else if (onClick) {
+ onClick(id);
+ }
+ }}
+ aria-label="Open piano roll"
+ >
+
+
+ )}
diff --git a/src/components/track/Track.css b/src/components/track/Track.css
index 67143bc..fe1d7c6 100644
--- a/src/components/track/Track.css
+++ b/src/components/track/Track.css
@@ -26,7 +26,7 @@
font-size: 12px;
padding: 0;
height: 100%;
- width: 100%;
+ flex: 1;
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx
index ab29a17..3b518e8 100644
--- a/src/components/track/TrackGridItem.tsx
+++ b/src/components/track/TrackGridItem.tsx
@@ -1,6 +1,8 @@
import React, { useEffect, useState, useRef } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
+import { KGAudioRegion } from '../../core/region/KGAudioRegion';
+import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import RegionItem from './RegionItem';
import type { RegionUI, ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
@@ -266,20 +268,23 @@ const TrackGridItem: React.FC
= ({
// If the mouse was moved and we have current values, calculate the new values
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
+ const snap = KGMainContentState.instance().isSnappingEnabled();
+
if (resizeAction === 'end') {
- // End resize: round length to nearest bar
- newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, Math.round(currentResizeWidth.current / barWidth));
+ // End resize: snap length to nearest bar, or use raw value
+ const rawLength = currentResizeWidth.current / barWidth;
+ newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, snap ? Math.round(rawLength) : rawLength);
} else if (resizeAction === 'start') {
- // Start resize: round bar number and adjust length accordingly
+ // Start resize: snap bar number, or use raw value
const rawBarNumber = currentResizeLeft.current / barWidth + 1;
- newBarNumber = Math.max(1, Math.round(rawBarNumber));
-
+ newBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber);
+
// Calculate the difference from the initial position
const barDiff = initialBarNumberRef.current! - newBarNumber;
-
+
// Adjust length to maintain the end position
newLength = initialLengthRef.current! + barDiff;
-
+
// Ensure minimum length
if (newLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
newLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
@@ -432,9 +437,10 @@ const TrackGridItem: React.FC = ({
// If the mouse was moved, calculate the final position
if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) {
- // Calculate the new bar number and round to nearest integer
+ // Calculate the new bar number; snap to nearest integer when snapping is on
+ const snap = KGMainContentState.instance().isSnappingEnabled();
const rawBarNumber = (currentDragLeft.current / barWidth) + 1;
- finalBarNumber = Math.max(1, Math.round(rawBarNumber));
+ finalBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber);
// Calculate the closest track based on vertical position
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
@@ -506,11 +512,22 @@ const TrackGridItem: React.FC = ({
>
{/* Render regions for this track */}
{trackRegions.map(region => {
- // Find the corresponding KGMidiRegion in the track
- const midiRegion = track.getRegions().find(r => r.getId() === region.id) as KGMidiRegion | undefined;
-
+ // Find the corresponding region in the track
+ const coreRegion = track.getRegions().find(r => r.getId() === region.id);
+ const midiRegion = coreRegion?.getCurrentType() === 'KGMidiRegion' ? coreRegion as unknown as KGMidiRegion : undefined;
+ const audioRegion = coreRegion?.getCurrentType() === 'KGAudioRegion' ? coreRegion as unknown as KGAudioRegion : undefined;
+
+ // Get audio buffer for waveform rendering
+ let audioBuffer: AudioBuffer | undefined;
+ if (audioRegion) {
+ audioBuffer = KGAudioInterface.instance().getAudioBuffer(
+ track.getId().toString(),
+ audioRegion.getAudioFileId()
+ );
+ }
+
return (
- = ({
onDragEnd={handleRegionDragEnd}
// Keep onClick for selection-only logic if needed by parent
onClick={handleRegionClick}
- // New explicit pencil action
- onOpenPianoRoll={(regionId) => {
+ // New explicit pencil action — disabled for audio regions
+ onOpenPianoRoll={audioRegion ? undefined : (regionId) => {
if (onOpenPianoRoll) {
onOpenPianoRoll(regionId);
} else if (onRegionClick) {
@@ -536,6 +553,8 @@ const TrackGridItem: React.FC = ({
}
}}
midiRegion={midiRegion}
+ audioRegion={audioRegion}
+ audioBuffer={audioBuffer}
/>
);
})}
diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx
index 50b104e..dad1391 100644
--- a/src/components/track/TrackGridPanel.tsx
+++ b/src/components/track/TrackGridPanel.tsx
@@ -1,14 +1,16 @@
import React, { useRef } from 'react';
-import { KGTrack } from '../../core/track/KGTrack';
+import { KGTrack, TrackType } from '../../core/track/KGTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import TrackGridItem from './TrackGridItem';
import { Playhead } from '../common';
import type { RegionUI } from '../interfaces';
-import { DEBUG_MODE } from '../../constants';
+import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil';
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
import { KGCore } from '../../core/KGCore';
+import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
+import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { generateNewRegionName } from '../../util/miscUtil';
interface TrackGridPanelProps {
@@ -64,7 +66,12 @@ const TrackGridPanel: React.FC = ({
// Get the track and its ID
const track = tracks[trackIndex];
const trackId = track.getId().toString();
-
+
+ // Don't allow manual region creation on audio tracks
+ if (track.getType() === TrackType.Wave) {
+ return;
+ }
+
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Creating region on track ${trackIndex + 1}, bar ${barNumber}`);
}
@@ -152,47 +159,94 @@ const TrackGridPanel: React.FC = ({
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Finished resizing region ${regionId} to barNumber ${finalBarNumber}, length ${finalLength}`);
}
-
+
// Find the region
const region = regions.find(r => r.id === regionId);
if (!region) return;
-
+
// Calculate new start and length in beats
const beatsPerBar = timeSignature.numerator;
- const newStartBeat = (finalBarNumber - 1) * beatsPerBar;
- const newLengthInBeats = finalLength * beatsPerBar;
-
+ let clampedBarNumber = finalBarNumber;
+ let clampedLength = finalLength;
+
// Find the track that contains this region
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
-
+
// Update the region in the track's model
const trackRegions = track.getRegions();
- const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
-
- if (midiRegion) {
- const oldStartBeat = midiRegion.getStartFromBeat();
+ const coreRegion = trackRegions.find(r => r.getId() === regionId);
+
+ if (coreRegion) {
+ const oldStartBeat = coreRegion.getStartFromBeat();
const oldBarNumber = region.barNumber;
-
+
if (DEBUG_MODE.TRACK_GRID_PANEL) {
- console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${midiRegion.getLength()}`);
+ console.log(`Updating KGRegion model - Before: startBeat=${oldStartBeat}, length=${coreRegion.getLength()}`);
console.log(`Bar numbers - old: ${oldBarNumber}, new: ${finalBarNumber}`);
}
-
+
+ // Clamp audio region resize to audio file boundaries
+ let newClipStartOffsetSeconds: number | undefined;
+ if (coreRegion instanceof KGAudioRegion) {
+ const bpm = KGCore.instance().getCurrentProject().getBpm();
+ const secondsPerBeat = 60 / bpm;
+ const clipOffset = coreRegion.getClipStartOffsetSeconds();
+ const audioDuration = coreRegion.getAudioDurationSeconds();
+ const snap = KGMainContentState.instance().isSnappingEnabled();
+
+ // Left edge changed — calculate new clip offset
+ if (clampedBarNumber !== oldBarNumber) {
+ const newStartBeat = (clampedBarNumber - 1) * beatsPerBar;
+ const beatDelta = newStartBeat - oldStartBeat;
+ const secondsDelta = beatDelta * secondsPerBeat;
+ const unclampedClipOffset = clipOffset + secondsDelta;
+
+ if (unclampedClipOffset < 0) {
+ // Dragged past audio start — snap to earliest allowed position
+ const maxLeftExtensionBeats = clipOffset / secondsPerBeat;
+ const minStartBeat = oldStartBeat - maxLeftExtensionBeats;
+ clampedBarNumber = snap
+ ? Math.ceil(minStartBeat / beatsPerBar) + 1
+ : (minStartBeat / beatsPerBar) + 1;
+ const oldEndBarNumber = oldBarNumber + (coreRegion.getLength() / beatsPerBar);
+ clampedLength = oldEndBarNumber - clampedBarNumber;
+ newClipStartOffsetSeconds = 0;
+ } else {
+ newClipStartOffsetSeconds = Math.min(unclampedClipOffset, audioDuration);
+ }
+ }
+
+ // Right edge — clamp length so it doesn't exceed remaining audio
+ const effectiveClipOffset = newClipStartOffsetSeconds ?? clipOffset;
+ const maxDurationSeconds = audioDuration - effectiveClipOffset;
+ const maxLengthBars = (maxDurationSeconds / secondsPerBeat) / beatsPerBar;
+ if (clampedLength > maxLengthBars) {
+ clampedLength = snap ? Math.floor(maxLengthBars) : maxLengthBars;
+ if (clampedLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
+ clampedLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
+ }
+ }
+ }
+
+ const newStartBeat = (clampedBarNumber - 1) * beatsPerBar;
+ const newLengthInBeats = clampedLength * beatsPerBar;
+
// Use command pattern to update the region position and length (note adjustments handled inside command)
try {
const command = ResizeRegionCommand.fromBarCoordinates(
regionId,
- finalBarNumber,
- finalLength,
- timeSignature
+ clampedBarNumber,
+ clampedLength,
+ timeSignature,
+ newClipStartOffsetSeconds
);
-
+
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed ResizeRegionCommand: region ${regionId} resized using command pattern`);
-
+
// Verify the command worked
const updatedRegion = track.getRegions().find(r => r.getId() === regionId);
console.log(`Verified region in track: ${updatedRegion ? 'found' : 'not found'}, startBeat=${updatedRegion?.getStartFromBeat()}, length=${updatedRegion?.getLength()}`);
@@ -201,15 +255,15 @@ const TrackGridPanel: React.FC = ({
console.error('Error resizing region:', error);
return;
}
- }
-
- // Update the region in the parent component with expected model values
- if (onRegionUpdated) {
- onRegionUpdated(
- regionId,
- { barNumber: finalBarNumber, length: finalLength },
- { startBeat: newStartBeat, length: newLengthInBeats }
- );
+
+ // Update the region in the parent component with expected model values
+ if (onRegionUpdated) {
+ onRegionUpdated(
+ regionId,
+ { barNumber: clampedBarNumber, length: clampedLength },
+ { startBeat: newStartBeat, length: newLengthInBeats }
+ );
+ }
}
};
@@ -238,7 +292,19 @@ const TrackGridPanel: React.FC = ({
// Get the target track
const targetTrack = tracks[finalTrackIndex];
if (!targetTrack) return;
-
+
+ // Block cross-type region moves (MIDI <-> Audio)
+ const sourceTrack = tracks.find(t => {
+ return t.getRegions().some(r => r.getId() === regionId);
+ });
+ if (sourceTrack && sourceTrack.getType() !== targetTrack.getType()) {
+ // Snap back — don't execute the move
+ if (DEBUG_MODE.TRACK_GRID_PANEL) {
+ console.log(`Blocked cross-type move: ${sourceTrack.getType()} region cannot move to ${targetTrack.getType()} track`);
+ }
+ return;
+ }
+
// Use command pattern to move the region
try {
const command = MoveRegionCommand.fromBarCoordinates(
@@ -251,9 +317,22 @@ const TrackGridPanel: React.FC = ({
KGCore.instance().executeCommand(command);
+ // Copy audio buffer to target track if this is a cross-track audio region move
+ if (sourceTrack && targetTrack && sourceTrack.getId() !== targetTrack.getId()) {
+ const coreRegion = targetTrack.getRegions().find(r => r.getId() === regionId);
+ if (coreRegion?.getCurrentType() === 'KGAudioRegion') {
+ const audioRegion = coreRegion as unknown as KGAudioRegion;
+ KGAudioInterface.instance().copyAudioBufferBetweenTracks(
+ sourceTrack.getId().toString(),
+ targetTrack.getId().toString(),
+ audioRegion.getAudioFileId()
+ );
+ }
+ }
+
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Executed MoveRegionCommand: region ${regionId} moved using command pattern`);
-
+
// Verify the command worked
const movedRegion = command.getTargetRegion();
console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`);
diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx
index c9feb2d..f6d3e8d 100644
--- a/src/components/track/TrackInfoItem.tsx
+++ b/src/components/track/TrackInfoItem.tsx
@@ -1,10 +1,13 @@
import React, { useState, useRef, useEffect } from 'react';
import { KGTrack } from '../../core/track/KGTrack';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
+import { KGAudioTrack } from '../../core/track/KGAudioTrack';
import { useProjectStore } from '../../stores/projectStore';
import { TbPiano } from 'react-icons/tb';
import { TbSettings } from 'react-icons/tb';
+import { FaFileAudio } from 'react-icons/fa';
import KGDropdown from '../common/KGDropdown';
+import FileImportModal from '../common/FileImportModal';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
@@ -34,7 +37,7 @@ const TrackInfoItem: React.FC = ({
onDrop,
onDragEnd
}) => {
- const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, tracks: allTracks } = useProjectStore();
+ const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
const isSelected = selectedTrackId === track.getId().toString();
// Inline instrument dropdown removed; use InstrumentSelection panel instead
@@ -48,6 +51,7 @@ const TrackInfoItem: React.FC = ({
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
+ const [showAudioImportModal, setShowAudioImportModal] = useState(false);
const settingsDropdownRef = useRef(null);
const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume());
@@ -195,6 +199,8 @@ const TrackInfoItem: React.FC = ({
// Inline instrument change removed; handled by InstrumentSelection panel
+ const isAudioTrack = track instanceof KGAudioTrack;
+
// Handle piano button click
const handlePianoButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -204,6 +210,19 @@ const TrackInfoItem: React.FC = ({
toggleInstrumentSelectionForTrack();
};
+ // Handle audio import button click
+ const handleAudioImportClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setSelectedTrack(track.getId().toString());
+ setShowAudioImportModal(true);
+ };
+
+ // Handle audio file import
+ const handleAudioFileImport = (file: File) => {
+ importAudioToTrack(track.getId().toString(), file);
+ setShowAudioImportModal(false);
+ };
+
// Handle settings button click
const handleSettingsButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -253,12 +272,21 @@ const TrackInfoItem: React.FC = ({
-
+ {isAudioTrack ? (
+
+ ) : (
+
+ )}
= ({
S
M
-
-
-
+ {isAudioTrack ? (
+
+
+
+ ) : (
+
+
+
+ )}
@@ -319,8 +353,18 @@ const TrackInfoItem: React.FC = ({
+ {isAudioTrack && (
+
setShowAudioImportModal(false)}
+ onFileImport={handleAudioFileImport}
+ acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
+ title="Import Audio"
+ description="Drag and drop your audio file here"
+ />
+ )}
);
};
-export default TrackInfoItem;
\ No newline at end of file
+export default TrackInfoItem;
\ No newline at end of file
diff --git a/src/constants/uiConstants.ts b/src/constants/uiConstants.ts
index 6eb9ded..32796dc 100644
--- a/src/constants/uiConstants.ts
+++ b/src/constants/uiConstants.ts
@@ -18,6 +18,7 @@ export const DEBUG_MODE = {
// Toolbar related constants
export const TOOLBAR_CONSTANTS = {
+ BASE_BAR_WIDTH: 40, // matches --track-grid-bar-width default in variables.css
};
// Region related constants
diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts
index 7d01253..6fc188a 100644
--- a/src/core/KGDebugger.ts
+++ b/src/core/KGDebugger.ts
@@ -30,6 +30,7 @@ export class KGDebugger {
'testToolCall(jsonInput)',
'inputChatBox(content, interval?)',
'opfs(command)',
+ 'startShell()',
]);
}
@@ -345,7 +346,8 @@ export class KGDebugger {
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
- console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl)");
+ console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl, rm)");
+ console.log(" startShell() - Start interactive OPFS shell (prompt-based loop)");
console.log(" help() - Show this help");
console.log("");
console.log("💡 Usage tips:");
@@ -449,6 +451,57 @@ export class KGDebugger {
}
}
+ /**
+ * Start an interactive OPFS shell using browser prompt() dialogs.
+ * Each prompt shows the current working directory. Type commands and click OK.
+ * Click Cancel or type 'exit'/'quit' to end the session.
+ */
+ public async startShell(): Promise
{
+ console.log('📟 OPFS Interactive Shell');
+ console.log(' Commands: pwd, ls, cd , cat , dl , rm ');
+ console.log(' Type "exit" or click Cancel to quit.\n');
+
+ let lastOutput = '';
+
+ while (true) {
+ const cwd = '/' + this.opfsCwd.join('/');
+ const promptText = lastOutput
+ ? `${lastOutput}\n\nopfs:${cwd}$ `
+ : `opfs:${cwd}$ `;
+ const input = prompt(promptText);
+
+ if (input === null) break;
+
+ const trimmed = input.trim();
+ if (trimmed === '') continue;
+ if (trimmed === 'exit' || trimmed === 'quit') break;
+
+ // Capture console output during command execution
+ const captured: string[] = [];
+ const origLog = console.log;
+ const origError = console.error;
+ console.log = (...args: unknown[]) => {
+ origLog(...args);
+ captured.push(args.map(String).join(' '));
+ };
+ console.error = (...args: unknown[]) => {
+ origError(...args);
+ captured.push(args.map(String).join(' '));
+ };
+
+ try {
+ await this.opfs(trimmed);
+ } finally {
+ console.log = origLog;
+ console.error = origError;
+ }
+
+ lastOutput = captured.join('\n');
+ }
+
+ console.log('📟 Shell exited.');
+ }
+
// --- OPFS Shell ---
/** Current working directory path segments (relative to OPFS root) */
@@ -463,6 +516,7 @@ export class KGDebugger {
* cd — change directory (supports .., /, relative, and quoted paths)
* cat — print file contents
* dl — download a file to your local machine
+ * rm — remove a file or directory (recursive)
*
* Usage in console:
* await KGDebugger.opfs('pwd')
@@ -498,9 +552,13 @@ export class KGDebugger {
await this.opfsDl(arg)
break
+ case 'rm':
+ await this.opfsRm(arg)
+ break
+
default:
console.log(`opfs: command not found: ${cmd}`)
- console.log('Available commands: pwd, ls, cd , cat , dl ')
+ console.log('Available commands: pwd, ls, cd , cat , dl , rm ')
}
} catch (error) {
console.error(`opfs: ${error}`)
@@ -660,4 +718,19 @@ export class KGDebugger {
console.error(`opfs: dl: ${fileName}: No such file`)
}
}
+
+ private async opfsRm(name: string): Promise {
+ if (!name) {
+ console.error('opfs: rm: missing file or directory name')
+ return
+ }
+
+ const dir = await this.opfsResolveCwd()
+ try {
+ await dir.removeEntry(name, { recursive: true })
+ console.log(`removed: ${name}`)
+ } catch {
+ console.error(`opfs: rm: ${name}: No such file or directory`)
+ }
+ }
}
\ No newline at end of file
diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts
index 233fc2f..83f8151 100644
--- a/src/core/KGProject.ts
+++ b/src/core/KGProject.ts
@@ -1,6 +1,7 @@
import { Expose, Type } from 'class-transformer';
import { KGTrack } from './track/KGTrack';
import { KGMidiTrack } from './track/KGMidiTrack';
+import { KGAudioTrack } from './track/KGAudioTrack';
import { type TimeSignature, WithDefault } from '../types/projectTypes';
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
@@ -43,11 +44,15 @@ export class KGProject {
@WithDefault([0, 0])
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
+ @Expose()
+ @WithDefault(1)
+ private barWidthMultiplier: number = 1;
+
@Expose()
@WithDefault(0)
private projectStructureVersion: number = 0;
- public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 3;
+ public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
@Expose()
@Type(() => KGTrack, {
@@ -56,13 +61,14 @@ export class KGProject {
subTypes: [
{ value: KGTrack, name: 'KGTrack' },
{ value: KGMidiTrack, name: 'KGMidiTrack' },
+ { value: KGAudioTrack, name: 'KGAudioTrack' },
],
},
})
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", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], 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], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) {
this.name = name;
this.maxBars = maxBars;
this.currentBars = currentBars;
@@ -72,6 +78,7 @@ export class KGProject {
this.selectedMode = selectedMode;
this.isLooping = isLooping;
this.loopingRange = loopingRange;
+ this.barWidthMultiplier = barWidthMultiplier;
this.tracks = tracks;
this.projectStructureVersion = projectStructureVersion;
}
@@ -165,5 +172,13 @@ export class KGProject {
public setLoopingRange(loopingRange: [number, number]): void {
this.loopingRange = loopingRange;
}
+
+ public getBarWidthMultiplier(): number {
+ return this.barWidthMultiplier;
+ }
+
+ public setBarWidthMultiplier(barWidthMultiplier: number): void {
+ this.barWidthMultiplier = barWidthMultiplier;
+ }
}
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index b775e98..17ccbbb 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -5,7 +5,9 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import { pitchToNoteNameString } from '../../util/midiUtil';
import * as Tone from 'tone';
import { KGAudioBus } from './KGAudioBus';
+import { KGAudioPlayerBus } from './KGAudioPlayerBus';
import type { InstrumentType } from '../track/KGMidiTrack';
+import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager';
@@ -15,6 +17,13 @@ import { ConfigManager } from '../config/ConfigManager';
* Abstracts audio engine implementation (Tone.js) for potential future replacement
*/
export class KGAudioInterface {
+ /**
+ * Avoid scheduling audio-region resume callbacks exactly on the current
+ * transport boundary. Tone.Transport can miss those edge-triggered events,
+ * which leaves the playhead moving but the resumed clip silent.
+ */
+ private static readonly AUDIO_RESUME_SAFETY_OFFSET_SECONDS = 0.005;
+
// Private static instance for singleton pattern
private static _instance: KGAudioInterface | null = null;
@@ -25,6 +34,9 @@ export class KGAudioInterface {
// Track management - now using KGAudioBus
private trackAudioBuses: Map = new Map();
+ // Audio player buses for audio/wav tracks
+ private trackAudioPlayerBuses: Map = new Map();
+
// Playback state
private isPlaying: boolean = false;
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
@@ -125,6 +137,12 @@ export class KGAudioInterface {
audioBus.dispose();
});
this.trackAudioBuses.clear();
+
+ // Dispose of all audio player buses
+ this.trackAudioPlayerBuses.forEach(playerBus => {
+ playerBus.dispose();
+ });
+ this.trackAudioPlayerBuses.clear();
// Dispose master gain
if (this.masterGain) {
@@ -217,6 +235,113 @@ export class KGAudioInterface {
}
}
+ // ===== AUDIO PLAYER BUS MANAGEMENT (for audio/wav tracks) =====
+
+ /**
+ * Create an audio player bus for an audio track
+ */
+ public async createTrackAudioPlayerBus(
+ trackId: string,
+ volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME
+ ): Promise {
+ // Remove existing player bus if it exists
+ await this.removeTrackAudioPlayerBus(trackId);
+
+ try {
+ console.log(`Creating audio player bus for track ${trackId}`);
+ const playerBus = await KGAudioPlayerBus.create(volume);
+
+ if (this.masterGain) {
+ playerBus.connect(this.masterGain);
+ }
+
+ this.trackAudioPlayerBuses.set(trackId, playerBus);
+ console.log(`Created audio player bus for track ${trackId}`);
+ } catch (error) {
+ console.error(`Failed to create audio player bus for track ${trackId}:`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * Remove an audio player bus
+ */
+ public async removeTrackAudioPlayerBus(trackId: string): Promise {
+ try {
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ if (playerBus) {
+ playerBus.dispose();
+ this.trackAudioPlayerBuses.delete(trackId);
+ console.log(`Removed audio player bus for track ${trackId}`);
+ }
+ } catch (error) {
+ console.error(`Error removing audio player bus for track ${trackId}:`, error);
+ }
+ }
+
+ /**
+ * Load an audio buffer into a track's player bus
+ */
+ public loadAudioBufferForTrack(
+ trackId: string,
+ audioFileId: string,
+ buffer: Tone.ToneAudioBuffer
+ ): void {
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ if (playerBus) {
+ playerBus.loadBuffer(audioFileId, buffer);
+ } else {
+ console.warn(`No audio player bus found for track ${trackId}`);
+ }
+ }
+
+ /**
+ * Get the raw AudioBuffer for waveform rendering
+ */
+ public getAudioBuffer(trackId: string, audioFileId: string): AudioBuffer | undefined {
+ // Try the specified track first
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ const buffer = playerBus?.getAudioBuffer(audioFileId);
+ if (buffer) return buffer;
+
+ // Fallback: search all player buses (handles region moved to a different track)
+ for (const bus of this.trackAudioPlayerBuses.values()) {
+ const found = bus.getAudioBuffer(audioFileId);
+ if (found) return found;
+ }
+ return undefined;
+ }
+
+ /**
+ * Copy an audio buffer from one track's player bus to another.
+ * Used when an audio region is moved between tracks.
+ */
+ public copyAudioBufferBetweenTracks(
+ sourceTrackId: string,
+ targetTrackId: string,
+ audioFileId: string
+ ): void {
+ // Use the raw AudioBuffer approach: get from any bus, wrap in ToneAudioBuffer, load into target
+ const rawBuffer = this.getAudioBuffer(sourceTrackId, audioFileId);
+ if (!rawBuffer) return;
+
+ const targetBus = this.trackAudioPlayerBuses.get(targetTrackId);
+ if (!targetBus) return;
+
+ if (!targetBus.hasBuffer(audioFileId)) {
+ const newToneBuffer = new Tone.ToneAudioBuffer(rawBuffer);
+ targetBus.loadBuffer(audioFileId, newToneBuffer);
+
+ // Remove the buffer from the source bus to free memory
+ const sBus = this.trackAudioPlayerBuses.get(sourceTrackId);
+ if (sBus && sBus !== targetBus) {
+ sBus.removeBuffer(audioFileId);
+ }
+
+ console.log(`Moved audio buffer ${audioFileId} from track ${sourceTrackId} to track ${targetTrackId}`);
+ }
+ }
+
/**
* Change instrument type for a track (replaces setTrackInstrument)
*/
@@ -263,6 +388,9 @@ export class KGAudioInterface {
// Set project BPM and time signature FIRST (this affects timing calculations)
Tone.Transport.bpm.value = project.getBpm();
const timeSignature = project.getTimeSignature();
+ const secondsPerBeat = 60 / project.getBpm();
+ const resumeSafetyOffsetBeats =
+ KGAudioInterface.AUDIO_RESUME_SAFETY_OFFSET_SECONDS / secondsPerBeat;
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
@@ -309,13 +437,14 @@ export class KGAudioInterface {
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
+ // Schedule MIDI track events
if (audioBus && track.getType() === 'MIDI') {
track.getRegions().forEach(region => {
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
if (region.getCurrentType() === 'KGMidiRegion') {
const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
-
+
// Get notes from region (assuming it has a getNotes method)
if (midiRegion.getNotes) {
midiRegion.getNotes().forEach((note: KGMidiNote) => {
@@ -334,7 +463,7 @@ export class KGAudioInterface {
if (noteStartBeat < startPosition) {
return; // Skip notes that would have already finished before playback starts
}
-
+
// Convert beats to Tone.js time format for scheduling
const noteStartTime = this.beatsToToneTime(noteStartBeat);
const noteDuration = this.beatsToToneTime(noteDurationBeats);
@@ -355,13 +484,130 @@ export class KGAudioInterface {
audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
}
}, noteStartTime);
-
+
this.scheduledEvents.add(eventId);
});
}
}
});
}
+
+ // Schedule audio/wav track events
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ if (playerBus && track.getType() === 'Wave') {
+ track.getRegions().forEach(region => {
+ if (region.getCurrentType() === 'KGAudioRegion') {
+ const audioRegion = region as unknown as KGAudioRegion;
+ const regionStartBeat = region.getStartFromBeat();
+ const regionEndBeat = regionStartBeat + region.getLength();
+
+ // Skip regions outside loop range when looping
+ if (regionStartBeat >= scheduleEndBeat || regionEndBeat <= scheduleStartBeat) {
+ return;
+ }
+
+ // Clip offset: where playback starts within the audio file
+ const clipStartOffsetSeconds = audioRegion.getClipStartOffsetSeconds();
+ const audioDurationSeconds = audioRegion.getAudioDurationSeconds();
+
+ // Skip regions that start before playback start position
+ if (regionStartBeat < startPosition) {
+ // Region starts before playhead — calculate offset into the audio file
+ const offsetBeats = startPosition - regionStartBeat;
+ const offsetSeconds = offsetBeats * secondsPerBeat;
+ const remainingBeats = regionEndBeat - startPosition;
+ const remainingSeconds = remainingBeats * secondsPerBeat;
+ const audioFileId = audioRegion.getAudioFileId();
+
+ // Cap duration at loop boundary to prevent overlap on loop re-trigger
+ let effectiveRemainingSeconds = remainingSeconds;
+ if (isLooping) {
+ const maxDurationBeats = scheduleEndBeat - startPosition;
+ const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
+ effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
+ }
+
+ // Cap at available audio after clip offset
+ effectiveRemainingSeconds = Math.min(
+ effectiveRemainingSeconds,
+ audioDurationSeconds - clipStartOffsetSeconds - offsetSeconds
+ );
+
+ if (effectiveRemainingSeconds > 0 && playerBus.hasBuffer(audioFileId)) {
+ // Resume slightly after the current transport boundary and
+ // compensate the source offset/duration. Scheduling exactly
+ // at the playhead here can intermittently miss the callback,
+ // which leaves the playhead moving but the clip silent.
+ const safeResumeBeat = Math.min(
+ startPosition + resumeSafetyOffsetBeats,
+ regionEndBeat
+ );
+ const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
+ const adjustedOffsetSeconds = clipStartOffsetSeconds + offsetSeconds + extraOffsetSeconds;
+ const adjustedRemainingSeconds = Math.max(
+ 0,
+ effectiveRemainingSeconds - extraOffsetSeconds
+ );
+
+ if (adjustedRemainingSeconds <= 0) {
+ return;
+ }
+
+ const regionStartTime = this.beatsToToneTime(safeResumeBeat);
+
+ const eventId = Tone.Transport.schedule((time) => {
+ const hasSoloedTracks = this.hasSoloedTracks();
+ if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
+ playerBus.schedulePlayback(
+ time + playbackDelay,
+ audioFileId,
+ adjustedOffsetSeconds,
+ adjustedRemainingSeconds
+ );
+ }
+ }, regionStartTime);
+ this.scheduledEvents.add(eventId);
+ }
+ return;
+ }
+
+ const audioFileId = audioRegion.getAudioFileId();
+ // Effective duration: region length in seconds, capped at available audio after clip offset
+ const regionLengthSeconds = region.getLength() * secondsPerBeat;
+ let effectiveDurationSeconds = Math.min(
+ regionLengthSeconds,
+ audioDurationSeconds - clipStartOffsetSeconds
+ );
+
+ if (!playerBus.hasBuffer(audioFileId)) {
+ console.warn(`No audio buffer loaded for ${audioFileId}`);
+ return;
+ }
+
+ // Cap duration at loop boundary to prevent overlap on loop re-trigger
+ if (isLooping) {
+ const maxDurationBeats = scheduleEndBeat - regionStartBeat;
+ const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
+ effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
+ }
+
+ const regionStartTime = this.beatsToToneTime(regionStartBeat);
+
+ console.log(
+ `Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, clipOffset: ${clipStartOffsetSeconds}s, duration: ${effectiveDurationSeconds}s`
+ );
+
+ const eventId = Tone.Transport.schedule((time) => {
+ const hasSoloedTracks = this.hasSoloedTracks();
+ if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
+ playerBus.schedulePlayback(time + playbackDelay, audioFileId, clipStartOffsetSeconds, effectiveDurationSeconds);
+ }
+ }, regionStartTime);
+
+ this.scheduledEvents.add(eventId);
+ }
+ });
+ }
});
console.log(`Prepared playback from position ${startPosition} with ${this.scheduledEvents.size} events`);
@@ -404,7 +650,12 @@ export class KGAudioInterface {
this.trackAudioBuses.forEach(audioBus => {
audioBus.releaseAll();
});
-
+
+ // Stop all audio player buses
+ this.trackAudioPlayerBuses.forEach(playerBus => {
+ playerBus.stopAll();
+ });
+
this.isPlaying = false;
console.log('Audio playback stopped');
@@ -560,10 +811,14 @@ export class KGAudioInterface {
public setTrackVolume(trackId: string, volume: number): void {
try {
const audioBus = this.trackAudioBuses.get(trackId);
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
if (audioBus) {
audioBus.setVolume(volume);
- console.log(`Set track ${trackId} volume to ${volume}`);
- } else {
+ }
+ if (playerBus) {
+ playerBus.setVolume(volume);
+ }
+ if (!audioBus && !playerBus) {
console.warn(`No audio bus found for track ${trackId}`);
}
} catch (error) {
@@ -577,14 +832,18 @@ export class KGAudioInterface {
public setTrackMute(trackId: string, muted: boolean): void {
try {
const audioBus = this.trackAudioBuses.get(trackId);
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
if (audioBus) {
audioBus.setMuted(muted);
- console.log(`Set track ${trackId} mute to ${muted}`);
- // Recompute effective volumes across all buses (solo logic)
- this.updateAllEffectiveVolumes();
- } else {
+ }
+ if (playerBus) {
+ playerBus.setMuted(muted);
+ }
+ if (!audioBus && !playerBus) {
console.warn(`No audio bus found for track ${trackId}`);
}
+ // Recompute effective volumes across all buses (solo logic)
+ this.updateAllEffectiveVolumes();
} catch (error) {
console.error(`Error setting track ${trackId} mute:`, error);
}
@@ -596,14 +855,18 @@ export class KGAudioInterface {
public setTrackSolo(trackId: string, solo: boolean): void {
try {
const audioBus = this.trackAudioBuses.get(trackId);
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
if (audioBus) {
audioBus.setSolo(solo);
- console.log(`Set track ${trackId} solo to ${solo}`);
- // Recompute effective volumes across all buses (solo logic)
- this.updateAllEffectiveVolumes();
- } else {
+ }
+ if (playerBus) {
+ playerBus.setSolo(solo);
+ }
+ if (!audioBus && !playerBus) {
console.warn(`No audio bus found for track ${trackId}`);
}
+ // Recompute effective volumes across all buses (solo logic)
+ this.updateAllEffectiveVolumes();
} catch (error) {
console.error(`Error setting track ${trackId} solo:`, error);
}
@@ -646,17 +909,20 @@ export class KGAudioInterface {
public getTrackVolume(trackId: string): number {
const audioBus = this.trackAudioBuses.get(trackId);
- return audioBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ return audioBus?.getVolume() ?? playerBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
}
public getTrackMuted(trackId: string): boolean {
const audioBus = this.trackAudioBuses.get(trackId);
- return audioBus?.getMuted() ?? false;
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ return audioBus?.getMuted() ?? playerBus?.getMuted() ?? false;
}
public getTrackSolo(trackId: string): boolean {
const audioBus = this.trackAudioBuses.get(trackId);
- return audioBus?.getSolo() ?? false;
+ const playerBus = this.trackAudioPlayerBuses.get(trackId);
+ return audioBus?.getSolo() ?? playerBus?.getSolo() ?? false;
}
public getMasterVolume(): number {
@@ -692,7 +958,8 @@ export class KGAudioInterface {
* Check if any tracks are currently soloed
*/
private hasSoloedTracks(): boolean {
- return Array.from(this.trackAudioBuses.values()).some(audioBus => audioBus.getSolo());
+ return Array.from(this.trackAudioBuses.values()).some(bus => bus.getSolo()) ||
+ Array.from(this.trackAudioPlayerBuses.values()).some(bus => bus.getSolo());
}
/**
@@ -702,6 +969,7 @@ export class KGAudioInterface {
try {
const hasSoloedTracks = this.hasSoloedTracks();
this.trackAudioBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
+ this.trackAudioPlayerBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
} catch (error) {
console.error('Error updating effective volumes:', error);
}
@@ -784,4 +1052,4 @@ export class KGAudioInterface {
public getLookaheadTime(): number {
return Tone.getContext().lookAhead;
}
-}
\ No newline at end of file
+}
diff --git a/src/core/audio-interface/KGAudioPlayerBus.ts b/src/core/audio-interface/KGAudioPlayerBus.ts
new file mode 100644
index 0000000..f4f97ca
--- /dev/null
+++ b/src/core/audio-interface/KGAudioPlayerBus.ts
@@ -0,0 +1,294 @@
+import * as Tone from 'tone';
+import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
+
+/**
+ * KGAudioPlayerBus - Represents an audio playback bus for a track.
+ * Parallel to KGAudioBus but wraps a Tone.Gain node + a buffer cache
+ * instead of a Tone.Sampler. Supports multiple audio files per track
+ * via ToneBufferSource instances created on-demand during playback.
+ */
+export class KGAudioPlayerBus {
+ // Gain node for volume/mute routing
+ private gainNode: Tone.Gain;
+
+ // Cached audio buffers keyed by audioFileId
+ private audioBuffers: Map = new Map();
+
+ // Active buffer sources for cleanup on stop
+ private activeSources: Tone.ToneBufferSource[] = [];
+
+ // Audio properties
+ private volume: number;
+ private muted: boolean;
+ private solo: boolean;
+
+ /**
+ * Private constructor - use KGAudioPlayerBus.create() instead
+ */
+ private constructor(
+ gainNode: Tone.Gain,
+ volume: number,
+ muted: boolean,
+ solo: boolean
+ ) {
+ this.gainNode = gainNode;
+ this.volume = volume;
+ this.muted = muted;
+ this.solo = solo;
+
+ this.updateGainVolume();
+
+ console.log(`KGAudioPlayerBus created - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
+ }
+
+ /**
+ * Create a new KGAudioPlayerBus instance (async factory method)
+ */
+ public static async create(
+ volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
+ muted: boolean = false,
+ solo: boolean = false
+ ): Promise {
+ try {
+ const gainNode = new Tone.Gain(1);
+ const bus = new KGAudioPlayerBus(gainNode, volume, muted, solo);
+ console.log('KGAudioPlayerBus created successfully');
+ return bus;
+ } catch (error) {
+ console.error('Failed to create KGAudioPlayerBus:', error);
+ throw error;
+ }
+ }
+
+ // ===== BUFFER MANAGEMENT =====
+
+ /**
+ * Load/cache an audio buffer for a given audioFileId
+ */
+ public loadBuffer(audioFileId: string, buffer: Tone.ToneAudioBuffer): void {
+ this.audioBuffers.set(audioFileId, buffer);
+ console.log(`Loaded audio buffer for ${audioFileId}, duration: ${buffer.duration}s`);
+ }
+
+ /**
+ * Check if a buffer is cached for the given audioFileId
+ */
+ public hasBuffer(audioFileId: string): boolean {
+ return this.audioBuffers.has(audioFileId);
+ }
+
+ /**
+ * Remove and dispose a cached buffer
+ */
+ public removeBuffer(audioFileId: string): void {
+ const buffer = this.audioBuffers.get(audioFileId);
+ if (buffer) {
+ buffer.dispose();
+ this.audioBuffers.delete(audioFileId);
+ console.log(`Removed audio buffer for ${audioFileId}`);
+ }
+ }
+
+ /**
+ * Get the raw AudioBuffer for waveform rendering
+ */
+ public getAudioBuffer(audioFileId: string): AudioBuffer | undefined {
+ const toneBuffer = this.audioBuffers.get(audioFileId);
+ return toneBuffer?.get() as AudioBuffer | undefined;
+ }
+
+ // ===== PLAYBACK =====
+
+ /**
+ * Schedule playback of an audio buffer at a specific time.
+ * Creates a new ToneBufferSource each call (stateless, safe for loop re-triggering).
+ */
+ public schedulePlayback(
+ time: number,
+ audioFileId: string,
+ offset: number = 0,
+ duration?: number
+ ): void {
+ const buffer = this.audioBuffers.get(audioFileId);
+ if (!buffer) {
+ console.error(`No audio buffer found for ${audioFileId}`);
+ return;
+ }
+
+ try {
+ const source = new Tone.ToneBufferSource(buffer);
+ source.connect(this.gainNode);
+
+ // Ensure start time is not in the past — ToneBufferSource silently fails
+ // if the time has already passed, unlike Sampler which handles it gracefully.
+ const safeTime = Math.max(time, Tone.now());
+
+ if (duration !== undefined) {
+ source.start(safeTime, offset, duration);
+ } else {
+ source.start(safeTime, offset);
+ }
+
+ this.activeSources.push(source);
+
+ // Clean up source reference after it finishes
+ source.onended = () => {
+ const idx = this.activeSources.indexOf(source);
+ if (idx !== -1) {
+ this.activeSources.splice(idx, 1);
+ }
+ source.dispose();
+ };
+ } catch (error) {
+ console.error(`Error scheduling playback for ${audioFileId}:`, error);
+ }
+ }
+
+ /**
+ * Stop all active audio sources
+ */
+ public stopAll(): void {
+ try {
+ for (const source of this.activeSources) {
+ try {
+ source.stop();
+ } catch {
+ // Source may have already stopped
+ }
+ // Don't dispose here — the onended callback handles disposal.
+ // Double-dispose corrupts Tone.js internal state.
+ }
+ this.activeSources = [];
+ } catch (error) {
+ console.error('Error stopping all audio sources:', error);
+ }
+ }
+
+ // ===== AUDIO PROPERTIES =====
+
+ public setVolume(volume: number): void {
+ this.volume = volume;
+ this.updateGainVolume();
+ console.log(`Set audio player bus volume to ${volume}`);
+ }
+
+ public getVolume(): number {
+ return this.volume;
+ }
+
+ public setMuted(muted: boolean): void {
+ this.muted = muted;
+ this.updateGainVolume();
+ console.log(`Set audio player bus muted to ${muted}`);
+ }
+
+ public getMuted(): boolean {
+ return this.muted;
+ }
+
+ public setSolo(solo: boolean): void {
+ this.solo = solo;
+ console.log(`Set audio player bus solo to ${solo}`);
+ }
+
+ public getSolo(): boolean {
+ return this.solo;
+ }
+
+ /**
+ * Apply effective volume considering both mute and solo context
+ */
+ public applyEffectiveVolume(hasSoloedTracks: boolean): void {
+ try {
+ let effectiveVolume = this.volume;
+ if (this.muted) {
+ effectiveVolume = 0;
+ } else if (hasSoloedTracks && !this.solo) {
+ effectiveVolume = 0;
+ }
+ const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
+ this.gainNode.gain.value = Math.pow(10, volumeDb / 20);
+ } catch (error) {
+ console.error('Error applying effective volume for audio player bus:', error);
+ }
+ }
+
+ /**
+ * Check if this audio bus should play considering solo logic
+ */
+ public shouldPlayWithSolo(hasSoloedTracks: boolean): boolean {
+ if (this.muted) {
+ return false;
+ }
+ if (hasSoloedTracks) {
+ return this.solo;
+ }
+ return true;
+ }
+
+ // ===== AUDIO ROUTING =====
+
+ public connect(destination: Tone.InputNode): void {
+ try {
+ this.gainNode.connect(destination);
+ console.log('Connected audio player bus to destination');
+ } catch (error) {
+ console.error('Error connecting audio player bus:', error);
+ }
+ }
+
+ public disconnect(): void {
+ try {
+ this.gainNode.disconnect();
+ console.log('Disconnected audio player bus');
+ } catch (error) {
+ console.error('Error disconnecting audio player bus:', error);
+ }
+ }
+
+ // ===== RESOURCE MANAGEMENT =====
+
+ public dispose(): void {
+ try {
+ this.stopAll();
+ for (const buffer of this.audioBuffers.values()) {
+ buffer.dispose();
+ }
+ this.audioBuffers.clear();
+ this.gainNode.dispose();
+ console.log('Disposed KGAudioPlayerBus');
+ } catch (error) {
+ console.error('Error disposing KGAudioPlayerBus:', error);
+ }
+ }
+
+ // ===== PRIVATE UTILITY =====
+
+ private updateGainVolume(): void {
+ try {
+ const effectiveVolume = this.muted ? 0 : this.volume;
+ // Convert linear volume to gain value
+ this.gainNode.gain.value = effectiveVolume;
+ } catch (error) {
+ console.error('Error updating gain volume:', error);
+ }
+ }
+
+ // ===== DEBUGGING =====
+
+ public getState(): {
+ volume: number;
+ muted: boolean;
+ solo: boolean;
+ bufferCount: number;
+ activeSourceCount: number;
+ } {
+ return {
+ volume: this.volume,
+ muted: this.muted,
+ solo: this.solo,
+ bufferCount: this.audioBuffers.size,
+ activeSourceCount: this.activeSources.length,
+ };
+ }
+}
diff --git a/src/core/audio-interface/KGOfflineRenderer.test.ts b/src/core/audio-interface/KGOfflineRenderer.test.ts
new file mode 100644
index 0000000..fa8b39d
--- /dev/null
+++ b/src/core/audio-interface/KGOfflineRenderer.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect } from 'vitest';
+import { encodeWav } from './KGOfflineRenderer';
+
+/**
+ * Create a minimal AudioBuffer-like object for testing.
+ * In the jsdom test environment, AudioBuffer is not available,
+ * so we create a plain object that matches the interface used by encodeWav.
+ */
+function createMockAudioBuffer(
+ options: { numberOfChannels: number; sampleRate: number; length: number },
+ channelData?: Float32Array[]
+): AudioBuffer {
+ const channels = channelData ?? Array.from({ length: options.numberOfChannels }, () =>
+ new Float32Array(options.length)
+ );
+ return {
+ numberOfChannels: options.numberOfChannels,
+ sampleRate: options.sampleRate,
+ length: options.length,
+ duration: options.length / options.sampleRate,
+ getChannelData: (ch: number) => channels[ch],
+ } as unknown as AudioBuffer;
+}
+
+describe('encodeWav', () => {
+ it('should produce a valid RIFF/WAV header for stereo 44100Hz', () => {
+ const audioBuffer = createMockAudioBuffer({
+ numberOfChannels: 2,
+ sampleRate: 44100,
+ length: 100,
+ });
+
+ const result = encodeWav(audioBuffer);
+ const view = new DataView(result);
+
+ // RIFF header
+ expect(String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3))).toBe('RIFF');
+ expect(String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11))).toBe('WAVE');
+
+ // File size field: total - 8
+ const dataSize = 100 * 2 * 2; // 100 frames * 2 channels * 2 bytes
+ expect(view.getUint32(4, true)).toBe(44 + dataSize - 8);
+
+ // fmt sub-chunk
+ expect(String.fromCharCode(view.getUint8(12), view.getUint8(13), view.getUint8(14), view.getUint8(15))).toBe('fmt ');
+ expect(view.getUint32(16, true)).toBe(16); // PCM sub-chunk size
+ expect(view.getUint16(20, true)).toBe(1); // audio format = PCM
+ expect(view.getUint16(22, true)).toBe(2); // channels
+ expect(view.getUint32(24, true)).toBe(44100); // sample rate
+ expect(view.getUint32(28, true)).toBe(44100 * 4); // byte rate (sampleRate * blockAlign)
+ expect(view.getUint16(32, true)).toBe(4); // block align (channels * bytesPerSample)
+ expect(view.getUint16(34, true)).toBe(16); // bits per sample
+
+ // data sub-chunk
+ expect(String.fromCharCode(view.getUint8(36), view.getUint8(37), view.getUint8(38), view.getUint8(39))).toBe('data');
+ expect(view.getUint32(40, true)).toBe(dataSize);
+ });
+
+ it('should produce correct header for mono 48000Hz', () => {
+ const audioBuffer = createMockAudioBuffer({
+ numberOfChannels: 1,
+ sampleRate: 48000,
+ length: 50,
+ });
+
+ const result = encodeWav(audioBuffer);
+ const view = new DataView(result);
+
+ expect(view.getUint16(22, true)).toBe(1); // 1 channel
+ expect(view.getUint32(24, true)).toBe(48000); // sample rate
+ expect(view.getUint16(32, true)).toBe(2); // block align (1 * 2)
+ expect(view.getUint32(28, true)).toBe(48000 * 2); // byte rate
+ expect(view.getUint32(40, true)).toBe(50 * 2); // data size
+ });
+
+ it('should have correct total buffer size', () => {
+ const audioBuffer = createMockAudioBuffer({
+ numberOfChannels: 2,
+ sampleRate: 44100,
+ length: 200,
+ });
+
+ const result = encodeWav(audioBuffer);
+ // 44 header + 200 frames * 2 channels * 2 bytes
+ expect(result.byteLength).toBe(44 + 200 * 2 * 2);
+ });
+
+ it('should correctly convert float32 samples to int16', () => {
+ const left = new Float32Array([0, 1, -1, 0.5, -0.5]);
+ const right = new Float32Array([0, -1, 1, -0.5, 0.5]);
+
+ const audioBuffer = createMockAudioBuffer(
+ { numberOfChannels: 2, sampleRate: 44100, length: 5 },
+ [left, right]
+ );
+
+ const result = encodeWav(audioBuffer);
+ const view = new DataView(result);
+
+ // Sample data starts at offset 44, interleaved L/R as int16 LE
+ // Sample 0: L=0 → 0, R=0 → 0
+ expect(view.getInt16(44, true)).toBe(0);
+ expect(view.getInt16(46, true)).toBe(0);
+
+ // Sample 1: L=1.0 → 32767, R=-1.0 → -32768
+ expect(view.getInt16(48, true)).toBe(32767);
+ expect(view.getInt16(50, true)).toBe(-32768);
+
+ // Sample 2: L=-1.0 → -32768, R=1.0 → 32767
+ expect(view.getInt16(52, true)).toBe(-32768);
+ expect(view.getInt16(54, true)).toBe(32767);
+
+ // Sample 3: L=0.5 → ~16383, R=-0.5 → ~-16384
+ expect(view.getInt16(56, true)).toBeCloseTo(16383, -1);
+ expect(view.getInt16(58, true)).toBeCloseTo(-16384, -1);
+ });
+
+ it('should clamp values outside [-1, 1]', () => {
+ const data = new Float32Array([1.5, -1.5]);
+
+ const audioBuffer = createMockAudioBuffer(
+ { numberOfChannels: 1, sampleRate: 44100, length: 2 },
+ [data]
+ );
+
+ const result = encodeWav(audioBuffer);
+ const view = new DataView(result);
+
+ // 1.5 clamped to 1.0 → 32767
+ expect(view.getInt16(44, true)).toBe(32767);
+ // -1.5 clamped to -1.0 → -32768
+ expect(view.getInt16(46, true)).toBe(-32768);
+ });
+
+ it('should handle zero-length audio', () => {
+ const audioBuffer = createMockAudioBuffer({
+ numberOfChannels: 2,
+ sampleRate: 44100,
+ length: 0,
+ });
+
+ const result = encodeWav(audioBuffer);
+ expect(result.byteLength).toBe(44); // header only
+ const view = new DataView(result);
+ expect(view.getUint32(40, true)).toBe(0); // data size = 0
+ });
+});
diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts
new file mode 100644
index 0000000..6f5af03
--- /dev/null
+++ b/src/core/audio-interface/KGOfflineRenderer.ts
@@ -0,0 +1,537 @@
+import * as Tone from 'tone';
+import type { KGProject } from '../KGProject';
+import type { KGMidiNote } from '../midi/KGMidiNote';
+import type { KGAudioRegion } from '../region/KGAudioRegion';
+import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
+import { pitchToNoteNameString } from '../../util/midiUtil';
+import { KGToneBuffersPool } from './KGToneBuffersPool';
+import { KGToneSamplerFactory } from './KGToneSamplerFactory';
+import { KGAudioInterface } from './KGAudioInterface';
+import { Mp3Encoder } from '@breezystack/lamejs';
+
+export interface RenderOptions {
+ sampleRate?: number; // default 44100
+ channels?: number; // default 2 (stereo)
+ tailSeconds?: number; // extra seconds after last note for release/reverb (default 2)
+ mp3Kbps?: number; // MP3 bitrate in kbps (default 192)
+}
+
+export interface RenderingEvent {
+ type: 'start' | 'end' | 'error';
+ message?: string;
+}
+
+/**
+ * KGOfflineRenderer - Singleton for bouncing/rendering a project to audio.
+ * Uses Tone.Offline() to render faster-than-realtime via OfflineAudioContext.
+ */
+export class KGOfflineRenderer {
+ private static _instance: KGOfflineRenderer | null = null;
+
+ private _isRendering = false;
+ private renderingListeners: Array<(_evt: RenderingEvent) => void> = [];
+
+ private constructor() {
+ console.log('KGOfflineRenderer initialized');
+ }
+
+ public static instance(): KGOfflineRenderer {
+ if (!KGOfflineRenderer._instance) {
+ KGOfflineRenderer._instance = new KGOfflineRenderer();
+ }
+ return KGOfflineRenderer._instance;
+ }
+
+ // ===== EVENT LISTENERS =====
+
+ public addRenderingListener(listener: (_evt: RenderingEvent) => void): void {
+ this.renderingListeners.push(listener);
+ }
+
+ public removeRenderingListener(listener: (_evt: RenderingEvent) => void): void {
+ this.renderingListeners = this.renderingListeners.filter(l => l !== listener);
+ }
+
+ private emitRenderingEvent(evt: RenderingEvent): void {
+ for (const listener of this.renderingListeners) {
+ try { listener(evt); } catch { /* swallow */ }
+ }
+ }
+
+ public get isRendering(): boolean {
+ return this._isRendering;
+ }
+
+ // ===== PUBLIC API =====
+
+ /**
+ * Render the project to a ToneAudioBuffer using Tone.Offline.
+ */
+ public async renderToBuffer(project: KGProject, options?: RenderOptions): Promise {
+ const sampleRate = options?.sampleRate ?? 44100;
+ const channels = options?.channels ?? 2;
+ const tailSeconds = options?.tailSeconds ?? 2;
+
+ // Calculate render duration in seconds
+ const bpm = project.getBpm();
+ const secondsPerBeat = 60 / bpm;
+ const timeSignature = project.getTimeSignature();
+ const beatsPerBar = timeSignature.numerator;
+
+ let renderStartBeat = 0;
+ let renderEndBeat: number;
+
+ const isLooping = project.getIsLooping();
+ // Looping range is determined up-front; non-looping range is computed
+ // after collecting track data (see below).
+ if (isLooping) {
+ const [startBar, endBarOriginal] = project.getLoopingRange();
+ const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal;
+ renderStartBeat = startBar * beatsPerBar;
+ renderEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
+ } else {
+ // Placeholder — will be refined after track data collection
+ renderEndBeat = project.getMaxBars() * beatsPerBar;
+ }
+
+ // Determine solo state from the live audio buses
+ const audioInterface = KGAudioInterface.instance();
+
+ // Collect track info we'll need inside the offline callback
+ const tracks = project.getTracks();
+
+ // Pre-collect all the data we need before entering the offline context
+ const midiTrackData: Array<{
+ trackId: string;
+ instrumentName: string;
+ volume: number;
+ muted: boolean;
+ solo: boolean;
+ regions: Array<{
+ startBeat: number;
+ notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>;
+ }>;
+ }> = [];
+
+ const audioTrackData: Array<{
+ trackId: string;
+ volume: number;
+ muted: boolean;
+ solo: boolean;
+ regions: Array<{
+ startBeat: number;
+ lengthBeats: number;
+ audioFileId: string;
+ clipStartOffsetSeconds: number;
+ audioDurationSeconds: number;
+ rawBuffer: AudioBuffer;
+ }>;
+ }> = [];
+
+ let hasSoloedTracks = false;
+
+ for (const track of tracks) {
+ const trackId = track.getId().toString();
+
+ if (track.getType() === 'MIDI') {
+ const midiTrack = track as unknown as { getInstrument: () => string };
+ const instrumentName = String(midiTrack.getInstrument());
+
+ // Get live bus state for volume/mute/solo via public getters
+ const volume = audioInterface.getTrackVolume(trackId);
+ const muted = audioInterface.getTrackMuted(trackId);
+ const solo = audioInterface.getTrackSolo(trackId);
+ if (solo) hasSoloedTracks = true;
+
+ const regions: typeof midiTrackData[0]['regions'] = [];
+ for (const region of track.getRegions()) {
+ if (region.getCurrentType() === 'KGMidiRegion') {
+ const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
+ if (midiRegion.getNotes) {
+ const notes = midiRegion.getNotes().map(note => ({
+ startBeat: note.getStartBeat() + region.getStartFromBeat(),
+ endBeat: note.getEndBeat() + region.getStartFromBeat(),
+ durationBeats: note.getEndBeat() - note.getStartBeat(),
+ pitch: note.getPitch(),
+ velocity: note.getVelocity(),
+ }));
+ regions.push({ startBeat: region.getStartFromBeat(), notes });
+ }
+ }
+ }
+
+ midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions });
+ } else if (track.getType() === 'Wave') {
+ const volume = audioInterface.getTrackVolume(trackId);
+ const muted = audioInterface.getTrackMuted(trackId);
+ const solo = audioInterface.getTrackSolo(trackId);
+ if (solo) hasSoloedTracks = true;
+
+ const regions: typeof audioTrackData[0]['regions'] = [];
+ for (const region of track.getRegions()) {
+ if (region.getCurrentType() === 'KGAudioRegion') {
+ const audioRegion = region as unknown as KGAudioRegion;
+ const audioFileId = audioRegion.getAudioFileId();
+ const rawBuffer = audioInterface.getAudioBuffer(trackId, audioFileId);
+ if (rawBuffer) {
+ regions.push({
+ startBeat: region.getStartFromBeat(),
+ lengthBeats: region.getLength(),
+ audioFileId,
+ clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
+ audioDurationSeconds: audioRegion.getAudioDurationSeconds(),
+ rawBuffer,
+ });
+ }
+ }
+ }
+
+ audioTrackData.push({ trackId, volume, muted, solo, regions });
+ }
+ }
+
+ // For non-looping mode, tighten the render range to the actual content
+ if (!isLooping) {
+ let contentStart = Infinity;
+ let contentEnd = 0;
+
+ for (const t of midiTrackData) {
+ for (const r of t.regions) {
+ for (const n of r.notes) {
+ if (n.startBeat < contentStart) contentStart = n.startBeat;
+ if (n.endBeat > contentEnd) contentEnd = n.endBeat;
+ }
+ }
+ }
+ for (const t of audioTrackData) {
+ for (const r of t.regions) {
+ if (r.startBeat < contentStart) contentStart = r.startBeat;
+ const regionEnd = r.startBeat + r.lengthBeats;
+ if (regionEnd > contentEnd) contentEnd = regionEnd;
+ }
+ }
+
+ if (contentEnd > 0) {
+ renderStartBeat = contentStart;
+ renderEndBeat = contentEnd;
+ }
+ // else: no content found, keep the full project range as fallback
+ }
+
+ const durationSeconds = (renderEndBeat - renderStartBeat) * secondsPerBeat + tailSeconds;
+
+ console.log(`Offline render: ${durationSeconds}s (beats ${renderStartBeat}-${renderEndBeat}), ${sampleRate}Hz, ${channels}ch`);
+
+ // Run offline render
+ const buffer = await Tone.Offline(async (context) => {
+ // Master gain routed to offline destination
+ const masterGain = new Tone.Gain(1).toDestination();
+
+ // Set BPM and time signature on offline transport
+ context.transport.bpm.value = bpm;
+ context.transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
+
+ // ---- Create MIDI track samplers ----
+ const samplerPromises: Promise[] = [];
+
+ for (const trackInfo of midiTrackData) {
+ if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
+
+ const promise = (async () => {
+ try {
+ // Get cached buffers from pool
+ const audioBuffers = await KGToneBuffersPool.instance().getToneAudioBuffers(trackInfo.instrumentName);
+ const pitchRange = FLUIDR3_INSTRUMENT_MAP[trackInfo.instrumentName]?.pitchRange || [21, 108];
+ const urlMap = KGToneSamplerFactory.instance().convertBuffersToUrls(audioBuffers, pitchRange);
+
+ // Create sampler inside offline context
+ const sampler = await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error(`Offline sampler timeout: ${trackInfo.instrumentName}`)), 30000);
+ const s = new Tone.Sampler({
+ urls: urlMap,
+ onload: () => { clearTimeout(timeout); resolve(s); },
+ onerror: (err) => { clearTimeout(timeout); reject(err); },
+ });
+ });
+
+ // Apply volume
+ const volumeDb = trackInfo.volume > 0 ? 20 * Math.log10(trackInfo.volume) : -Infinity;
+ sampler.volume.value = volumeDb;
+ sampler.connect(masterGain);
+
+ // Schedule all notes for this track
+ for (const regionInfo of trackInfo.regions) {
+ for (const note of regionInfo.notes) {
+ // Skip notes outside render range
+ if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
+
+ const offsetBeat = note.startBeat - renderStartBeat;
+ const noteStartTime = offsetBeat * secondsPerBeat;
+ const noteDuration = note.durationBeats * secondsPerBeat;
+ const noteName = pitchToNoteNameString(note.pitch);
+ const velocity = note.velocity / 127;
+
+ context.transport.schedule((time) => {
+ sampler.triggerAttackRelease(noteName, noteDuration, time, velocity);
+ }, noteStartTime);
+ }
+ }
+ } catch (error) {
+ console.error(`Offline render: failed to create sampler for ${trackInfo.instrumentName}:`, error);
+ }
+ })();
+
+ samplerPromises.push(promise);
+ }
+
+ // ---- Create audio track gain nodes and schedule regions ----
+ for (const trackInfo of audioTrackData) {
+ if (!shouldPlay(trackInfo, hasSoloedTracks)) continue;
+
+ const trackGain = new Tone.Gain(trackInfo.volume);
+ trackGain.connect(masterGain);
+
+ for (const regionInfo of trackInfo.regions) {
+ const regionStartBeat = regionInfo.startBeat;
+ const regionEndBeat = regionStartBeat + regionInfo.lengthBeats;
+
+ // Skip regions outside render range
+ if (regionStartBeat >= renderEndBeat || regionEndBeat <= renderStartBeat) continue;
+
+ const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds;
+ const audioDurationSeconds = regionInfo.audioDurationSeconds;
+ const regionLengthSeconds = regionInfo.lengthBeats * secondsPerBeat;
+ const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds);
+
+ if (effectiveDurationSeconds <= 0) continue;
+
+ const offsetBeat = regionStartBeat - renderStartBeat;
+ const regionStartTime = Math.max(0, offsetBeat * secondsPerBeat);
+
+ // Create buffer source NOW while the offline context is still active.
+ // Schedule callbacks fire during rendering after Tone.js restores the
+ // main context, so creating nodes there would bind them to the wrong context.
+ const toneBuffer = new Tone.ToneAudioBuffer(regionInfo.rawBuffer);
+ const source = new Tone.ToneBufferSource(toneBuffer);
+ source.connect(trackGain);
+
+ context.transport.schedule((time) => {
+ source.start(time, clipStartOffsetSeconds, effectiveDurationSeconds);
+ }, regionStartTime);
+ }
+ }
+
+ // Wait for all samplers to load
+ await Promise.all(samplerPromises);
+
+ // Start offline transport
+ context.transport.start(0);
+ }, durationSeconds, channels, sampleRate);
+
+ console.log(`Offline render complete: ${buffer.duration}s, ${buffer.numberOfChannels}ch`);
+ return buffer;
+ }
+
+ /**
+ * Render the project and download as a WAV file.
+ */
+ public async bounceToWav(project: KGProject, fileName?: string, options?: RenderOptions): Promise {
+ if (this._isRendering) {
+ console.warn('Already rendering, ignoring bounce request');
+ return;
+ }
+
+ this._isRendering = true;
+ this.emitRenderingEvent({ type: 'start', message: 'Bouncing to WAV...' });
+
+ try {
+ const toneBuffer = await this.renderToBuffer(project, options);
+ const audioBuffer = toneBuffer.get() as AudioBuffer;
+ const wavData = encodeWav(audioBuffer);
+
+ // Trigger download
+ const blob = new Blob([wavData], { type: 'audio/wav' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${fileName ?? 'bounce'}.wav`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ this.emitRenderingEvent({ type: 'end', message: 'Bounce complete' });
+ console.log('WAV bounce complete');
+ } catch (error) {
+ console.error('Bounce to WAV failed:', error);
+ this.emitRenderingEvent({ type: 'error', message: String(error) });
+ throw error;
+ } finally {
+ this._isRendering = false;
+ }
+ }
+ /**
+ * Render the project and download as an MP3 file.
+ */
+ public async bounceToMp3(project: KGProject, fileName?: string, options?: RenderOptions): Promise {
+ if (this._isRendering) {
+ console.warn('Already rendering, ignoring bounce request');
+ return;
+ }
+
+ this._isRendering = true;
+ this.emitRenderingEvent({ type: 'start', message: 'Bouncing to MP3...' });
+
+ try {
+ const toneBuffer = await this.renderToBuffer(project, options);
+ const audioBuffer = toneBuffer.get() as AudioBuffer;
+ const mp3Data = encodeMp3(audioBuffer, options?.mp3Kbps ?? 192);
+
+ // Trigger download
+ const blob = new Blob(mp3Data, { type: 'audio/mp3' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${fileName ?? 'bounce'}.mp3`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ this.emitRenderingEvent({ type: 'end', message: 'Bounce complete' });
+ console.log('MP3 bounce complete');
+ } catch (error) {
+ console.error('Bounce to MP3 failed:', error);
+ this.emitRenderingEvent({ type: 'error', message: String(error) });
+ throw error;
+ } finally {
+ this._isRendering = false;
+ }
+ }
+}
+
+// ===== HELPERS =====
+
+function shouldPlay(trackInfo: { muted: boolean; solo: boolean }, hasSoloedTracks: boolean): boolean {
+ if (trackInfo.muted) return false;
+ if (hasSoloedTracks) return trackInfo.solo;
+ return true;
+}
+
+// ===== WAV ENCODER =====
+
+/**
+ * Encode an AudioBuffer as a 16-bit PCM WAV file.
+ * Returns the complete WAV file as an ArrayBuffer.
+ */
+export function encodeWav(audioBuffer: AudioBuffer): ArrayBuffer {
+ const numChannels = audioBuffer.numberOfChannels;
+ const sampleRate = audioBuffer.sampleRate;
+ const numFrames = audioBuffer.length;
+ const bitsPerSample = 16;
+ const bytesPerSample = bitsPerSample / 8;
+ const blockAlign = numChannels * bytesPerSample;
+ const dataSize = numFrames * blockAlign;
+ const headerSize = 44;
+ const totalSize = headerSize + dataSize;
+
+ const buffer = new ArrayBuffer(totalSize);
+ const view = new DataView(buffer);
+
+ // Collect channel data
+ const channels: Float32Array[] = [];
+ for (let ch = 0; ch < numChannels; ch++) {
+ channels.push(audioBuffer.getChannelData(ch));
+ }
+
+ // RIFF header
+ writeString(view, 0, 'RIFF');
+ view.setUint32(4, totalSize - 8, true); // file size - 8
+ writeString(view, 8, 'WAVE');
+
+ // fmt sub-chunk
+ writeString(view, 12, 'fmt ');
+ view.setUint32(16, 16, true); // sub-chunk size (16 for PCM)
+ view.setUint16(20, 1, true); // audio format (1 = PCM)
+ view.setUint16(22, numChannels, true);
+ view.setUint32(24, sampleRate, true);
+ view.setUint32(28, sampleRate * blockAlign, true); // byte rate
+ view.setUint16(32, blockAlign, true);
+ view.setUint16(34, bitsPerSample, true);
+
+ // data sub-chunk
+ writeString(view, 36, 'data');
+ view.setUint32(40, dataSize, true);
+
+ // Interleave and convert float32 [-1, 1] to int16
+ let offset = headerSize;
+ for (let i = 0; i < numFrames; i++) {
+ for (let ch = 0; ch < numChannels; ch++) {
+ const sample = channels[ch][i];
+ // Clamp to [-1, 1] then scale to int16 range
+ const clamped = Math.max(-1, Math.min(1, sample));
+ const int16 = clamped < 0 ? clamped * 0x8000 : clamped * 0x7FFF;
+ view.setInt16(offset, int16, true);
+ offset += bytesPerSample;
+ }
+ }
+
+ return buffer;
+}
+
+function writeString(view: DataView, offset: number, str: string): void {
+ for (let i = 0; i < str.length; i++) {
+ view.setUint8(offset + i, str.charCodeAt(i));
+ }
+}
+
+// ===== MP3 ENCODER =====
+
+/**
+ * Convert float32 sample to Int16.
+ */
+function floatToInt16(sample: number): number {
+ const clamped = Math.max(-1, Math.min(1, sample));
+ return clamped < 0 ? clamped * 0x8000 : clamped * 0x7FFF;
+}
+
+/**
+ * Encode an AudioBuffer as MP3 using lamejs.
+ * Returns an array of Int8Array chunks (suitable for Blob constructor).
+ */
+export function encodeMp3(audioBuffer: AudioBuffer, kbps: number = 192): Uint8Array[] {
+ const numChannels = audioBuffer.numberOfChannels;
+ const sampleRate = audioBuffer.sampleRate;
+ const numFrames = audioBuffer.length;
+ const encoder = new Mp3Encoder(numChannels, sampleRate, kbps);
+
+ const chunkSize = 1152; // MPEG frame size
+ const mp3Chunks: Uint8Array[] = [];
+
+ const leftFloat = audioBuffer.getChannelData(0);
+ const rightFloat = numChannels > 1 ? audioBuffer.getChannelData(1) : leftFloat;
+
+ for (let i = 0; i < numFrames; i += chunkSize) {
+ const end = Math.min(i + chunkSize, numFrames);
+ const leftChunk = new Int16Array(end - i);
+ const rightChunk = new Int16Array(end - i);
+
+ for (let j = 0; j < leftChunk.length; j++) {
+ leftChunk[j] = floatToInt16(leftFloat[i + j]);
+ rightChunk[j] = floatToInt16(rightFloat[i + j]);
+ }
+
+ const mp3buf = encoder.encodeBuffer(leftChunk, rightChunk);
+ if (mp3buf.length > 0) {
+ mp3Chunks.push(mp3buf);
+ }
+ }
+
+ // Flush remaining data
+ const tail = encoder.flush();
+ if (tail.length > 0) {
+ mp3Chunks.push(tail);
+ }
+
+ return mp3Chunks;
+}
diff --git a/src/core/audio-interface/KGToneSamplerFactory.ts b/src/core/audio-interface/KGToneSamplerFactory.ts
index 944f9c9..676434d 100644
--- a/src/core/audio-interface/KGToneSamplerFactory.ts
+++ b/src/core/audio-interface/KGToneSamplerFactory.ts
@@ -78,7 +78,7 @@ export class KGToneSamplerFactory {
* Convert ToneAudioBuffers to the URL format expected by Tone.Sampler
* This creates a mapping from note names to the actual audio buffers
*/
- private convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } {
+ public convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } {
const urls: { [key: string]: Tone.ToneAudioBuffer } = {};
// Note names in order (using flats instead of sharps where applicable)
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index bb1e8cd..06b73f0 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -8,6 +8,7 @@ export { KGCommandHistory } from './KGCommandHistory';
// Track commands
export { AddTrackCommand } from './track/AddTrackCommand';
+export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
@@ -19,6 +20,7 @@ export { ResizeRegionCommand } from './region/ResizeRegionCommand';
export { MoveRegionCommand } from './region/MoveRegionCommand';
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
+export { ImportAudioCommand } from './region/ImportAudioCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
diff --git a/src/core/commands/region/ImportAudioCommand.ts b/src/core/commands/region/ImportAudioCommand.ts
new file mode 100644
index 0000000..27eb620
--- /dev/null
+++ b/src/core/commands/region/ImportAudioCommand.ts
@@ -0,0 +1,104 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { KGAudioRegion } from '../../region/KGAudioRegion';
+
+/**
+ * Command to import an audio file into an audio track as a region.
+ * Async work (file decode, OPFS storage, buffer loading) must be done
+ * before execute() is called — this command is synchronous.
+ */
+export class ImportAudioCommand extends KGCommand {
+ private trackId: number;
+ private trackIndex: number;
+ private audioFileId: string;
+ private audioFileName: string;
+ private audioDurationSeconds: number;
+ private insertBeat: number;
+ private durationInBeats: number;
+ private previousMaxBars: number;
+ private newMaxBars: number;
+ private regionId: string;
+ private createdRegion: KGAudioRegion | null = null;
+
+ constructor(
+ trackId: number,
+ trackIndex: number,
+ audioFileId: string,
+ audioFileName: string,
+ audioDurationSeconds: number,
+ insertBeat: number,
+ durationInBeats: number,
+ previousMaxBars: number,
+ newMaxBars: number
+ ) {
+ super();
+ this.trackId = trackId;
+ this.trackIndex = trackIndex;
+ this.audioFileId = audioFileId;
+ this.audioFileName = audioFileName;
+ this.audioDurationSeconds = audioDurationSeconds;
+ this.insertBeat = insertBeat;
+ this.durationInBeats = durationInBeats;
+ this.previousMaxBars = previousMaxBars;
+ this.newMaxBars = newMaxBars;
+ this.regionId = `audio_region_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
+ }
+
+ execute(): void {
+ const core = KGCore.instance();
+ const currentProject = core.getCurrentProject();
+
+ // Create the audio region
+ this.createdRegion = new KGAudioRegion(
+ this.regionId,
+ this.trackId.toString(),
+ this.trackIndex,
+ this.audioFileName,
+ this.insertBeat,
+ this.durationInBeats,
+ this.audioFileId,
+ this.audioFileName,
+ this.audioDurationSeconds
+ );
+
+ // Add region to the track
+ const track = currentProject.getTracks().find(t => t.getId() === this.trackId);
+ if (!track) {
+ throw new Error(`Track ${this.trackId} not found`);
+ }
+ track.addRegion(this.createdRegion);
+
+ // Expand maxBars if needed
+ if (this.newMaxBars > this.previousMaxBars) {
+ currentProject.setMaxBars(this.newMaxBars);
+ }
+
+ console.log(`Imported audio "${this.audioFileName}" at beat ${this.insertBeat}, duration: ${this.durationInBeats} beats`);
+ }
+
+ undo(): void {
+ const core = KGCore.instance();
+ const currentProject = core.getCurrentProject();
+
+ // Remove the region from the track
+ const track = currentProject.getTracks().find(t => t.getId() === this.trackId);
+ if (track) {
+ track.removeRegion(this.regionId);
+ }
+
+ // Revert maxBars if we expanded it
+ if (this.newMaxBars > this.previousMaxBars) {
+ currentProject.setMaxBars(this.previousMaxBars);
+ }
+
+ console.log(`Undid audio import "${this.audioFileName}"`);
+ }
+
+ getDescription(): string {
+ return `Import audio "${this.audioFileName}"`;
+ }
+
+ public getCreatedRegion(): KGAudioRegion | null {
+ return this.createdRegion;
+ }
+}
diff --git a/src/core/commands/region/ResizeRegionCommand.ts b/src/core/commands/region/ResizeRegionCommand.ts
index ae9bf83..ccd02d4 100644
--- a/src/core/commands/region/ResizeRegionCommand.ts
+++ b/src/core/commands/region/ResizeRegionCommand.ts
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
+import { KGAudioRegion } from '../../region/KGAudioRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
/**
@@ -15,7 +16,7 @@ export class ResizeRegionCommand extends KGCommand {
private originalStartFromBeat: number = 0;
private originalLength: number = 0;
private targetRegion: KGRegion | null = null;
-
+
// Store note adjustments for undo
private noteAdjustments: Array<{
noteId: string;
@@ -23,11 +24,16 @@ export class ResizeRegionCommand extends KGCommand {
originalEndBeat: number;
}> = [];
- constructor(regionId: string, newStartFromBeat: number, newLength: number) {
+ // Audio region clip offset support
+ private newClipStartOffsetSeconds?: number;
+ private originalClipStartOffsetSeconds: number = 0;
+
+ constructor(regionId: string, newStartFromBeat: number, newLength: number, newClipStartOffsetSeconds?: number) {
super();
this.regionId = regionId;
this.newStartFromBeat = newStartFromBeat;
this.newLength = newLength;
+ this.newClipStartOffsetSeconds = newClipStartOffsetSeconds;
}
execute(): void {
@@ -57,10 +63,10 @@ export class ResizeRegionCommand extends KGCommand {
this.originalStartFromBeat = targetRegion.getStartFromBeat();
this.originalLength = targetRegion.getLength();
- // Handle note adjustments if start position changes (left-edge resize)
+ // Handle note adjustments if start position changes (left-edge resize) for MIDI regions
if (this.newStartFromBeat !== this.originalStartFromBeat && targetRegion instanceof KGMidiRegion) {
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
-
+
// Store original note positions and adjust notes to maintain absolute positions
const notes = targetRegion.getNotes();
notes.forEach(note => {
@@ -70,15 +76,24 @@ export class ResizeRegionCommand extends KGCommand {
originalStartBeat: note.getStartBeat(),
originalEndBeat: note.getEndBeat()
});
-
+
// Adjust note positions to maintain absolute position
note.setStartBeat(note.getStartBeat() - beatOffset);
note.setEndBeat(note.getEndBeat() - beatOffset);
});
-
+
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
}
+ // Handle clip offset for audio regions
+ if (targetRegion instanceof KGAudioRegion) {
+ this.originalClipStartOffsetSeconds = targetRegion.getClipStartOffsetSeconds();
+ if (this.newClipStartOffsetSeconds !== undefined) {
+ targetRegion.setClipStartOffsetSeconds(this.newClipStartOffsetSeconds);
+ console.log(`Updated audio clip offset: ${this.originalClipStartOffsetSeconds} → ${this.newClipStartOffsetSeconds}`);
+ }
+ }
+
// Apply the resize
targetRegion.setStartFromBeat(this.newStartFromBeat);
targetRegion.setLength(this.newLength);
@@ -95,7 +110,7 @@ export class ResizeRegionCommand extends KGCommand {
// Restore note positions if they were adjusted
if (this.noteAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
const notes = this.targetRegion.getNotes();
-
+
// Restore each note to its original position
this.noteAdjustments.forEach(adjustment => {
const note = notes.find(n => n.getId() === adjustment.noteId);
@@ -104,10 +119,16 @@ export class ResizeRegionCommand extends KGCommand {
note.setEndBeat(adjustment.originalEndBeat);
}
});
-
+
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
}
+ // Restore clip offset for audio regions
+ if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
+ this.targetRegion.setClipStartOffsetSeconds(this.originalClipStartOffsetSeconds);
+ console.log(`Restored audio clip offset: ${this.newClipStartOffsetSeconds} → ${this.originalClipStartOffsetSeconds}`);
+ }
+
// Restore original region values
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
this.targetRegion.setLength(this.originalLength);
@@ -184,12 +205,13 @@ export class ResizeRegionCommand extends KGCommand {
regionId: string,
newBarNumber: number,
newLengthInBars: number,
- timeSignature: { numerator: number; denominator: number }
+ timeSignature: { numerator: number; denominator: number },
+ newClipStartOffsetSeconds?: number
): ResizeRegionCommand {
const beatsPerBar = timeSignature.numerator;
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
const newLength = newLengthInBars * beatsPerBar;
-
- return new ResizeRegionCommand(regionId, newStartFromBeat, newLength);
+
+ return new ResizeRegionCommand(regionId, newStartFromBeat, newLength, newClipStartOffsetSeconds);
}
}
\ No newline at end of file
diff --git a/src/core/commands/track/AddAudioTrackCommand.ts b/src/core/commands/track/AddAudioTrackCommand.ts
new file mode 100644
index 0000000..ac9ef10
--- /dev/null
+++ b/src/core/commands/track/AddAudioTrackCommand.ts
@@ -0,0 +1,83 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { KGAudioTrack } from '../../track/KGAudioTrack';
+import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
+import { generateNewTrackName } from '../../../util/miscUtil';
+
+/**
+ * Command to add a new audio track to the project.
+ * Handles both the core model update and audio player bus setup.
+ */
+export class AddAudioTrackCommand extends KGCommand {
+ private trackId: number;
+ private trackName: string;
+ private trackIndex: number;
+ private createdTrack: KGAudioTrack | null = null;
+
+ constructor(trackId?: number, trackName?: string) {
+ super();
+
+ if (trackId === undefined) {
+ const currentProject = KGCore.instance().getCurrentProject();
+ const tracks = currentProject.getTracks();
+ this.trackId = tracks.length > 0
+ ? Math.max(...tracks.map(track => track.getId())) + 1
+ : 1;
+ } else {
+ this.trackId = trackId;
+ }
+
+ this.trackName = trackName || generateNewTrackName();
+ this.trackIndex = 0;
+ }
+
+ execute(): void {
+ const core = KGCore.instance();
+ const currentProject = core.getCurrentProject();
+ const tracks = currentProject.getTracks();
+
+ this.trackIndex = tracks.length;
+
+ this.createdTrack = new KGAudioTrack(this.trackName, this.trackId);
+ this.createdTrack.setTrackIndex(this.trackIndex);
+
+ const updatedTracks = [...tracks, this.createdTrack];
+ currentProject.setTracks(updatedTracks);
+
+ // Create audio player bus for the new track
+ const audioInterface = KGAudioInterface.instance();
+ audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
+
+ console.log(`Added audio track ${this.trackId}`);
+ }
+
+ undo(): void {
+ if (!this.createdTrack) {
+ throw new Error('Cannot undo: no track was created');
+ }
+
+ const core = KGCore.instance();
+ const currentProject = core.getCurrentProject();
+ const tracks = currentProject.getTracks();
+
+ const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
+ currentProject.setTracks(updatedTracks);
+
+ const audioInterface = KGAudioInterface.instance();
+ audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
+
+ console.log(`Removed audio track ${this.trackId}`);
+ }
+
+ getDescription(): string {
+ return `Add audio track "${this.trackName}"`;
+ }
+
+ public getTrackId(): number {
+ return this.trackId;
+ }
+
+ public getCreatedTrack(): KGAudioTrack | null {
+ return this.createdTrack;
+ }
+}
diff --git a/src/core/commands/track/RemoveTrackCommand.ts b/src/core/commands/track/RemoveTrackCommand.ts
index c72db00..d4fc159 100644
--- a/src/core/commands/track/RemoveTrackCommand.ts
+++ b/src/core/commands/track/RemoveTrackCommand.ts
@@ -39,9 +39,10 @@ export class RemoveTrackCommand extends KGCommand {
this.originalInstrument = (trackToRemove as KGMidiTrack).getInstrument();
}
- // Remove audio synth for the track
+ // Remove audio bus for the track (handles both MIDI synth and audio player bus)
const audioInterface = KGAudioInterface.instance();
audioInterface.removeTrackSynth(this.trackId.toString());
+ audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
// Remove the track from the core model
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
@@ -85,11 +86,15 @@ export class RemoveTrackCommand extends KGCommand {
// Update the core model
currentProject.setTracks(updatedTracks);
- // Recreate audio synth for the track
+ // Recreate audio bus for the track
const audioInterface = KGAudioInterface.instance();
- audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
-
- console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`);
+ if (this.removedTrack.getCurrentType() === 'KGAudioTrack') {
+ audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
+ } else {
+ audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
+ }
+
+ console.log(`Restored track ${this.trackId}`);
}
getDescription(): string {
diff --git a/src/core/io/KGAudioFileStorage.ts b/src/core/io/KGAudioFileStorage.ts
new file mode 100644
index 0000000..208e539
--- /dev/null
+++ b/src/core/io/KGAudioFileStorage.ts
@@ -0,0 +1,85 @@
+import { OPFS_CONSTANTS } from '../../constants/coreConstants';
+
+/**
+ * KGAudioFileStorage — Utility for storing and loading audio files
+ * in the OPFS media/ directory alongside project data.
+ */
+export class KGAudioFileStorage {
+ /**
+ * Store an audio file in the project's media/ directory.
+ * Creates the project and media directories if they don't exist yet.
+ */
+ public static async storeAudioFile(
+ projectName: string,
+ fileId: string,
+ file: File
+ ): Promise {
+ const mediaDir = await KGAudioFileStorage.getOrCreateMediaDir(projectName);
+ const fileHandle = await mediaDir.getFileHandle(fileId, { create: true });
+ const writable = await fileHandle.createWritable();
+ await writable.write(await file.arrayBuffer());
+ await writable.close();
+ console.log(`Stored audio file ${fileId} (${file.size} bytes) for project "${projectName}"`);
+ }
+
+ /**
+ * Load an audio file as ArrayBuffer from the project's media/ directory.
+ */
+ public static async loadAudioFile(
+ projectName: string,
+ fileId: string
+ ): Promise {
+ const mediaDir = await KGAudioFileStorage.getMediaDir(projectName);
+ const fileHandle = await mediaDir.getFileHandle(fileId);
+ const file = await fileHandle.getFile();
+ return file.arrayBuffer();
+ }
+
+ /**
+ * Delete an audio file from the project's media/ directory.
+ */
+ public static async deleteAudioFile(
+ projectName: string,
+ fileId: string
+ ): Promise {
+ try {
+ const mediaDir = await KGAudioFileStorage.getMediaDir(projectName);
+ await mediaDir.removeEntry(fileId);
+ console.log(`Deleted audio file ${fileId} from project "${projectName}"`);
+ } catch (error) {
+ console.warn(`Failed to delete audio file ${fileId}:`, error);
+ }
+ }
+
+ /**
+ * Generate a unique audio file ID preserving the original file extension.
+ */
+ public static generateAudioFileId(originalFileName: string): string {
+ const ext = originalFileName.split('.').pop()?.toLowerCase() || 'wav';
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).substring(2, 10);
+ return `audio_${timestamp}_${random}.${ext}`;
+ }
+
+ /**
+ * Get the media directory handle for an existing project (read/delete path).
+ * Does NOT create directories — fails if project folder doesn't exist.
+ */
+ private static async getMediaDir(projectName: string): Promise {
+ const root = await navigator.storage.getDirectory();
+ const projectsDir = await root.getDirectoryHandle(OPFS_CONSTANTS.ROOT_DIR);
+ const projectDir = await projectsDir.getDirectoryHandle(projectName);
+ return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
+ }
+
+ /**
+ * Get or create the media directory handle for a project (write path).
+ * Creates project and media directories if they don't exist.
+ */
+ private static async getOrCreateMediaDir(projectName: string): Promise {
+ const root = await navigator.storage.getDirectory();
+ const projectsDir = await root.getDirectoryHandle(OPFS_CONSTANTS.ROOT_DIR, { create: true });
+ const projectDir = await projectsDir.getDirectoryHandle(projectName, { create: true });
+ return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
+ }
+}
diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts
index ed92db8..0ef41f1 100644
--- a/src/core/io/KGProjectStorage.ts
+++ b/src/core/io/KGProjectStorage.ts
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
import { isValidProjectName } from '../../util/projectNameUtil';
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
+import { KGAudioRegion } from '../region/KGAudioRegion';
export class DuplicateEntryError extends Error {
constructor(name: string) {
@@ -12,10 +13,11 @@ export class DuplicateEntryError extends Error {
}
}
-interface ProjectMeta {
+export interface ProjectMeta {
name: string;
createdAt: number;
updatedAt: number;
+ deletedAt?: number | null;
}
/**
@@ -154,6 +156,137 @@ export class KGProjectStorage {
return names.sort();
}
+ /**
+ * List all projects with metadata (name, createdAt, updatedAt).
+ * Reads each project's meta.json without loading the full project data.
+ * @param deleted - if true, return only soft-deleted projects; if false (default), return only non-deleted projects.
+ */
+ public async listWithMeta(deleted: boolean = false): Promise {
+ this.ensureInitialized();
+
+ const results: ProjectMeta[] = [];
+ for await (const entry of this.projectsDirHandle!.values()) {
+ if (entry.kind === 'directory') {
+ try {
+ const dirHandle = await this.projectsDirHandle!.getDirectoryHandle(entry.name);
+ const metaJson = await this.readFile(dirHandle, OPFS_CONSTANTS.METADATA_FILE);
+ const meta = JSON.parse(metaJson) as ProjectMeta;
+ const isDeleted = !!meta.deletedAt;
+ if (isDeleted === deleted) {
+ results.push(meta);
+ }
+ } catch {
+ // Skip folders without a valid meta.json (e.g. temporary unsaved projects)
+ }
+ }
+ }
+ return results.sort((a, b) => b.updatedAt - a.updatedAt);
+ }
+
+ /**
+ * Soft-delete a project by setting deletedAt in its meta.json.
+ */
+ public async softDelete(name: string): Promise {
+ this.ensureInitialized();
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
+ const meta = await this.readMeta(projectDir, name);
+ meta.deletedAt = Date.now();
+ await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
+ }
+
+ /**
+ * Restore a soft-deleted project by removing deletedAt from its meta.json.
+ */
+ public async restore(name: string): Promise {
+ this.ensureInitialized();
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
+ const meta = await this.readMeta(projectDir, name);
+ delete meta.deletedAt;
+ await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
+ }
+
+ /**
+ * Duplicate a project under a new name, copying all files including media.
+ */
+ public async duplicate(sourceName: string, targetName: string): Promise {
+ this.ensureInitialized();
+
+ if (!isValidProjectName(targetName)) {
+ throw new Error(`Invalid project name "${targetName}".`);
+ }
+
+ // Load the source project
+ const project = await this.load(sourceName);
+ if (!project) {
+ throw new Error(`Project "${sourceName}" not found.`);
+ }
+
+ // Save to new location with fresh timestamps
+ project.setName(targetName);
+ await this.save(targetName, project, false);
+
+ // Copy media files
+ await this.copyMediaFiles(sourceName, targetName);
+ }
+
+ /**
+ * Permanently delete all soft-deleted projects older than the given age in milliseconds.
+ */
+ public async purgeDeletedOlderThan(ageMs: number): Promise {
+ this.ensureInitialized();
+ const cutoff = Date.now() - ageMs;
+ const deleted = await this.listWithMeta(true);
+ for (const meta of deleted) {
+ if (meta.deletedAt && meta.deletedAt < cutoff) {
+ await this.delete(meta.name);
+ }
+ }
+ }
+
+ /**
+ * Remove orphan media files that are not referenced by any track in the project.
+ */
+ public async cleanupOrphanMedia(name: string, project: KGProject): Promise {
+ this.ensureInitialized();
+
+ try {
+ const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
+ let mediaDir: FileSystemDirectoryHandle;
+ try {
+ mediaDir = await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
+ } catch {
+ return; // No media directory — nothing to clean up
+ }
+
+ // Collect all referenced audio file IDs from the project
+ const referencedIds = new Set();
+ for (const track of project.getTracks()) {
+ for (const region of track.getRegions()) {
+ if (region instanceof KGAudioRegion) {
+ const fileId = region.getAudioFileId();
+ if (fileId) referencedIds.add(fileId);
+ }
+ }
+ }
+
+ // Iterate media files and remove orphans
+ const orphans: string[] = [];
+ for await (const entry of mediaDir.values()) {
+ if (entry.kind === 'file' && !referencedIds.has(entry.name)) {
+ orphans.push(entry.name);
+ }
+ }
+
+ for (const orphan of orphans) {
+ await mediaDir.removeEntry(orphan);
+ console.log(`Removed orphan media file "${orphan}" from project "${name}"`);
+ }
+ } catch (error) {
+ // Non-critical — log but don't throw
+ console.warn(`Error cleaning up orphan media for project "${name}":`, error);
+ }
+ }
+
/**
* Delete a project and all its files.
*/
@@ -206,10 +339,31 @@ export class KGProjectStorage {
project.setName(newName);
await this.save(newName, project, false);
+ // Copy media files from old to new location
+ await this.copyMediaFiles(oldName, newName);
+
// Delete old location
await this.delete(oldName);
}
+ /**
+ * Save a project under a new name, migrating media files from the old folder.
+ * Used when the user renames the project and saves. Handles the case where the
+ * old folder doesn't exist yet (new project never saved).
+ */
+ public async saveWithRename(oldName: string, newName: string, data: KGProject): Promise {
+ this.ensureInitialized();
+
+ // Save project JSON to the new folder
+ await this.save(newName, data, false);
+
+ // Migrate media files only if the old folder exists
+ if (await this.exists(oldName)) {
+ await this.copyMediaFiles(oldName, newName);
+ await this.delete(oldName);
+ }
+ }
+
/**
* Export a project folder as a zip Blob (.kgstudio bundle).
* Includes project.json, meta.json, and all files in media/.
@@ -374,6 +528,42 @@ export class KGProjectStorage {
return candidate;
}
+ // --- Media migration ---
+
+ /**
+ * Copy all files from projects//media/ to projects//media/.
+ * If the source media directory doesn't exist, returns without error.
+ */
+ private async copyMediaFiles(fromName: string, toName: string): Promise {
+ try {
+ const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName);
+ let fromMedia: FileSystemDirectoryHandle;
+ try {
+ fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
+ } catch {
+ // No media directory in source — nothing to copy
+ return;
+ }
+
+ const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName);
+ const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
+
+ for await (const entry of fromMedia.values()) {
+ if (entry.kind === 'file') {
+ const fileHandle = entry as FileSystemFileHandle;
+ const file = await fileHandle.getFile();
+ const newHandle = await toMedia.getFileHandle(entry.name, { create: true });
+ const writable = await newHandle.createWritable();
+ await writable.write(await file.arrayBuffer());
+ await writable.close();
+ }
+ }
+ } catch (error) {
+ console.error(`Error copying media files from "${fromName}" to "${toName}":`, error);
+ throw error;
+ }
+ }
+
// --- File I/O helpers ---
private async writeFile(
@@ -395,4 +585,16 @@ export class KGProjectStorage {
const file = await fileHandle.getFile();
return file.text();
}
+
+ private async readMeta(
+ projectDir: FileSystemDirectoryHandle,
+ name: string,
+ ): Promise {
+ try {
+ const raw = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
+ return JSON.parse(raw) as ProjectMeta;
+ } catch {
+ return { name, createdAt: 0, updatedAt: 0 };
+ }
+ }
}
diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts
index d0f3bfa..1ab940d 100644
--- a/src/core/project-upgrader/KGProjectUpgrader.ts
+++ b/src/core/project-upgrader/KGProjectUpgrader.ts
@@ -2,6 +2,9 @@ import { KGProject } from '../KGProject';
import { upgradeToV1 } from './upgradeToV1';
import { upgradeToV2 } from './upgradeToV2';
import { upgradeToV3 } from './upgradeToV3';
+import { upgradeToV4 } from './upgradeToV4';
+import { upgradeToV5 } from './upgradeToV5';
+import { upgradeToV6 } from './upgradeToV6';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -33,6 +36,18 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV3(workingProject);
break;
}
+ case 4: {
+ workingProject = upgradeToV4(workingProject);
+ break;
+ }
+ case 5: {
+ workingProject = upgradeToV5(workingProject);
+ break;
+ }
+ case 6: {
+ workingProject = upgradeToV6(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/upgradeToV4.ts b/src/core/project-upgrader/upgradeToV4.ts
new file mode 100644
index 0000000..2e8ef31
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV4.ts
@@ -0,0 +1,17 @@
+import { KGProject } from '../KGProject';
+
+/**
+ * Upgrade a project from structure version 3 to 4.
+ * Adds audio track support. No data migration needed — existing projects have no audio tracks.
+ */
+export function upgradeToV4(project: KGProject): KGProject {
+ try {
+ // No data migration needed for audio track support.
+ // The new KGAudioTrack and KGAudioRegion subtypes are registered in
+ // the class-transformer discriminators and will be deserialized automatically.
+ } finally {
+ project.setProjectStructureVersion(4);
+ }
+
+ return project;
+}
diff --git a/src/core/project-upgrader/upgradeToV5.ts b/src/core/project-upgrader/upgradeToV5.ts
new file mode 100644
index 0000000..49200e1
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV5.ts
@@ -0,0 +1,13 @@
+import { KGProject } from '../KGProject';
+
+export function upgradeToV5(project: KGProject): KGProject {
+ try {
+ const current = project.getBarWidthMultiplier?.();
+ if (current === undefined || current === null) {
+ project.setBarWidthMultiplier(1);
+ }
+ } finally {
+ project.setProjectStructureVersion(5);
+ }
+ return project;
+}
diff --git a/src/core/project-upgrader/upgradeToV6.ts b/src/core/project-upgrader/upgradeToV6.ts
new file mode 100644
index 0000000..0676fe6
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV6.ts
@@ -0,0 +1,21 @@
+import { KGProject } from '../KGProject';
+import { KGAudioRegion } from '../region/KGAudioRegion';
+
+export function upgradeToV6(project: KGProject): KGProject {
+ try {
+ // Ensure all audio regions have clipStartOffsetSeconds initialized
+ for (const track of project.getTracks()) {
+ for (const region of track.getRegions()) {
+ if (region instanceof KGAudioRegion) {
+ const current = region.getClipStartOffsetSeconds?.();
+ if (current === undefined || current === null) {
+ region.setClipStartOffsetSeconds(0);
+ }
+ }
+ }
+ }
+ } finally {
+ project.setProjectStructureVersion(6);
+ }
+ return project;
+}
diff --git a/src/core/region/KGAudioRegion.ts b/src/core/region/KGAudioRegion.ts
new file mode 100644
index 0000000..853ab5a
--- /dev/null
+++ b/src/core/region/KGAudioRegion.ts
@@ -0,0 +1,87 @@
+import { Expose } from 'class-transformer';
+import { KGRegion } from './KGRegion';
+import { WithDefault } from '../../types/projectTypes';
+
+/**
+ * KGAudioRegion - Class representing an audio region in the DAW
+ * Contains a reference to an audio file stored in OPFS and inherits position/length from KGRegion
+ */
+export class KGAudioRegion extends KGRegion {
+ @Expose()
+ protected override __type: string = 'KGAudioRegion';
+
+ @Expose()
+ @WithDefault('')
+ protected audioFileId: string = '';
+
+ @Expose()
+ @WithDefault('')
+ protected audioFileName: string = '';
+
+ @Expose()
+ @WithDefault(0)
+ protected audioDurationSeconds: number = 0;
+
+ @Expose()
+ @WithDefault(0)
+ protected clipStartOffsetSeconds: number = 0;
+
+ constructor(
+ id: string,
+ trackId: string,
+ trackIndex: number,
+ name: string,
+ startFromBeat: number = 0,
+ length: number = 0,
+ audioFileId: string = '',
+ audioFileName: string = '',
+ audioDurationSeconds: number = 0,
+ clipStartOffsetSeconds: number = 0
+ ) {
+ super(id, trackId, trackIndex, name, startFromBeat, length);
+ this.__type = 'KGAudioRegion';
+ this.audioFileId = audioFileId;
+ this.audioFileName = audioFileName;
+ this.audioDurationSeconds = audioDurationSeconds;
+ this.clipStartOffsetSeconds = clipStartOffsetSeconds;
+ }
+
+ // Getters
+ public getAudioFileId(): string {
+ return this.audioFileId;
+ }
+
+ public getAudioFileName(): string {
+ return this.audioFileName;
+ }
+
+ public getAudioDurationSeconds(): number {
+ return this.audioDurationSeconds;
+ }
+
+ // Setters
+ public setAudioFileId(audioFileId: string): void {
+ this.audioFileId = audioFileId;
+ }
+
+ public setAudioFileName(audioFileName: string): void {
+ this.audioFileName = audioFileName;
+ }
+
+ public setAudioDurationSeconds(audioDurationSeconds: number): void {
+ this.audioDurationSeconds = audioDurationSeconds;
+ }
+
+ public getClipStartOffsetSeconds(): number {
+ return this.clipStartOffsetSeconds;
+ }
+
+ public setClipStartOffsetSeconds(clipStartOffsetSeconds: number): void {
+ this.clipStartOffsetSeconds = clipStartOffsetSeconds;
+ }
+
+ // Override getCurrentType to return specific subclass type
+ public override getCurrentType(): string {
+ return 'KGAudioRegion';
+ }
+}
diff --git a/src/core/state/KGMainContentState.ts b/src/core/state/KGMainContentState.ts
index 8350f7f..ca38d8f 100644
--- a/src/core/state/KGMainContentState.ts
+++ b/src/core/state/KGMainContentState.ts
@@ -6,6 +6,7 @@ export class KGMainContentState {
private static _instance: KGMainContentState | null = null;
private activeTool: string = "pointer";
+ private snapping: boolean = true;
private constructor() {
console.log("KGMainContentState initialized");
@@ -26,4 +27,12 @@ export class KGMainContentState {
public setActiveTool(tool: string): void {
this.activeTool = tool;
}
+
+ public isSnappingEnabled(): boolean {
+ return this.snapping;
+ }
+
+ public setSnapping(enabled: boolean): void {
+ this.snapping = enabled;
+ }
}
\ No newline at end of file
diff --git a/src/core/track/KGAudioTrack.ts b/src/core/track/KGAudioTrack.ts
new file mode 100644
index 0000000..5053995
--- /dev/null
+++ b/src/core/track/KGAudioTrack.ts
@@ -0,0 +1,38 @@
+import { Expose, Type } from 'class-transformer';
+import { KGTrack, TrackType } from './KGTrack';
+import { KGRegion } from '../region/KGRegion';
+import { KGAudioRegion } from '../region/KGAudioRegion';
+import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
+
+export class KGAudioTrack extends KGTrack {
+ @Expose()
+ protected override __type: string = 'KGAudioTrack';
+
+ @Expose()
+ @Type(() => KGRegion, {
+ discriminator: {
+ property: '__type',
+ subTypes: [
+ { value: KGRegion, name: 'KGRegion' },
+ { value: KGAudioRegion, name: 'KGAudioRegion' },
+ ],
+ },
+ })
+ protected override regions: KGAudioRegion[] = [];
+
+ constructor(name: string = 'Untitled Audio Track', id: number = 0, volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
+ super(name, id, TrackType.Wave);
+ this.__type = 'KGAudioTrack';
+ this.volume = volume;
+ }
+
+ // Override parent setRegions to enforce KGAudioRegion type
+ public override setRegions(regions: KGAudioRegion[]): void {
+ this.regions = regions;
+ }
+
+ // Override getCurrentType to return specific subclass type
+ public override getCurrentType(): string {
+ return 'KGAudioTrack';
+ }
+}
diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts
index 7b811a7..11bbf3d 100644
--- a/src/core/track/KGTrack.ts
+++ b/src/core/track/KGTrack.ts
@@ -1,6 +1,7 @@
import { Expose, Type } from 'class-transformer';
import { KGRegion } from '../region/KGRegion';
import { KGMidiRegion } from '../region/KGMidiRegion';
+import { KGAudioRegion } from '../region/KGAudioRegion';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { WithDefault } from '../../types/projectTypes';
@@ -42,6 +43,7 @@ export class KGTrack {
subTypes: [
{ value: KGRegion, name: 'KGRegion' },
{ value: KGMidiRegion, name: 'KGMidiRegion' },
+ { value: KGAudioRegion, name: 'KGAudioRegion' },
],
},
})
diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts
index 3ab7ae8..3d0d916 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, toggleLoop, projectName } = useProjectStore();
+ const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -39,7 +39,10 @@ export const useGlobalKeyboardHandler = () => {
// Early return to handle save immediately
try {
- saveProject(projectName, setStatus);
+ saveProject(projectName, savedProjectName, setStatus, (finalName) => {
+ setSavedProjectName(finalName);
+ if (finalName !== projectName) setProjectName(finalName);
+ });
} catch (error) {
console.error('Save failed:', error);
setStatus('Save failed');
@@ -154,7 +157,10 @@ export const useGlobalKeyboardHandler = () => {
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault();
try {
- saveProject(projectName, setStatus);
+ saveProject(projectName, savedProjectName, setStatus, (finalName) => {
+ setSavedProjectName(finalName);
+ if (finalName !== projectName) setProjectName(finalName);
+ });
} catch (error) {
console.error('Save failed:', error);
setStatus('Save failed');
diff --git a/src/hooks/useRegionOperations.ts b/src/hooks/useRegionOperations.ts
index e2e1adc..5bd59e6 100644
--- a/src/hooks/useRegionOperations.ts
+++ b/src/hooks/useRegionOperations.ts
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { DEBUG_MODE } from '../constants';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
+import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGTrack } from '../core/track/KGTrack';
import { KGCore } from '../core/KGCore';
import type { RegionUI } from '../components/interfaces';
@@ -46,9 +47,9 @@ export const useRegionOperations = ({
const deleteSelectedRegions = useCallback(() => {
// Get all selected regions from KGCore
const selectedItems = core.getSelectedItems();
- const selectedRegions = selectedItems.filter(item =>
- item instanceof KGMidiRegion
- ) as KGMidiRegion[];
+ const selectedRegions = selectedItems.filter(item =>
+ item instanceof KGMidiRegion || item instanceof KGAudioRegion
+ );
if (selectedRegions.length === 0) {
if (DEBUG_MODE.MAIN_CONTENT) {
diff --git a/src/main.tsx b/src/main.tsx
index 13b6272..c3caa3c 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -17,8 +17,10 @@ await KGMidiInput.instance().initialize();
// Attach debugger to global window in development mode
if (import.meta.env.DEV) {
- (window as unknown as { KGDebugger: KGDebugger }).KGDebugger = KGDebugger.instance();
- console.log('🔧 KGDebugger attached to window - try: KGDebugger.help()');
+ const dbg = KGDebugger.instance();
+ (window as unknown as { KGDebugger: KGDebugger; sh: () => Promise }).KGDebugger = dbg;
+ (window as unknown as { sh: () => Promise }).sh = () => dbg.startShell();
+ console.log('🔧 KGDebugger attached to window - try: KGDebugger.help() or sh()');
}
// Start audio context on first user interaction
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index 78c7a2a..3175783 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -9,10 +9,15 @@ import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import { KGRegion } from '../core/region/KGRegion';
-import { AddTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand } from '../core/commands';
+import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands';
+import { KGAudioTrack } from '../core/track/KGAudioTrack';
+import { KGAudioRegion } from '../core/region/KGAudioRegion';
+import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
import { ConfigManager } from '../core/config/ConfigManager';
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
import { toggleLoop } from '../util/loopUtil';
+import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
+import * as Tone from 'tone';
/**
* Update CSS custom property for time signature numerator
@@ -30,13 +35,25 @@ function updateMaxBarsCSS(maxBars: number): void {
document.documentElement.style.setProperty('--max-number-of-bars', maxBars.toString());
}
+/**
+ * Update CSS custom property for track grid bar width based on multiplier
+ */
+function updateBarWidthMultiplierCSS(multiplier: number): void {
+ document.documentElement.style.setProperty(
+ '--track-grid-bar-width',
+ `${TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * multiplier}px`
+ );
+}
+
// Define the store state interface
interface ProjectState {
// State
projectName: string;
+ savedProjectName: string; // OPFS folder name where the project is currently saved
tracks: KGTrack[];
currentStatus: string;
maxBars: number;
+ barWidthMultiplier: number;
timeSignature: TimeSignature;
bpm: number;
keySignature: KeySignature;
@@ -63,9 +80,13 @@ interface ProjectState {
showInstrumentSelection: boolean;
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
+ // Audio import modal state
+ showAudioImportModal: boolean;
+ audioImportTargetTrackId: string | null;
+
// Settings state
showSettings: boolean;
-
+
// Undo/redo state
canUndo: boolean;
canRedo: boolean;
@@ -74,7 +95,12 @@ interface ProjectState {
// Actions
setProjectName: (name: string) => void;
+ setSavedProjectName: (name: string) => void;
addTrack: () => Promise;
+ addAudioTrack: () => Promise;
+ importAudioToTrack: (trackId: string, file: File) => Promise;
+ openAudioImportModal: (trackId: string) => void;
+ closeAudioImportModal: () => void;
removeTrack: (id: number) => Promise;
updateTrack: (track: KGTrack) => Promise;
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise;
@@ -83,13 +109,14 @@ interface ProjectState {
setStatus: (status: string) => void;
removeStatus: () => void;
refreshStatus: () => void;
- loadProject: (project: KGProject | null) => Promise;
+ loadProject: (project: KGProject | null, savedName?: string) => Promise;
setPlayheadPosition: (position: number) => void;
startPlaying: () => Promise;
stopPlaying: () => Promise;
toggleLoop: () => void;
setBpm: (bpm: number) => void;
setMaxBars: (maxBars: number) => void;
+ setBarWidthMultiplier: (multiplier: number) => void;
setTimeSignature: (timeSignature: TimeSignature) => void;
setKeySignature: (keySignature: KeySignature) => void;
setSelectedMode: (selectedMode: string) => void;
@@ -143,6 +170,8 @@ export const useProjectStore = create((set, get) => {
updateTimeSignatureCSS(currentProject.getTimeSignature());
// Initialize CSS variable for max bars on store creation
updateMaxBarsCSS(currentProject.getMaxBars());
+ // Initialize CSS variable for bar width multiplier on store creation
+ updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier());
// Get initial ChatBox state from config
const configManager = ConfigManager.instance();
@@ -222,9 +251,11 @@ export const useProjectStore = create((set, get) => {
return {
// Initial state
projectName: currentProject.getName(),
+ savedProjectName: currentProject.getName(),
tracks: currentProject.getTracks() as KGTrack[],
currentStatus: KGCore.instance().getStatus() || 'Unknown',
maxBars: currentProject.getMaxBars(),
+ barWidthMultiplier: currentProject.getBarWidthMultiplier(),
timeSignature: currentProject.getTimeSignature(),
bpm: currentProject.getBpm(),
keySignature: currentProject.getKeySignature(),
@@ -250,6 +281,10 @@ export const useProjectStore = create((set, get) => {
// Initial Instrument Selection panel state
showInstrumentSelection: initialShowInstrumentSelection,
+ // Initial audio import modal state
+ showAudioImportModal: false,
+ audioImportTargetTrackId: null,
+
// Initial Settings state
showSettings: false,
@@ -265,10 +300,10 @@ export const useProjectStore = create((set, get) => {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ name });
KGCore.instance().executeCommand(command);
-
+
// Update the store state
set({ projectName: name });
-
+
console.log(`Set project name to "${name}"`);
} catch (error) {
console.error('Error setting project name:', error);
@@ -276,6 +311,10 @@ export const useProjectStore = create((set, get) => {
}
},
+ setSavedProjectName: (name: string) => {
+ set({ savedProjectName: name });
+ },
+
addTrack: async () => {
try {
// Create and execute the add track command
@@ -300,6 +339,117 @@ export const useProjectStore = create((set, get) => {
}
},
+ addAudioTrack: async () => {
+ try {
+ const command = new AddAudioTrackCommand();
+ KGCore.instance().executeCommand(command);
+
+ const project = KGCore.instance().getCurrentProject();
+ set({ tracks: [...project.getTracks()] as KGTrack[] });
+
+ const newTrackId = command.getTrackId().toString();
+ set({
+ selectedTrackId: newTrackId,
+ showAudioImportModal: true,
+ audioImportTargetTrackId: newTrackId,
+ });
+
+ console.log(`Added audio track ${command.getTrackId()}`);
+ } catch (error) {
+ console.error('Error adding audio track:', error);
+ get().setStatus('Failed to add audio track');
+ }
+ },
+
+ importAudioToTrack: async (trackId: string, file: File) => {
+ try {
+ get().setStatus(`Importing "${file.name}"...`);
+
+ // Decode the audio file to get duration
+ const arrayBuffer = await file.arrayBuffer();
+ const toneBuffer = new Tone.ToneAudioBuffer();
+ await new Promise((resolve, reject) => {
+ toneBuffer.onload = () => resolve();
+ // Set buffer from array buffer
+ const audioContext = Tone.getContext().rawContext as AudioContext;
+ audioContext.decodeAudioData(
+ arrayBuffer.slice(0), // slice to avoid detached buffer
+ (decoded) => {
+ toneBuffer.set(decoded);
+ resolve();
+ },
+ (err) => reject(err)
+ );
+ });
+
+ const audioDurationSeconds = toneBuffer.duration;
+ const { bpm, timeSignature, playheadPosition, maxBars } = get();
+
+ // Calculate duration in beats
+ const durationInBeats = audioDurationSeconds * (bpm / 60);
+
+ // Calculate if we need to expand maxBars
+ const beatsPerBar = timeSignature.numerator;
+ const endBeat = playheadPosition + durationInBeats;
+ const requiredBars = Math.ceil(endBeat / beatsPerBar);
+ const newMaxBars = Math.max(maxBars, requiredBars);
+
+ // Store audio file in OPFS
+ const projectName = get().projectName;
+ const audioFileId = KGAudioFileStorage.generateAudioFileId(file.name);
+ await KGAudioFileStorage.storeAudioFile(projectName, audioFileId, file);
+
+ // Load buffer into the audio player bus
+ const audioInterface = KGAudioInterface.instance();
+ audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
+
+ // Find the track to get trackIndex
+ const project = KGCore.instance().getCurrentProject();
+ const track = project.getTracks().find(t => t.getId().toString() === trackId);
+ if (!track) {
+ throw new Error(`Track ${trackId} not found`);
+ }
+
+ // Execute the import command
+ const command = new ImportAudioCommand(
+ track.getId(),
+ track.getTrackIndex(),
+ audioFileId,
+ file.name,
+ audioDurationSeconds,
+ playheadPosition,
+ durationInBeats,
+ maxBars,
+ newMaxBars
+ );
+ KGCore.instance().executeCommand(command);
+
+ // Update store state
+ const updatedState: Partial = {
+ tracks: [...project.getTracks()] as KGTrack[],
+ };
+ if (newMaxBars > maxBars) {
+ updatedState.maxBars = newMaxBars;
+ updateMaxBarsCSS(newMaxBars);
+ }
+ set(updatedState);
+
+ get().setStatus(`Imported "${file.name}" successfully`);
+ console.log(`Imported audio "${file.name}" to track ${trackId}`);
+ } catch (error) {
+ console.error('Error importing audio:', error);
+ get().setStatus(`Failed to import audio: ${error}`);
+ }
+ },
+
+ openAudioImportModal: (trackId: string) => {
+ set({ showAudioImportModal: true, audioImportTargetTrackId: trackId });
+ },
+
+ closeAudioImportModal: () => {
+ set({ showAudioImportModal: false, audioImportTargetTrackId: null });
+ },
+
removeTrack: async (id: number) => {
try {
// Get the current tracks and find the index of the track being deleted
@@ -452,7 +602,7 @@ export const useProjectStore = create((set, get) => {
set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' });
},
- loadProject: async (project: KGProject | null = null) => {
+ loadProject: async (project: KGProject | null = null, savedName?: string) => {
try {
const { setPlayheadPosition } = get();
@@ -488,29 +638,58 @@ export const useProjectStore = create((set, get) => {
// Setup audio synths for all tracks
const audioInterface = KGAudioInterface.instance();
- // Clear any existing synths first
- tracks.forEach(track => {
- audioInterface.removeTrackSynth(track.getId().toString());
- });
-
- // Create synths for all tracks (with their stored volumes)
+ // Clear any existing synths/buses first
tracks.forEach(track => {
const trackId = track.getId().toString();
- // Get instrument from track model if it's a MIDI track
- let instrument: InstrumentType = 'acoustic_grand_piano'; // Default fallback
- if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) {
- instrument = (track as KGMidiTrack).getInstrument();
- }
- audioInterface.createTrackSynth(trackId, instrument);
- // Volume is applied during bus creation; ensure sync if bus already existed
- audioInterface.setTrackVolume(trackId, track.getVolume());
+ audioInterface.removeTrackSynth(trackId);
+ audioInterface.removeTrackAudioPlayerBus(trackId);
});
+
+ // Create synths/buses for all tracks (with their stored volumes)
+ const projectName = projectToLoad.getName();
+ for (const track of tracks) {
+ const trackId = track.getId().toString();
+
+ if (track.getCurrentType() === 'KGAudioTrack') {
+ // Audio track: create player bus and load audio buffers
+ await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume());
+
+ // Load audio buffers for all regions in this audio track
+ const audioTrack = track as KGAudioTrack;
+ for (const region of audioTrack.getRegions()) {
+ if (region.getCurrentType() === 'KGAudioRegion') {
+ const audioRegion = region as KGAudioRegion;
+ const audioFileId = audioRegion.getAudioFileId();
+ if (audioFileId) {
+ try {
+ const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
+ const audioContext = Tone.getContext().rawContext as AudioContext;
+ const decoded = await audioContext.decodeAudioData(arrayBuffer);
+ const toneBuffer = new Tone.ToneAudioBuffer();
+ toneBuffer.set(decoded);
+ audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
+ } catch (err) {
+ console.error(`Failed to load audio file ${audioFileId}:`, err);
+ }
+ }
+ }
+ }
+ } else {
+ // MIDI track: create sampler-based audio bus
+ let instrument: InstrumentType = 'acoustic_grand_piano';
+ if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) {
+ instrument = (track as KGMidiTrack).getInstrument();
+ }
+ audioInterface.createTrackSynth(trackId, instrument);
+ audioInterface.setTrackVolume(trackId, track.getVolume());
+ }
+ }
- // Update CSS variable for time signature numerator
+ // Update CSS variables
updateTimeSignatureCSS(timeSignature);
- // Update CSS variable for max bars
updateMaxBarsCSS(maxBars);
-
+ updateBarWidthMultiplierCSS(projectToLoad.getBarWidthMultiplier());
+
// Log project loading info
console.log(`Project max bars: ${maxBars}`);
console.log(`Setup audio synths for ${tracks.length} tracks`);
@@ -519,8 +698,10 @@ export const useProjectStore = create((set, get) => {
// Force a new array reference for tracks to trigger React/Zustand re-render
set({
projectName: projectToLoad.getName(),
+ savedProjectName: savedName ?? projectToLoad.getName(),
tracks: [...tracks],
maxBars,
+ barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
timeSignature,
bpm,
keySignature,
@@ -617,16 +798,23 @@ export const useProjectStore = create((set, get) => {
}
},
+ setBarWidthMultiplier: (multiplier: number) => {
+ const project = KGCore.instance().getCurrentProject();
+ project.setBarWidthMultiplier(multiplier);
+ set({ barWidthMultiplier: multiplier });
+ updateBarWidthMultiplierCSS(multiplier);
+ },
+
setTimeSignature: (timeSignature: TimeSignature) => {
try {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ timeSignature });
KGCore.instance().executeCommand(command);
-
+
// Update the store state
set({ timeSignature });
updateTimeSignatureCSS(timeSignature);
-
+
console.log(`Set time signature to ${timeSignature.numerator}/${timeSignature.denominator}`);
} catch (error) {
console.error('Error setting time signature:', error);
@@ -821,15 +1009,17 @@ export const useProjectStore = create((set, get) => {
projectName: project.getName(),
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
maxBars: project.getMaxBars(),
+ barWidthMultiplier: project.getBarWidthMultiplier(),
timeSignature: project.getTimeSignature(),
bpm: project.getBpm(),
keySignature: project.getKeySignature(),
selectedMode: project.getSelectedMode()
});
-
+
// Sync CSS variables that affect layout
updateTimeSignatureCSS(project.getTimeSignature());
updateMaxBarsCSS(project.getMaxBars());
+ updateBarWidthMultiplierCSS(project.getBarWidthMultiplier());
// Sync all related state
const actions = get();
diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts
index c7607e3..4b0adcc 100644
--- a/src/test/utils/mock-data.ts
+++ b/src/test/utils/mock-data.ts
@@ -123,8 +123,9 @@ export const createMockProject = (overrides: Partial<{
'ionian', // selectedMode
false, // isLooping
[0, 0], // loopingRange
+ 1, // barWidthMultiplier
defaults.tracks, // tracks
- 3 // projectStructureVersion
+ 5 // projectStructureVersion
)
return project
diff --git a/src/util/saveUtil.ts b/src/util/saveUtil.ts
index 780e16a..a394744 100644
--- a/src/util/saveUtil.ts
+++ b/src/util/saveUtil.ts
@@ -2,58 +2,79 @@ import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStora
import { KGCore } from '../core/KGCore';
/**
- * Save project utility function
- * Handles saving the current project with proper error handling and user confirmation
- * @param projectName - The name of the project to save
- * @param setStatus - Function to update the status message
- * @returns Promise - Returns true if save was successful, false otherwise
+ * Save project utility function.
+ * Detects renames (savedProjectName !== projectName) and migrates the OPFS folder,
+ * including media files. Duplicate name conflicts during rename auto-resolve with
+ * the {name} (1), {name} (2), ... pattern.
+ *
+ * @param projectName Current in-memory project name
+ * @param savedProjectName OPFS folder name the project was last saved under
+ * @param setStatus Function to update the status message
+ * @param onSaveSuccess Called with the final saved name on success
*/
export const saveProject = async (
projectName: string,
- setStatus: (status: string) => void
+ savedProjectName: string,
+ setStatus: (status: string) => void,
+ onSaveSuccess: (finalName: string) => void,
): Promise => {
const storage = KGProjectStorage.getInstance();
+ const isRename = savedProjectName !== projectName;
+ if (isRename) {
+ // Determine the target name, resolving conflicts automatically
+ let targetName = projectName;
+ if (await storage.exists(projectName)) {
+ targetName = await storage.resolveUniqueName(projectName);
+ }
+
+ try {
+ await storage.saveWithRename(
+ savedProjectName,
+ targetName,
+ KGCore.instance().getCurrentProject(),
+ );
+
+ const statusMsg =
+ targetName !== projectName
+ ? `Project renamed to "${targetName}" and saved`
+ : `Project "${targetName}" has been saved`;
+ setStatus(statusMsg);
+ onSaveSuccess(targetName);
+ return true;
+ } catch (error) {
+ console.error('Error saving renamed project:', error);
+ window.alert(`An error occurred while saving: ${error}`);
+ return false;
+ }
+ }
+
+ // Same name — existing overwrite logic
try {
- await storage.save(
- projectName,
- KGCore.instance().getCurrentProject(),
- false,
- );
-
+ await storage.save(projectName, KGCore.instance().getCurrentProject(), false);
setStatus(`Project "${projectName}" has been saved`);
- console.log("project saved successfully");
+ onSaveSuccess(projectName);
return true;
-
} catch (error) {
- console.error("Error saving project:", error);
-
if (error instanceof DuplicateEntryError) {
- const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`);
-
+ const confirmed = window.confirm(
+ `Project "${projectName}" already exists. Do you want to overwrite it?`,
+ );
if (confirmed) {
try {
- await storage.save(
- projectName,
- KGCore.instance().getCurrentProject(),
- true,
- );
-
+ await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
setStatus(`Project "${projectName}" has been saved`);
- console.log("project saved successfully after overwrite");
+ onSaveSuccess(projectName);
return true;
-
} catch (overwriteError) {
- console.error("Error overwriting project:", overwriteError);
+ console.error('Error overwriting project:', overwriteError);
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
return false;
}
- } else {
- // User cancelled the overwrite
- return false;
}
+ return false;
} else {
- console.error("Error saving project:", error);
+ console.error('Error saving project:', error);
window.alert(`An unknown error ${error} occurred. Please try again.`);
return false;
}