Merge pull request #30 from KGAudioLab/feat/2026-04-09-add-wav-track-support

Feat/2026 04 09 add wav track support
This commit is contained in:
Xiaohan-Tian
2026-04-11 12:55:45 -07:00
committed by GitHub
50 changed files with 3760 additions and 292 deletions
+7
View File
@@ -8,6 +8,7 @@
"name": "K.G.Studio",
"version": "0.9.0-build.20260406",
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1",
"idb": "^8.0.3",
"jszip": "^3.10.1",
@@ -391,6 +392,12 @@
"node": ">=18"
}
},
"node_modules/@breezystack/lamejs": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@breezystack/lamejs/-/lamejs-1.2.7.tgz",
"integrity": "sha512-6wc7ck65ctA75Hq7FYHTtTvGnYs6msgdxiSUICQ+A01nVOWg6rqouZB8IdyteRlfpYYiFovkf67dIeOgWIUzTA==",
"license": "LGPL-3.0"
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"test:run": "vitest run"
},
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1",
"idb": "^8.0.3",
"jszip": "^3.10.1",
+14 -7
View File
@@ -17,8 +17,8 @@ Navigate to **Settings ⚙️ → General → LLM Provider**. Depending on your
1. Obtain an OpenAI API Key from [**OpenAI**](https://platform.openai.com/account/api-keys). You may need to create an account and add a payment method to generate an API Key.
2. In **Settings ⚙️ → General → LLM Provider**, select **OpenAI** as your provider.
3. Enter your API Key in **OpenAI → Key**.
4. Select your preferred model from the **OpenAI → Model** dropdown. For optimal performance, we recommend `gpt-5.2` or `gpt-5-mini`.
5. Optionally, choose whether to enable Flex Mode in **OpenAI → Flex Mode**. Flex Mode offers discounted pricing, but may result in slower response times.
4. Select your preferred model from the **OpenAI → Model** dropdown. For a good balance between performance and cost, we recommend `gpt-5.4-mini`.
5. Optionally, choose whether to enable Flex Mode in **OpenAI → Flex Mode**. Flex Mode offers discounted pricing, but may result in slower response times or server-side errors.
---
@@ -32,10 +32,10 @@ OpenRouter is a platform that provides unified access to a wide range of languag
4. Browse available models on the [**OpenRouter Models Page**](https://openrouter.ai/models). Use the "Prompt Pricing" filter to identify free models.
**Note:** Each model provider may have different data retention and privacy policies. Please review these policies before use.
5. Enter your chosen model name in **OpenAI Compatible Server → Model**. Recommended model series include:
- `Anthropic: claude-4-sonnet` (`anthropic/claude-sonnet-4`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4))
- `Qwen: qwen3-30b-a3b` (FREE MODEL: `qwen/qwen3-30b-a3b:free`: [Link](https://openrouter.ai/qwen/qwen3-30b-a3b:free))
- `Qwen: qwen3-235b-a22b` (FREE MODEL: `qwen/qwen3-235b-a22b:free`: [Link](https://openrouter.ai/qwen/qwen3-235b-a22b:free))
6. Input the base URL `https://openrouter.ai/api/v1` of the OpenAI Compatible Server in **OpenAI Compatible Server → Base URL**.
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — best balance of quality and cost for the Claude series
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — recommended open source model
- `Qwen: Qwen3.6 Plus` (FREE MODEL: `qwen/qwen3.6-plus:free`: [Link](https://openrouter.ai/qwen/qwen3.6-plus:free)) — recommended free model; note that free model providers may collect your data, check the model page for details
6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**.
### Basic DAW Operations
@@ -56,6 +56,13 @@ OpenRouter is a platform that provides unified access to a wide range of languag
- Move/resize: drag note body to move selected notes; drag edges to resize.
- Close the piano roll with X or ESC.
- Intelligent Chord Assistant
- Enable chord guide from the piano roll toolbar: select T (Tonic), S (Subdominant), or D (Dominant) function.
- Hover over any key to see context-aware chord suggestions highlighted in red, matching your selected key signature and mode.
- Press Tab to cycle through different chord voicings for the same harmonic function.
- Double-click (or Ctrl/Cmd+click) on a highlighted chord to create all notes at once.
- Chord length automatically matches your last edited note for consistent rhythm.
- Snapping and Quantize
- Set snapping from the NO SNAP menu (topright).
- Quantize timing with Qua. Pos. (start) and Qua. Len. (length).
@@ -69,7 +76,7 @@ OpenRouter is a platform that provides unified access to a wide range of languag
### Note
Due to security reasons, if you are using K.G.Studio from a non-local hosted environment, we won't persist your API Key in your browser's IndexedDB in order to prevent potential XSS attack, you will need to input your API Key every time you start K.G.Studio.
For security, when running from a non-local host we do not persist your API key in IndexedDB by default (to reduce XSS risk). You'll be prompted to enter it each time you start K.G.Studio. To opt-in to persistence on non-local hosts, enable "Persist API Keys on Non-Localhost" in Settings (not recommended for shared/production environments).
### Disclaimer
Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

+28
View File
@@ -12,6 +12,8 @@ import { SettingsPanel } from './components/settings';
import LoadingOverlay from './components/common/LoadingOverlay';
import { useEffect as useEffectReact, useState, useRef } from 'react';
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer';
import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
import { KGCore } from './core/KGCore';
import { ConfigManager } from './core/config/ConfigManager';
import { validateFunctionalChordsJSON } from './util/scaleUtil';
@@ -144,6 +146,9 @@ function App() {
{/* Migration Loading Overlay */}
<MigrationOverlayContainer />
{/* Bounce/Render Overlay */}
<BounceOverlayContainer />
</div>
);
}
@@ -232,3 +237,26 @@ const MigrationOverlayContainer: React.FC = () => {
/>
);
};
// Bounce/render overlay — shown during offline WAV/MP3 rendering
const BounceOverlayContainer: React.FC = () => {
const [renderMessage, setRenderMessage] = useState<string | null>(null);
useEffectReact(() => {
const renderer = KGOfflineRenderer.instance();
const listener = (evt: RenderingEvent) => {
setRenderMessage(evt.type === 'start' ? (evt.message ?? 'Rendering...') : null);
};
renderer.addRenderingListener(listener);
return () => {
renderer.removeRenderingListener(listener);
};
}, []);
return (
<LoadingOverlay
visible={renderMessage !== null}
message={renderMessage ?? 'Rendering...'}
/>
);
};
+19 -5
View File
@@ -83,6 +83,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
id: m.id,
role: m.role,
content: m.content,
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
timestamp: formatLocalDateTime(new Date(m.timestamp))
}));
const json = JSON.stringify(exportMessages, null, 2);
@@ -99,15 +101,27 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const res = await fetch(templateUrl);
const template = await res.text();
const sections = agentMessages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map((m) => {
const roleLabel = m.role === 'assistant' ? 'Assistant' : 'User';
const sections = agentMessages.map((m) => {
let roleLabel: string;
if (m.role === 'assistant') roleLabel = 'Assistant';
else if (m.role === 'tool') roleLabel = 'Tool Result';
else roleLabel = 'User';
const ts = formatLocalDateTime(new Date(m.timestamp));
let content = m.content ?? '';
// For assistant messages with tool_calls but no text, show tool call info
if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
const toolCallsText = m.tool_calls.map(tc =>
`**Tool call: ${tc.function.name}**\n\`\`\`json\n${tc.function.arguments}\n\`\`\``
).join('\n\n');
content = content ? `${content}\n\n${toolCallsText}` : toolCallsText;
}
return template
.replace('{role}', roleLabel)
.replace('{timestamp}', ts)
.replace('{content}', m.content ?? '');
.replace('{content}', content);
});
const markdown = sections.join('\n');
+7 -3
View File
@@ -3,6 +3,7 @@ import './InstrumentSelection.css';
import { useProjectStore } from '../stores/projectStore';
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
const InstrumentSelection: React.FC = () => {
const {
@@ -53,14 +54,15 @@ const InstrumentSelection: React.FC = () => {
}
};
const previewImage = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.image || 'piano.png';
const previewAlt = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.displayName || currentInstrumentKey;
const isAudioTrack = targetTrack instanceof KGAudioTrack;
const previewImage = isAudioTrack ? 'speaker.png' : (FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.image || 'piano.png');
const previewAlt = isAudioTrack ? 'Audio Track' : (FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.displayName || currentInstrumentKey);
const hasTargetTrack = !!targetTrack;
return (
<div className="instrument-selection">
<div className="instrument-selection-header">
<h3>{hasTargetTrack ? `${previewAlt.toString()}` : ''}</h3>
<h3>{hasTargetTrack ? previewAlt : ''}</h3>
<button className="instrument-selection-close-btn" onClick={closeInstrumentSelection}></button>
</div>
<div className="instrument-selection-top">
@@ -76,6 +78,7 @@ const InstrumentSelection: React.FC = () => {
)}
<div className="instrument-name-overlay">{hasTargetTrack ? targetTrack.getName() : ''}</div>
</div>
{!isAudioTrack && (
<div className="instrument-selection-bottom">
<div className="instrument-groups">
<div className="instrument-groups-list">
@@ -104,6 +107,7 @@ const InstrumentSelection: React.FC = () => {
</div>
</div>
</div>
)}
</div>
);
};
+2
View File
@@ -25,6 +25,8 @@
border-bottom: 1px solid #3a3a3a;
border-right: 1px solid #3a3a3a;
z-index: 1002; /* Higher than other elements to ensure it's always visible */
display: flex;
flex-direction: row;
}
/* Offset spacer when instrument selection panel is visible on the left */
+20 -6
View File
@@ -5,6 +5,7 @@ import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore';
import { KGTrack } from '../core/track/KGTrack';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel';
import PianoRoll from './piano-roll/PianoRoll';
@@ -12,6 +13,7 @@ import type { RegionUI } from './interfaces';
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants';
import { useRegionOperations } from '../hooks/useRegionOperations';
import { regionDeleteManager } from '../util/regionDeleteUtil';
import { KGMainContentState } from '../core/state/KGMainContentState';
import { ChangeLoopSettingsCommand } from '../core/commands';
interface MainContentProps {
@@ -35,7 +37,8 @@ const MainContent: React.FC<MainContentProps> = ({
activeRegionId,
setShowPianoRoll,
setActiveRegionId,
addTrack
addTrack,
addAudioTrack
} = useProjectStore();
// State to store regions
@@ -130,10 +133,10 @@ const MainContent: React.FC<MainContentProps> = ({
// Iterate through all regions in the track
track.getRegions().forEach(region => {
if (region instanceof KGMidiRegion) {
if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) {
// Calculate bar number and length from beats
const beatsPerBar = timeSignature.numerator;
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
const length = region.getLength() / beatsPerBar;
// Create a RegionUI object
@@ -432,6 +435,15 @@ const MainContent: React.FC<MainContentProps> = ({
// Handle explicit pencil action: select region and open piano roll
const handleOpenPianoRoll = (regionId: string) => {
// Don't open piano roll for audio regions
const project = KGCore.instance().getCurrentProject();
for (const track of project.getTracks()) {
const region = track.getRegions().find(r => r.getId() === regionId);
if (region && region.getCurrentType() === 'KGAudioRegion') {
return;
}
}
if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Open piano roll via pencil for region: ${regionId}`);
}
@@ -506,8 +518,9 @@ const MainContent: React.FC<MainContentProps> = ({
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
) || 40;
// Find the closest bar start (using Math.round for nearest bar)
const barIndex = Math.round(relativeX / barWidth);
// Find the closest bar start; honor snapping toggle
const snap = KGMainContentState.instance().isSnappingEnabled();
const barIndex = snap ? Math.round(relativeX / barWidth) : relativeX / barWidth;
// Ensure we don't go below 0
const clampedBarIndex = Math.max(0, barIndex);
@@ -691,7 +704,8 @@ const MainContent: React.FC<MainContentProps> = ({
<div className="main-content-wrapper">
{/* Top-left spacer */}
<div className="top-left-spacer">
<button className="add-track-btn" onClick={() => addTrack()}>+ Add track</button>
<button className="add-track-btn" onClick={() => addTrack()}>+ MIDI</button>
<button className="add-track-btn" onClick={() => addAudioTrack()}>+ Audio</button>
</div>
{/* Bar numbers at the top */}
+46
View File
@@ -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;
}
+163 -62
View File
@@ -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);
@@ -55,11 +60,30 @@ 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<HTMLDivElement>(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,7 +97,7 @@ 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();
@@ -83,7 +107,13 @@ const Toolbar: React.FC = () => {
// 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`);
@@ -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");
}
const handleLoadProject = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("user clicked load button");
}
setShowOpenProject(true);
};
// Ask user for project name
const projectNameToLoad = window.prompt("Enter the project name to load:");
const handleOpenProjectSelect = async (projectNameToLoad: string) => {
try {
const storage = KGProjectStorage.getInstance();
const loadedProject = await storage.load(projectNameToLoad);
// 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.");
}
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) {
@@ -766,6 +829,13 @@ const Toolbar: React.FC = () => {
>
<FaPencil />
</button>
<button
title="Snap to Grid"
className={`tool-button ${isSnapping ? 'active' : ''}`}
onClick={handleSnappingToggle}
>
<FaMagnet />
</button>
<div className="toolbar-separator"></div>
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
@@ -792,6 +862,28 @@ const Toolbar: React.FC = () => {
<div className="toolbar-right">
<div className="transport-control">
<div className="transport-item" style={{ position: 'relative' }} ref={zoomSliderRef}>
<span
className='current-zoom'
onClick={() => setShowZoomSlider(!showZoomSlider)}
style={{ cursor: 'pointer' }}
>
{barWidthMultiplier}x
</span>
{showZoomSlider && (
<div className="zoom-slider-popup">
<input
type="range"
min="1"
max="8"
step="1"
value={barWidthMultiplier}
onChange={(e) => setBarWidthMultiplier(parseInt(e.target.value))}
/>
<span className="zoom-slider-label">{barWidthMultiplier}x</span>
</div>
)}
</div>
<div className="transport-item">
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
</div>
@@ -836,6 +928,15 @@ const Toolbar: React.FC = () => {
title="Import Project"
description="Drag and drop your project file here"
/>
{showOpenProject && (
<OpenProjectModal
onClose={() => setShowOpenProject(false)}
onOpenProject={handleOpenProjectSelect}
currentProjectName={savedProjectName}
onCreateNewProject={createNewProject}
/>
)}
</>
);
};
+1
View File
@@ -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;
+285
View File
@@ -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;
}
+442
View File
@@ -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<void>;
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<OpenProjectModalProps> = ({ onClose, onOpenProject, currentProjectName, onCreateNewProject }) => {
const panelRef = useRef<HTMLDivElement>(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<ProjectMeta[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
// Sort
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
// View mode toggle
const [viewMode, setViewMode] = useState<ViewMode>('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' ? <FaSortUp /> : <FaSortDown />;
};
const content = (
<div className="open-project-overlay" onClick={onClose}>
<div
className="open-project-panel"
ref={panelRef}
onClick={(e) => e.stopPropagation()}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: 2001,
}}
>
{/* Header */}
<div className="open-project-header" onMouseDown={(e) => handleMouseDown(e, 'drag')}>
<button className="close-button" onClick={onClose}>
<FaTimes />
</button>
<div className="open-project-title">Open Project</div>
</div>
{/* Filter + view toggle */}
<div className="open-project-filter">
<div className="open-project-filter-row">
<input
type="text"
placeholder="Filter projects..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
autoFocus
/>
<div className="open-project-view-toggle">
<button
className={`open-project-toggle-btn ${viewMode === 'projects' ? 'active' : ''}`}
onClick={() => handleViewModeChange('projects')}
>
Projects
</button>
<button
className={`open-project-toggle-btn ${viewMode === 'trash' ? 'active' : ''}`}
onClick={() => handleViewModeChange('trash')}
>
Trash
</button>
</div>
</div>
</div>
{/* Trash hint */}
{viewMode === 'trash' && (
<div className="open-project-trash-hint">
Deleted projects are automatically removed after 30 days.
</div>
)}
{/* Sort header */}
<div className="open-project-sort-header">
<span
className={`open-project-sort-col open-project-sort-col-name ${sortField === 'name' ? 'active' : ''}`}
onClick={() => handleSortClick('name')}
>
Name <SortIcon field="name" />
</span>
<span
className={`open-project-sort-col open-project-sort-col-created ${sortField === 'createdAt' ? 'active' : ''}`}
onClick={() => handleSortClick('createdAt')}
>
Created <SortIcon field="createdAt" />
</span>
<span
className={`open-project-sort-col open-project-sort-col-updated ${sortField === 'updatedAt' ? 'active' : ''}`}
onClick={() => handleSortClick('updatedAt')}
>
Updated <SortIcon field="updatedAt" />
</span>
</div>
{/* Project list */}
{isLoading ? (
<div className="open-project-loading">Loading projects...</div>
) : filteredProjects.length === 0 ? (
<div className="open-project-empty">
{projects.length === 0
? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects')
: 'No projects match the filter'}
</div>
) : (
<div className="open-project-list">
{filteredProjects.map((project) => (
<div
key={project.name}
className="open-project-item"
onClick={viewMode === 'projects' ? () => handleOpen(project.name) : undefined}
>
<div className="open-project-item-info">
<div className="open-project-item-name">{project.name}</div>
<div className="open-project-item-meta">
Created {formatDate(project.createdAt)} &middot; Updated {formatDate(project.updatedAt)}
{viewMode === 'trash' && project.deletedAt && (
<> &middot; Deleted {formatDate(project.deletedAt)}</>
)}
</div>
</div>
<div className="open-project-item-actions">
{viewMode === 'projects' ? (
<>
<button
className="open-project-item-open-btn"
onClick={(e) => { e.stopPropagation(); handleOpen(project.name); }}
>
Open
</button>
<button
className="open-project-item-action-btn open-project-item-duplicate-btn"
onClick={(e) => handleDuplicate(e, project.name)}
title="Duplicate"
>
<FaCopy />
</button>
<button
className="open-project-item-action-btn open-project-item-delete-btn"
onClick={(e) => handleSoftDelete(e, project.name)}
title="Delete"
>
<FaTrash />
</button>
</>
) : (
<>
<button
className="open-project-item-action-btn open-project-item-restore-btn"
onClick={(e) => handleRestore(e, project.name)}
title="Restore"
>
<FaUndo />
</button>
<button
className="open-project-item-action-btn open-project-item-permdelete-btn"
onClick={(e) => handlePermanentDelete(e, project.name)}
title="Delete permanently"
>
<FaTrash />
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{/* Resize handle */}
<div className="resize-handle" onMouseDown={(e) => handleMouseDown(e, 'resize')}>
<FaGripLines />
</div>
</div>
</div>
);
return createPortal(content, document.body);
};
export default OpenProjectModal;
+1
View File
@@ -2,3 +2,4 @@ export { default as KGDropdown } from './KGDropdown';
export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal';
+26
View File
@@ -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;
+118 -32
View File
@@ -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<RegionItemProps> = ({
@@ -45,10 +49,12 @@ const RegionItem: React.FC<RegionItemProps> = ({
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<string>('pointer');
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
@@ -204,6 +210,76 @@ const RegionItem: React.FC<RegionItemProps> = ({
}
};
// 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<string>('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
@@ -226,15 +302,23 @@ const RegionItem: React.FC<RegionItemProps> = ({
// 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<RegionItemProps> = ({
resizeObserver.unobserve(regionContentRef.current);
}
};
}, [midiRegion, timeSignature]);
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -453,7 +537,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
return (
<div
key={id}
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''} ${audioRegion ? 'audio-region' : ''}`}
style={{ ...style, cursor }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
@@ -464,32 +548,34 @@ const RegionItem: React.FC<RegionItemProps> = ({
data-is-dragging={isDragging}
>
<div className="region-header">
{name}
{audioRegion ? audioRegion.getAudioFileName() : name}
</div>
<div className="region-content" ref={regionContentRef}>
<button
className="region-pencil-btn"
title="Edit notes"
onMouseDown={(e) => {
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"
>
<FaPencilAlt size={10} />
</button>
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
{!audioRegion && (
<button
className="region-pencil-btn"
title="Edit notes"
onMouseDown={(e) => {
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"
>
<FaPencilAlt size={10} />
</button>
)}
<canvas ref={canvasRef} />
</div>
</div>
+1 -1
View File
@@ -26,7 +26,7 @@
font-size: 12px;
padding: 0;
height: 100%;
width: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
+29 -10
View File
@@ -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,13 +268,16 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
// 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;
@@ -432,9 +437,10 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
// 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,8 +512,19 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
>
{/* 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 (
<RegionItem
@@ -526,8 +543,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
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<TrackGridItemProps> = ({
}
}}
midiRegion={midiRegion}
audioRegion={audioRegion}
audioBuffer={audioBuffer}
/>
);
})}
+98 -19
View File
@@ -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 {
@@ -65,6 +67,11 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
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}`);
}
@@ -159,8 +166,8 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
// 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);
@@ -168,24 +175,71 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
// Update the region in the track's model
const trackRegions = track.getRegions();
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
const coreRegion = trackRegions.find(r => r.getId() === regionId);
if (midiRegion) {
const oldStartBeat = midiRegion.getStartFromBeat();
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);
@@ -201,15 +255,15 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
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 }
);
}
}
};
@@ -239,6 +293,18 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
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,6 +317,19 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
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`);
+54 -10
View File
@@ -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<TrackInfoItemProps> = ({
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<TrackInfoItemProps> = ({
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
const settingsDropdownRef = useRef<HTMLDivElement>(null);
const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume());
@@ -195,6 +199,8 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
// 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<TrackInfoItemProps> = ({
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<TrackInfoItemProps> = ({
<div className="track-controls">
<div className="track-name-and-volume">
<div className="instrument-image">
<img
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
width="64"
height="64"
/>
{isAudioTrack ? (
<img
src={`${import.meta.env.BASE_URL}resources/instruments/speaker.png`}
alt="Audio Track"
width="64"
height="64"
/>
) : (
<img
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
width="64"
height="64"
/>
)}
</div>
<div className="track-name-and-controls">
<div
@@ -296,9 +324,15 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
<div>
<button className="instrument" onClick={handlePianoButtonClick}>
<TbPiano />
</button>
{isAudioTrack ? (
<button className="instrument" onClick={handleAudioImportClick} title="Import Audio">
<FaFileAudio />
</button>
) : (
<button className="instrument" onClick={handlePianoButtonClick}>
<TbPiano />
</button>
)}
</div>
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
<button className="settings" onClick={handleSettingsButtonClick}>
@@ -319,6 +353,16 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
</div>
</div>
</div>
{isAudioTrack && (
<FileImportModal
isVisible={showAudioImportModal}
onClose={() => setShowAudioImportModal(false)}
onFileImport={handleAudioFileImport}
acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
title="Import Audio"
description="Drag and drop your audio file here"
/>
)}
</div>
);
};
+1
View File
@@ -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
+75 -2
View File
@@ -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<void> {
console.log('📟 OPFS Interactive Shell');
console.log(' Commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>');
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 <path> — change directory (supports .., /, relative, and quoted paths)
* cat <file> — print file contents
* dl <file> — download a file to your local machine
* rm <name> — 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 <path>, cat <file>, dl <file>')
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>')
}
} 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<void> {
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`)
}
}
}
+17 -2
View File
@@ -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;
}
}
+282 -14
View File
@@ -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<string, KGAudioBus> = new Map();
// Audio player buses for audio/wav tracks
private trackAudioPlayerBuses: Map<string, KGAudioPlayerBus> = new Map();
// Playback state
private isPlaying: boolean = false;
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
@@ -126,6 +138,12 @@ export class KGAudioInterface {
});
this.trackAudioBuses.clear();
// Dispose of all audio player buses
this.trackAudioPlayerBuses.forEach(playerBus => {
playerBus.dispose();
});
this.trackAudioPlayerBuses.clear();
// Dispose master gain
if (this.masterGain) {
this.masterGain.dispose();
@@ -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<void> {
// 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<void> {
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,6 +437,7 @@ 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()}`);
@@ -362,6 +491,123 @@ export class KGAudioInterface {
}
});
}
// 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`);
@@ -405,6 +651,11 @@ export class KGAudioInterface {
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);
}
@@ -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<string, Tone.ToneAudioBuffer> = 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<KGAudioPlayerBus> {
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,
};
}
}
@@ -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
});
});
@@ -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<Tone.ToneAudioBuffer> {
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<void>[] = [];
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<Tone.Sampler>((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<void> {
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<void> {
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;
}
@@ -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)
+2
View File
@@ -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';
@@ -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;
}
}
@@ -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';
/**
@@ -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,7 +63,7 @@ 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;
@@ -79,6 +85,15 @@ export class ResizeRegionCommand extends KGCommand {
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);
@@ -108,6 +123,12 @@ export class ResizeRegionCommand extends KGCommand {
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);
}
}
@@ -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;
}
}
@@ -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);
if (this.removedTrack.getCurrentType() === 'KGAudioTrack') {
audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
} else {
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
}
console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`);
console.log(`Restored track ${this.trackId}`);
}
getDescription(): string {
+85
View File
@@ -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<void> {
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<ArrayBuffer> {
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<void> {
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<FileSystemDirectoryHandle> {
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<FileSystemDirectoryHandle> {
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 });
}
}
+203 -1
View File
@@ -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<ProjectMeta[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string>();
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<void> {
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/<fromName>/media/ to projects/<toName>/media/.
* If the source media directory doesn't exist, returns without error.
*/
private async copyMediaFiles(fromName: string, toName: string): Promise<void> {
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<ProjectMeta> {
try {
const raw = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
return JSON.parse(raw) as ProjectMeta;
} catch {
return { name, createdAt: 0, updatedAt: 0 };
}
}
}
@@ -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}`);
+17
View File
@@ -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;
}
+13
View File
@@ -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;
}
+21
View File
@@ -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;
}
+87
View File
@@ -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';
}
}
+9
View File
@@ -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;
}
}
+38
View File
@@ -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';
}
}
+2
View File
@@ -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' },
],
},
})
+9 -3
View File
@@ -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');
+3 -2
View File
@@ -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';
@@ -47,8 +48,8 @@ export const useRegionOperations = ({
// Get all selected regions from KGCore
const selectedItems = core.getSelectedItems();
const selectedRegions = selectedItems.filter(item =>
item instanceof KGMidiRegion
) as KGMidiRegion[];
item instanceof KGMidiRegion || item instanceof KGAudioRegion
);
if (selectedRegions.length === 0) {
if (DEBUG_MODE.MAIN_CONTENT) {
+4 -2
View File
@@ -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<void> }).KGDebugger = dbg;
(window as unknown as { sh: () => Promise<void> }).sh = () => dbg.startShell();
console.log('🔧 KGDebugger attached to window - try: KGDebugger.help() or sh()');
}
// Start audio context on first user interaction
+209 -19
View File
@@ -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,6 +80,10 @@ interface ProjectState {
showInstrumentSelection: boolean;
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
// Audio import modal state
showAudioImportModal: boolean;
audioImportTargetTrackId: string | null;
// Settings state
showSettings: boolean;
@@ -74,7 +95,12 @@ interface ProjectState {
// Actions
setProjectName: (name: string) => void;
setSavedProjectName: (name: string) => void;
addTrack: () => Promise<void>;
addAudioTrack: () => Promise<void>;
importAudioToTrack: (trackId: string, file: File) => Promise<void>;
openAudioImportModal: (trackId: string) => void;
closeAudioImportModal: () => void;
removeTrack: (id: number) => Promise<void>;
updateTrack: (track: KGTrack) => Promise<void>;
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
@@ -83,13 +109,14 @@ interface ProjectState {
setStatus: (status: string) => void;
removeStatus: () => void;
refreshStatus: () => void;
loadProject: (project: KGProject | null) => Promise<void>;
loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
setPlayheadPosition: (position: number) => void;
startPlaying: () => Promise<void>;
stopPlaying: () => Promise<void>;
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<ProjectState>((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<ProjectState>((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<ProjectState>((set, get) => {
// Initial Instrument Selection panel state
showInstrumentSelection: initialShowInstrumentSelection,
// Initial audio import modal state
showAudioImportModal: false,
audioImportTargetTrackId: null,
// Initial Settings state
showSettings: false,
@@ -276,6 +311,10 @@ export const useProjectStore = create<ProjectState>((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<ProjectState>((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<void>((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<ProjectState> = {
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<ProjectState>((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,28 +638,57 @@ export const useProjectStore = create<ProjectState>((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);
});
// Update CSS variable for time signature numerator
// 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 variables
updateTimeSignatureCSS(timeSignature);
// Update CSS variable for max bars
updateMaxBarsCSS(maxBars);
updateBarWidthMultiplierCSS(projectToLoad.getBarWidthMultiplier());
// Log project loading info
console.log(`Project max bars: ${maxBars}`);
@@ -519,8 +698,10 @@ export const useProjectStore = create<ProjectState>((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,6 +798,13 @@ export const useProjectStore = create<ProjectState>((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
@@ -821,6 +1009,7 @@ export const useProjectStore = create<ProjectState>((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(),
@@ -830,6 +1019,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Sync CSS variables that affect layout
updateTimeSignatureCSS(project.getTimeSignature());
updateMaxBarsCSS(project.getMaxBars());
updateBarWidthMultiplierCSS(project.getBarWidthMultiplier());
// Sync all related state
const actions = get();
+2 -1
View File
@@ -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
+52 -31
View File
@@ -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<boolean> - 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<boolean> => {
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;
}