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:
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.9.0-build.20260406",
|
"version": "0.9.0-build.20260406",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -391,6 +392,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
+14
-7
@@ -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.
|
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.
|
2. In **Settings ⚙️ → General → LLM Provider**, select **OpenAI** as your provider.
|
||||||
3. Enter your API Key in **OpenAI → Key**.
|
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`.
|
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.
|
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.
|
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.
|
**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:
|
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))
|
- `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-30b-a3b` (FREE MODEL: `qwen/qwen3-30b-a3b:free`: [Link](https://openrouter.ai/qwen/qwen3-30b-a3b:free))
|
- `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-235b-a22b` (FREE MODEL: `qwen/qwen3-235b-a22b:free`: [Link](https://openrouter.ai/qwen/qwen3-235b-a22b:free))
|
- `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` of the OpenAI Compatible Server in **OpenAI Compatible Server → Base URL**.
|
6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**.
|
||||||
|
|
||||||
### Basic DAW Operations
|
### 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.
|
- Move/resize: drag note body to move selected notes; drag edges to resize.
|
||||||
- Close the piano roll with X or ESC.
|
- 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
|
- Snapping and Quantize
|
||||||
- Set snapping from the NO SNAP menu (top‑right).
|
- Set snapping from the NO SNAP menu (top‑right).
|
||||||
- Quantize timing with Qua. Pos. (start) and Qua. Len. (length).
|
- 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
|
### 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
|
### Disclaimer
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 571 KiB |
+28
@@ -12,6 +12,8 @@ import { SettingsPanel } from './components/settings';
|
|||||||
import LoadingOverlay from './components/common/LoadingOverlay';
|
import LoadingOverlay from './components/common/LoadingOverlay';
|
||||||
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
||||||
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
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 { KGCore } from './core/KGCore';
|
||||||
import { ConfigManager } from './core/config/ConfigManager';
|
import { ConfigManager } from './core/config/ConfigManager';
|
||||||
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
||||||
@@ -144,6 +146,9 @@ function App() {
|
|||||||
|
|
||||||
{/* Migration Loading Overlay */}
|
{/* Migration Loading Overlay */}
|
||||||
<MigrationOverlayContainer />
|
<MigrationOverlayContainer />
|
||||||
|
|
||||||
|
{/* Bounce/Render Overlay */}
|
||||||
|
<BounceOverlayContainer />
|
||||||
</div>
|
</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...'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
content: m.content,
|
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))
|
timestamp: formatLocalDateTime(new Date(m.timestamp))
|
||||||
}));
|
}));
|
||||||
const json = JSON.stringify(exportMessages, null, 2);
|
const json = JSON.stringify(exportMessages, null, 2);
|
||||||
@@ -99,15 +101,27 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
const res = await fetch(templateUrl);
|
const res = await fetch(templateUrl);
|
||||||
const template = await res.text();
|
const template = await res.text();
|
||||||
|
|
||||||
const sections = agentMessages
|
const sections = agentMessages.map((m) => {
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
let roleLabel: string;
|
||||||
.map((m) => {
|
if (m.role === 'assistant') roleLabel = 'Assistant';
|
||||||
const roleLabel = m.role === 'assistant' ? 'Assistant' : 'User';
|
else if (m.role === 'tool') roleLabel = 'Tool Result';
|
||||||
|
else roleLabel = 'User';
|
||||||
|
|
||||||
const ts = formatLocalDateTime(new Date(m.timestamp));
|
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
|
return template
|
||||||
.replace('{role}', roleLabel)
|
.replace('{role}', roleLabel)
|
||||||
.replace('{timestamp}', ts)
|
.replace('{timestamp}', ts)
|
||||||
.replace('{content}', m.content ?? '');
|
.replace('{content}', content);
|
||||||
});
|
});
|
||||||
|
|
||||||
const markdown = sections.join('\n');
|
const markdown = sections.join('\n');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './InstrumentSelection.css';
|
|||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
|
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
|
||||||
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
|
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
|
||||||
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
|
|
||||||
const InstrumentSelection: React.FC = () => {
|
const InstrumentSelection: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -53,14 +54,15 @@ const InstrumentSelection: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewImage = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.image || 'piano.png';
|
const isAudioTrack = targetTrack instanceof KGAudioTrack;
|
||||||
const previewAlt = FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.displayName || currentInstrumentKey;
|
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;
|
const hasTargetTrack = !!targetTrack;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="instrument-selection">
|
<div className="instrument-selection">
|
||||||
<div className="instrument-selection-header">
|
<div className="instrument-selection-header">
|
||||||
<h3>{hasTargetTrack ? `${previewAlt.toString()}` : ''}</h3>
|
<h3>{hasTargetTrack ? previewAlt : ''}</h3>
|
||||||
<button className="instrument-selection-close-btn" onClick={closeInstrumentSelection}>✕</button>
|
<button className="instrument-selection-close-btn" onClick={closeInstrumentSelection}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="instrument-selection-top">
|
<div className="instrument-selection-top">
|
||||||
@@ -76,6 +78,7 @@ const InstrumentSelection: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<div className="instrument-name-overlay">{hasTargetTrack ? targetTrack.getName() : ''}</div>
|
<div className="instrument-name-overlay">{hasTargetTrack ? targetTrack.getName() : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isAudioTrack && (
|
||||||
<div className="instrument-selection-bottom">
|
<div className="instrument-selection-bottom">
|
||||||
<div className="instrument-groups">
|
<div className="instrument-groups">
|
||||||
<div className="instrument-groups-list">
|
<div className="instrument-groups-list">
|
||||||
@@ -104,6 +107,7 @@ const InstrumentSelection: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
border-bottom: 1px solid #3a3a3a;
|
border-bottom: 1px solid #3a3a3a;
|
||||||
border-right: 1px solid #3a3a3a;
|
border-right: 1px solid #3a3a3a;
|
||||||
z-index: 1002; /* Higher than other elements to ensure it's always visible */
|
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 */
|
/* Offset spacer when instrument selection panel is visible on the left */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useProjectStore } from '../stores/projectStore';
|
|||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { KGTrack } from '../core/track/KGTrack';
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||||
import TrackGridPanel from './track/TrackGridPanel';
|
import TrackGridPanel from './track/TrackGridPanel';
|
||||||
import PianoRoll from './piano-roll/PianoRoll';
|
import PianoRoll from './piano-roll/PianoRoll';
|
||||||
@@ -12,6 +13,7 @@ import type { RegionUI } from './interfaces';
|
|||||||
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants';
|
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants';
|
||||||
import { useRegionOperations } from '../hooks/useRegionOperations';
|
import { useRegionOperations } from '../hooks/useRegionOperations';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { ChangeLoopSettingsCommand } from '../core/commands';
|
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
@@ -35,7 +37,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
activeRegionId,
|
activeRegionId,
|
||||||
setShowPianoRoll,
|
setShowPianoRoll,
|
||||||
setActiveRegionId,
|
setActiveRegionId,
|
||||||
addTrack
|
addTrack,
|
||||||
|
addAudioTrack
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
// State to store regions
|
// State to store regions
|
||||||
@@ -130,10 +133,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
|
|
||||||
// Iterate through all regions in the track
|
// Iterate through all regions in the track
|
||||||
track.getRegions().forEach(region => {
|
track.getRegions().forEach(region => {
|
||||||
if (region instanceof KGMidiRegion) {
|
if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) {
|
||||||
// Calculate bar number and length from beats
|
// Calculate bar number and length from beats
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
|
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
|
||||||
const length = region.getLength() / beatsPerBar;
|
const length = region.getLength() / beatsPerBar;
|
||||||
|
|
||||||
// Create a RegionUI object
|
// Create a RegionUI object
|
||||||
@@ -432,6 +435,15 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
|
|
||||||
// Handle explicit pencil action: select region and open piano roll
|
// Handle explicit pencil action: select region and open piano roll
|
||||||
const handleOpenPianoRoll = (regionId: string) => {
|
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) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Open piano roll via pencil for region: ${regionId}`);
|
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')
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||||
) || 40;
|
) || 40;
|
||||||
|
|
||||||
// Find the closest bar start (using Math.round for nearest bar)
|
// Find the closest bar start; honor snapping toggle
|
||||||
const barIndex = Math.round(relativeX / barWidth);
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
const barIndex = snap ? Math.round(relativeX / barWidth) : relativeX / barWidth;
|
||||||
|
|
||||||
// Ensure we don't go below 0
|
// Ensure we don't go below 0
|
||||||
const clampedBarIndex = Math.max(0, barIndex);
|
const clampedBarIndex = Math.max(0, barIndex);
|
||||||
@@ -691,7 +704,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
<div className="main-content-wrapper">
|
<div className="main-content-wrapper">
|
||||||
{/* Top-left spacer */}
|
{/* Top-left spacer */}
|
||||||
<div className="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>
|
</div>
|
||||||
|
|
||||||
{/* Bar numbers at the top */}
|
{/* Bar numbers at the top */}
|
||||||
|
|||||||
@@ -153,3 +153,49 @@
|
|||||||
width: 100px;
|
width: 100px;
|
||||||
left: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+143
-42
@@ -12,7 +12,7 @@ import {
|
|||||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||||
FaPlay, FaPause, FaComments, FaSync,
|
FaPlay, FaPause, FaComments, FaSync,
|
||||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||||
FaCog
|
FaCog, FaMagnet
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
@@ -22,18 +22,22 @@ import { regionDeleteManager } from '../util/regionDeleteUtil';
|
|||||||
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
|
import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer';
|
||||||
import KGDropdown from './common/KGDropdown';
|
import KGDropdown from './common/KGDropdown';
|
||||||
import FileImportModal from './common/FileImportModal';
|
import FileImportModal from './common/FileImportModal';
|
||||||
|
import OpenProjectModal from './common/OpenProjectModal';
|
||||||
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
||||||
import PianoIcon from './common/icons/PianoIcon';
|
import PianoIcon from './common/icons/PianoIcon';
|
||||||
|
|
||||||
const Toolbar: React.FC = () => {
|
const Toolbar: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
projectName, setProjectName,
|
projectName, setProjectName,
|
||||||
|
savedProjectName, setSavedProjectName,
|
||||||
bpm, timeSignature, keySignature, setStatus,
|
bpm, timeSignature, keySignature, setStatus,
|
||||||
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
|
isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
|
||||||
currentTime, setBpm, setTimeSignature, setKeySignature,
|
currentTime, setBpm, setTimeSignature, setKeySignature,
|
||||||
maxBars, setMaxBars,
|
maxBars, setMaxBars,
|
||||||
|
barWidthMultiplier, setBarWidthMultiplier,
|
||||||
isLooping, toggleLoop,
|
isLooping, toggleLoop,
|
||||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||||
toggleChatBox, toggleSettings, cleanupProjectState,
|
toggleChatBox, toggleSettings, cleanupProjectState,
|
||||||
@@ -45,6 +49,7 @@ const Toolbar: React.FC = () => {
|
|||||||
|
|
||||||
// State for main content tools
|
// State for main content tools
|
||||||
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
|
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
|
||||||
|
const [isSnapping, setIsSnapping] = React.useState(true);
|
||||||
|
|
||||||
// State for key signature dropdown
|
// State for key signature dropdown
|
||||||
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
|
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
|
||||||
@@ -55,11 +60,30 @@ const Toolbar: React.FC = () => {
|
|||||||
// State for import modal
|
// State for import modal
|
||||||
const [showImportModal, setShowImportModal] = React.useState(false);
|
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
|
// Key signature options
|
||||||
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
||||||
|
|
||||||
// Export options
|
// 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 handleProjectNameClick = () => {
|
||||||
const newName = prompt("Enter project name:", projectName);
|
const newName = prompt("Enter project name:", projectName);
|
||||||
@@ -73,7 +97,7 @@ const Toolbar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Common project loading logic extracted for reuse
|
// Common project loading logic extracted for reuse
|
||||||
const loadProjectFromData = async (project: KGProject, sourceDescription: string) => {
|
const loadProjectFromData = async (project: KGProject, sourceDescription: string, savedName?: string) => {
|
||||||
try {
|
try {
|
||||||
// Clean up UI state first
|
// Clean up UI state first
|
||||||
cleanupProjectState();
|
cleanupProjectState();
|
||||||
@@ -83,7 +107,13 @@ const Toolbar: React.FC = () => {
|
|||||||
|
|
||||||
// Load the project using the store's loadProject method
|
// Load the project using the store's loadProject method
|
||||||
const { loadProject: storeLoadProject } = useProjectStore.getState();
|
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
|
// Update status to indicate project loaded
|
||||||
setStatus(`${sourceDescription} loaded successfully`);
|
setStatus(`${sourceDescription} loaded successfully`);
|
||||||
@@ -99,74 +129,56 @@ const Toolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler functions for file operations
|
// Core logic for creating a new project (no confirmation dialog)
|
||||||
const handleNewProject = () => {
|
const createNewProject = () => {
|
||||||
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) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("user clicked new button");
|
console.log("creating new project");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up UI state first
|
|
||||||
cleanupProjectState();
|
cleanupProjectState();
|
||||||
|
|
||||||
// Automatically clear chat history when creating a new project
|
|
||||||
clearChatHistoryAndUI();
|
clearChatHistoryAndUI();
|
||||||
|
|
||||||
// Create a new project with default parameters
|
|
||||||
const newProject = new KGProject();
|
const newProject = new KGProject();
|
||||||
|
|
||||||
// Load the new project using the store's loadProject method
|
|
||||||
const { loadProject: storeLoadProject } = useProjectStore.getState();
|
const { loadProject: storeLoadProject } = useProjectStore.getState();
|
||||||
storeLoadProject(newProject);
|
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`);
|
setStatus(`New project "${newProject.getName()}" created`);
|
||||||
|
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("new project created successfully");
|
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) {
|
||||||
|
createNewProject();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadProject = async () => {
|
const handleLoadProject = () => {
|
||||||
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) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("user clicked load button");
|
console.log("user clicked load button");
|
||||||
}
|
}
|
||||||
|
setShowOpenProject(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Ask user for project name
|
const handleOpenProjectSelect = async (projectNameToLoad: string) => {
|
||||||
const projectNameToLoad = window.prompt("Enter the project name to load:");
|
|
||||||
|
|
||||||
// Check if user input is empty or null (user cancelled)
|
|
||||||
if (!projectNameToLoad || projectNameToLoad.trim() === '') {
|
|
||||||
if (projectNameToLoad !== null) { // Only show error if user didn't cancel
|
|
||||||
window.alert("Project name cannot be empty. Please enter a valid project name.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to load the project from storage
|
|
||||||
const storage = KGProjectStorage.getInstance();
|
const storage = KGProjectStorage.getInstance();
|
||||||
const loadedProject = await storage.load(projectNameToLoad.trim());
|
const loadedProject = await storage.load(projectNameToLoad);
|
||||||
|
|
||||||
if (!loadedProject) {
|
if (!loadedProject) {
|
||||||
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
|
window.alert(`Project "${projectNameToLoad}" not found.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use common loading logic
|
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
|
||||||
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading project:", error);
|
console.error("Error loading project:", error);
|
||||||
window.alert(`An error occurred while loading the project: ${error}`);
|
window.alert(`An error occurred while loading the project: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProject = async () => {
|
const handleSaveProject = async () => {
|
||||||
@@ -174,7 +186,12 @@ const Toolbar: React.FC = () => {
|
|||||||
console.log("user clicked save button");
|
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) => {
|
const handleExportProject = (exportType: string) => {
|
||||||
@@ -186,6 +203,10 @@ const Toolbar: React.FC = () => {
|
|||||||
handleExportKGStudio();
|
handleExportKGStudio();
|
||||||
} else if (exportType === "Export to MIDI file") {
|
} else if (exportType === "Export to MIDI file") {
|
||||||
handleExportMIDI();
|
handleExportMIDI();
|
||||||
|
} else if (exportType === "Export to WAV") {
|
||||||
|
handleBounceToWav();
|
||||||
|
} else if (exportType === "Export to MP3") {
|
||||||
|
handleBounceToMp3();
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowExportDropdown(false);
|
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 = () => {
|
const handleImportProject = () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("user clicked import button");
|
console.log("user clicked import button");
|
||||||
@@ -317,7 +370,7 @@ const Toolbar: React.FC = () => {
|
|||||||
throw new Error('Failed to load imported project');
|
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) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("KGStudio file imported successfully:", projectName);
|
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');
|
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) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
|
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
|
// Handle copy button click
|
||||||
const handleCopyClick = () => {
|
const handleCopyClick = () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
@@ -766,6 +829,13 @@ const Toolbar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FaPencil />
|
<FaPencil />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
title="Snap to Grid"
|
||||||
|
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||||
|
onClick={handleSnappingToggle}
|
||||||
|
>
|
||||||
|
<FaMagnet />
|
||||||
|
</button>
|
||||||
<div className="toolbar-separator"></div>
|
<div className="toolbar-separator"></div>
|
||||||
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
|
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
|
||||||
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
|
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
|
||||||
@@ -792,6 +862,28 @@ const Toolbar: React.FC = () => {
|
|||||||
|
|
||||||
<div className="toolbar-right">
|
<div className="toolbar-right">
|
||||||
<div className="transport-control">
|
<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">
|
<div className="transport-item">
|
||||||
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -836,6 +928,15 @@ const Toolbar: React.FC = () => {
|
|||||||
title="Import Project"
|
title="Import Project"
|
||||||
description="Drag and drop your project file here"
|
description="Drag and drop your project file here"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showOpenProject && (
|
||||||
|
<OpenProjectModal
|
||||||
|
onClose={() => setShowOpenProject(false)}
|
||||||
|
onOpenProject={handleOpenProjectSelect}
|
||||||
|
currentProjectName={savedProjectName}
|
||||||
|
onCreateNewProject={createNewProject}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
z-index: 20000;
|
z-index: 20000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)} · Updated {formatDate(project.updatedAt)}
|
||||||
|
{viewMode === 'trash' && project.deletedAt && (
|
||||||
|
<> · 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;
|
||||||
@@ -2,3 +2,4 @@ export { default as KGDropdown } from './KGDropdown';
|
|||||||
export { default as Playhead } from './Playhead';
|
export { default as Playhead } from './Playhead';
|
||||||
export { default as FileImportModal } from './FileImportModal';
|
export { default as FileImportModal } from './FileImportModal';
|
||||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||||
|
export { default as OpenProjectModal } from './OpenProjectModal';
|
||||||
@@ -71,6 +71,32 @@
|
|||||||
position: relative; /* Allow overlayed controls */
|
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 trigger inside content */
|
||||||
.region-pencil-btn {
|
.region-pencil-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FaPencilAlt } from 'react-icons/fa';
|
|||||||
import type { ResizeAction } from '../interfaces';
|
import type { ResizeAction } from '../interfaces';
|
||||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ interface RegionItemProps {
|
|||||||
onOpenPianoRoll?: (regionId: string) => void;
|
onOpenPianoRoll?: (regionId: string) => void;
|
||||||
// MIDI region data for rendering notes
|
// MIDI region data for rendering notes
|
||||||
midiRegion?: KGMidiRegion;
|
midiRegion?: KGMidiRegion;
|
||||||
|
// Audio region data for rendering waveform
|
||||||
|
audioRegion?: KGAudioRegion;
|
||||||
|
audioBuffer?: AudioBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegionItem: React.FC<RegionItemProps> = ({
|
const RegionItem: React.FC<RegionItemProps> = ({
|
||||||
@@ -45,10 +49,12 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
onDragEnd,
|
onDragEnd,
|
||||||
onClick,
|
onClick,
|
||||||
onOpenPianoRoll,
|
onOpenPianoRoll,
|
||||||
midiRegion
|
midiRegion,
|
||||||
|
audioRegion,
|
||||||
|
audioBuffer
|
||||||
}) => {
|
}) => {
|
||||||
// Get selection state and time signature from store
|
// Get selection state and time signature from store
|
||||||
const { selectedRegionIds, timeSignature } = useProjectStore();
|
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||||
const isSelected = selectedRegionIds.includes(id);
|
const isSelected = selectedRegionIds.includes(id);
|
||||||
const [cursor, setCursor] = useState<string>('pointer');
|
const [cursor, setCursor] = useState<string>('pointer');
|
||||||
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
|
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
|
// Create a stable reference to track note changes
|
||||||
const notesRef = useRef<string>('');
|
const notesRef = useRef<string>('');
|
||||||
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
|
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
|
||||||
@@ -226,15 +302,23 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
|
|
||||||
// Set up canvas when component mounts or updates
|
// Set up canvas when component mounts or updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (audioRegion && audioBuffer) {
|
||||||
|
renderWaveformOnCanvas();
|
||||||
|
} else {
|
||||||
renderNotesOnCanvas();
|
renderNotesOnCanvas();
|
||||||
}, [midiRegion, timeSignature, id, noteUpdateTrigger]);
|
}
|
||||||
|
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
|
||||||
|
|
||||||
// Re-render canvas when region content size changes
|
// Re-render canvas when region content size changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!regionContentRef.current) return;
|
if (!regionContentRef.current) return;
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (audioRegion && audioBuffer) {
|
||||||
|
renderWaveformOnCanvas();
|
||||||
|
} else {
|
||||||
renderNotesOnCanvas();
|
renderNotesOnCanvas();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(regionContentRef.current);
|
resizeObserver.observe(regionContentRef.current);
|
||||||
@@ -244,7 +328,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
resizeObserver.unobserve(regionContentRef.current);
|
resizeObserver.unobserve(regionContentRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [midiRegion, timeSignature]);
|
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
|
||||||
|
|
||||||
// Handle mouse movement to detect edge proximity
|
// Handle mouse movement to detect edge proximity
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -453,7 +537,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
|
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''} ${audioRegion ? 'audio-region' : ''}`}
|
||||||
style={{ ...style, cursor }}
|
style={{ ...style, cursor }}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
@@ -464,9 +548,10 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
data-is-dragging={isDragging}
|
data-is-dragging={isDragging}
|
||||||
>
|
>
|
||||||
<div className="region-header">
|
<div className="region-header">
|
||||||
{name}
|
{audioRegion ? audioRegion.getAudioFileName() : name}
|
||||||
</div>
|
</div>
|
||||||
<div className="region-content" ref={regionContentRef}>
|
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
||||||
|
{!audioRegion && (
|
||||||
<button
|
<button
|
||||||
className="region-pencil-btn"
|
className="region-pencil-btn"
|
||||||
title="Edit notes"
|
title="Edit notes"
|
||||||
@@ -490,6 +575,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
>
|
>
|
||||||
<FaPencilAlt size={10} />
|
<FaPencilAlt size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { KGTrack } from '../../core/track/KGTrack';
|
import { KGTrack } from '../../core/track/KGTrack';
|
||||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import RegionItem from './RegionItem';
|
import RegionItem from './RegionItem';
|
||||||
import type { RegionUI, ResizeAction } from '../interfaces';
|
import type { RegionUI, ResizeAction } from '../interfaces';
|
||||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
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 the mouse was moved and we have current values, calculate the new values
|
||||||
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
|
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
|
||||||
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
|
||||||
if (resizeAction === 'end') {
|
if (resizeAction === 'end') {
|
||||||
// End resize: round length to nearest bar
|
// End resize: snap length to nearest bar, or use raw value
|
||||||
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, Math.round(currentResizeWidth.current / barWidth));
|
const rawLength = currentResizeWidth.current / barWidth;
|
||||||
|
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, snap ? Math.round(rawLength) : rawLength);
|
||||||
} else if (resizeAction === 'start') {
|
} 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;
|
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
|
// Calculate the difference from the initial position
|
||||||
const barDiff = initialBarNumberRef.current! - newBarNumber;
|
const barDiff = initialBarNumberRef.current! - newBarNumber;
|
||||||
@@ -432,9 +437,10 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// If the mouse was moved, calculate the final position
|
// If the mouse was moved, calculate the final position
|
||||||
if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) {
|
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;
|
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
|
// Calculate the closest track based on vertical position
|
||||||
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
|
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
|
||||||
@@ -506,8 +512,19 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Render regions for this track */}
|
{/* Render regions for this track */}
|
||||||
{trackRegions.map(region => {
|
{trackRegions.map(region => {
|
||||||
// Find the corresponding KGMidiRegion in the track
|
// Find the corresponding region in the track
|
||||||
const midiRegion = track.getRegions().find(r => r.getId() === region.id) as KGMidiRegion | undefined;
|
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 (
|
return (
|
||||||
<RegionItem
|
<RegionItem
|
||||||
@@ -526,8 +543,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
onDragEnd={handleRegionDragEnd}
|
onDragEnd={handleRegionDragEnd}
|
||||||
// Keep onClick for selection-only logic if needed by parent
|
// Keep onClick for selection-only logic if needed by parent
|
||||||
onClick={handleRegionClick}
|
onClick={handleRegionClick}
|
||||||
// New explicit pencil action
|
// New explicit pencil action — disabled for audio regions
|
||||||
onOpenPianoRoll={(regionId) => {
|
onOpenPianoRoll={audioRegion ? undefined : (regionId) => {
|
||||||
if (onOpenPianoRoll) {
|
if (onOpenPianoRoll) {
|
||||||
onOpenPianoRoll(regionId);
|
onOpenPianoRoll(regionId);
|
||||||
} else if (onRegionClick) {
|
} else if (onRegionClick) {
|
||||||
@@ -536,6 +553,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
midiRegion={midiRegion}
|
midiRegion={midiRegion}
|
||||||
|
audioRegion={audioRegion}
|
||||||
|
audioBuffer={audioBuffer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useRef } from 'react';
|
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 { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
import TrackGridItem from './TrackGridItem';
|
import TrackGridItem from './TrackGridItem';
|
||||||
import { Playhead } from '../common';
|
import { Playhead } from '../common';
|
||||||
import type { RegionUI } from '../interfaces';
|
import type { RegionUI } from '../interfaces';
|
||||||
import { DEBUG_MODE } from '../../constants';
|
import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
||||||
import { KGCore } from '../../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import { generateNewRegionName } from '../../util/miscUtil';
|
import { generateNewRegionName } from '../../util/miscUtil';
|
||||||
|
|
||||||
interface TrackGridPanelProps {
|
interface TrackGridPanelProps {
|
||||||
@@ -65,6 +67,11 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const track = tracks[trackIndex];
|
const track = tracks[trackIndex];
|
||||||
const trackId = track.getId().toString();
|
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) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`Creating region on track ${trackIndex + 1}, bar ${barNumber}`);
|
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
|
// Calculate new start and length in beats
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const newStartBeat = (finalBarNumber - 1) * beatsPerBar;
|
let clampedBarNumber = finalBarNumber;
|
||||||
const newLengthInBeats = finalLength * beatsPerBar;
|
let clampedLength = finalLength;
|
||||||
|
|
||||||
// Find the track that contains this region
|
// Find the track that contains this region
|
||||||
const track = tracks.find(t => t.getId().toString() === region.trackId);
|
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
|
// Update the region in the track's model
|
||||||
const trackRegions = track.getRegions();
|
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) {
|
if (coreRegion) {
|
||||||
const oldStartBeat = midiRegion.getStartFromBeat();
|
const oldStartBeat = coreRegion.getStartFromBeat();
|
||||||
const oldBarNumber = region.barNumber;
|
const oldBarNumber = region.barNumber;
|
||||||
|
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
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}`);
|
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)
|
// Use command pattern to update the region position and length (note adjustments handled inside command)
|
||||||
try {
|
try {
|
||||||
const command = ResizeRegionCommand.fromBarCoordinates(
|
const command = ResizeRegionCommand.fromBarCoordinates(
|
||||||
regionId,
|
regionId,
|
||||||
finalBarNumber,
|
clampedBarNumber,
|
||||||
finalLength,
|
clampedLength,
|
||||||
timeSignature
|
timeSignature,
|
||||||
|
newClipStartOffsetSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
@@ -201,16 +255,16 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
console.error('Error resizing region:', error);
|
console.error('Error resizing region:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update the region in the parent component with expected model values
|
// Update the region in the parent component with expected model values
|
||||||
if (onRegionUpdated) {
|
if (onRegionUpdated) {
|
||||||
onRegionUpdated(
|
onRegionUpdated(
|
||||||
regionId,
|
regionId,
|
||||||
{ barNumber: finalBarNumber, length: finalLength },
|
{ barNumber: clampedBarNumber, length: clampedLength },
|
||||||
{ startBeat: newStartBeat, length: newLengthInBeats }
|
{ startBeat: newStartBeat, length: newLengthInBeats }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle region drag during movement
|
// Handle region drag during movement
|
||||||
@@ -239,6 +293,18 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const targetTrack = tracks[finalTrackIndex];
|
const targetTrack = tracks[finalTrackIndex];
|
||||||
if (!targetTrack) return;
|
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
|
// Use command pattern to move the region
|
||||||
try {
|
try {
|
||||||
const command = MoveRegionCommand.fromBarCoordinates(
|
const command = MoveRegionCommand.fromBarCoordinates(
|
||||||
@@ -251,6 +317,19 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
|
|
||||||
KGCore.instance().executeCommand(command);
|
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) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`Executed MoveRegionCommand: region ${regionId} moved using command pattern`);
|
console.log(`Executed MoveRegionCommand: region ${regionId} moved using command pattern`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { KGTrack } from '../../core/track/KGTrack';
|
import { KGTrack } from '../../core/track/KGTrack';
|
||||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
|
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { TbPiano } from 'react-icons/tb';
|
import { TbPiano } from 'react-icons/tb';
|
||||||
import { TbSettings } from 'react-icons/tb';
|
import { TbSettings } from 'react-icons/tb';
|
||||||
|
import { FaFileAudio } from 'react-icons/fa';
|
||||||
import KGDropdown from '../common/KGDropdown';
|
import KGDropdown from '../common/KGDropdown';
|
||||||
|
import FileImportModal from '../common/FileImportModal';
|
||||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
@@ -34,7 +37,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd
|
onDragEnd
|
||||||
}) => {
|
}) => {
|
||||||
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, tracks: allTracks } = useProjectStore();
|
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
|
||||||
const isSelected = selectedTrackId === track.getId().toString();
|
const isSelected = selectedTrackId === track.getId().toString();
|
||||||
// Inline instrument dropdown removed; use InstrumentSelection panel instead
|
// Inline instrument dropdown removed; use InstrumentSelection panel instead
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
|
|
||||||
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
|
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
|
||||||
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
||||||
|
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const suppressDragRef = useRef(false);
|
const suppressDragRef = useRef(false);
|
||||||
const [volume, setVolume] = useState(track.getVolume());
|
const [volume, setVolume] = useState(track.getVolume());
|
||||||
@@ -195,6 +199,8 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
|
|
||||||
// Inline instrument change removed; handled by InstrumentSelection panel
|
// Inline instrument change removed; handled by InstrumentSelection panel
|
||||||
|
|
||||||
|
const isAudioTrack = track instanceof KGAudioTrack;
|
||||||
|
|
||||||
// Handle piano button click
|
// Handle piano button click
|
||||||
const handlePianoButtonClick = (e: React.MouseEvent) => {
|
const handlePianoButtonClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -204,6 +210,19 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
toggleInstrumentSelectionForTrack();
|
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
|
// Handle settings button click
|
||||||
const handleSettingsButtonClick = (e: React.MouseEvent) => {
|
const handleSettingsButtonClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -253,12 +272,21 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
<div className="track-controls">
|
<div className="track-controls">
|
||||||
<div className="track-name-and-volume">
|
<div className="track-name-and-volume">
|
||||||
<div className="instrument-image">
|
<div className="instrument-image">
|
||||||
|
{isAudioTrack ? (
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.BASE_URL}resources/instruments/speaker.png`}
|
||||||
|
alt="Audio Track"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
|
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)}
|
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
|
||||||
width="64"
|
width="64"
|
||||||
height="64"
|
height="64"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="track-name-and-controls">
|
<div className="track-name-and-controls">
|
||||||
<div
|
<div
|
||||||
@@ -296,9 +324,15 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
|
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
|
||||||
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
|
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
|
||||||
<div>
|
<div>
|
||||||
|
{isAudioTrack ? (
|
||||||
|
<button className="instrument" onClick={handleAudioImportClick} title="Import Audio">
|
||||||
|
<FaFileAudio />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button className="instrument" onClick={handlePianoButtonClick}>
|
<button className="instrument" onClick={handlePianoButtonClick}>
|
||||||
<TbPiano />
|
<TbPiano />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
|
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
|
||||||
<button className="settings" onClick={handleSettingsButtonClick}>
|
<button className="settings" onClick={handleSettingsButtonClick}>
|
||||||
@@ -319,6 +353,16 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const DEBUG_MODE = {
|
|||||||
|
|
||||||
// Toolbar related constants
|
// Toolbar related constants
|
||||||
export const TOOLBAR_CONSTANTS = {
|
export const TOOLBAR_CONSTANTS = {
|
||||||
|
BASE_BAR_WIDTH: 40, // matches --track-grid-bar-width default in variables.css
|
||||||
};
|
};
|
||||||
|
|
||||||
// Region related constants
|
// Region related constants
|
||||||
|
|||||||
+75
-2
@@ -30,6 +30,7 @@ export class KGDebugger {
|
|||||||
'testToolCall(jsonInput)',
|
'testToolCall(jsonInput)',
|
||||||
'inputChatBox(content, interval?)',
|
'inputChatBox(content, interval?)',
|
||||||
'opfs(command)',
|
'opfs(command)',
|
||||||
|
'startShell()',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +346,8 @@ export class KGDebugger {
|
|||||||
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
|
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
|
||||||
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
|
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(" 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(" help() - Show this help");
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log("💡 Usage tips:");
|
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 ---
|
// --- OPFS Shell ---
|
||||||
|
|
||||||
/** Current working directory path segments (relative to OPFS root) */
|
/** 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)
|
* cd <path> — change directory (supports .., /, relative, and quoted paths)
|
||||||
* cat <file> — print file contents
|
* cat <file> — print file contents
|
||||||
* dl <file> — download a file to your local machine
|
* dl <file> — download a file to your local machine
|
||||||
|
* rm <name> — remove a file or directory (recursive)
|
||||||
*
|
*
|
||||||
* Usage in console:
|
* Usage in console:
|
||||||
* await KGDebugger.opfs('pwd')
|
* await KGDebugger.opfs('pwd')
|
||||||
@@ -498,9 +552,13 @@ export class KGDebugger {
|
|||||||
await this.opfsDl(arg)
|
await this.opfsDl(arg)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'rm':
|
||||||
|
await this.opfsRm(arg)
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log(`opfs: command not found: ${cmd}`)
|
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) {
|
} catch (error) {
|
||||||
console.error(`opfs: ${error}`)
|
console.error(`opfs: ${error}`)
|
||||||
@@ -660,4 +718,19 @@ export class KGDebugger {
|
|||||||
console.error(`opfs: dl: ${fileName}: No such file`)
|
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
@@ -1,6 +1,7 @@
|
|||||||
import { Expose, Type } from 'class-transformer';
|
import { Expose, Type } from 'class-transformer';
|
||||||
import { KGTrack } from './track/KGTrack';
|
import { KGTrack } from './track/KGTrack';
|
||||||
import { KGMidiTrack } from './track/KGMidiTrack';
|
import { KGMidiTrack } from './track/KGMidiTrack';
|
||||||
|
import { KGAudioTrack } from './track/KGAudioTrack';
|
||||||
import { type TimeSignature, WithDefault } from '../types/projectTypes';
|
import { type TimeSignature, WithDefault } from '../types/projectTypes';
|
||||||
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
|
|
||||||
@@ -43,11 +44,15 @@ export class KGProject {
|
|||||||
@WithDefault([0, 0])
|
@WithDefault([0, 0])
|
||||||
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
|
private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based)
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(1)
|
||||||
|
private barWidthMultiplier: number = 1;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 3;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
@@ -56,13 +61,14 @@ export class KGProject {
|
|||||||
subTypes: [
|
subTypes: [
|
||||||
{ value: KGTrack, name: 'KGTrack' },
|
{ value: KGTrack, name: 'KGTrack' },
|
||||||
{ value: KGMidiTrack, name: 'KGMidiTrack' },
|
{ value: KGMidiTrack, name: 'KGMidiTrack' },
|
||||||
|
{ value: KGAudioTrack, name: 'KGAudioTrack' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
private tracks: KGTrack[] = [];
|
private tracks: KGTrack[] = [];
|
||||||
|
|
||||||
// Constructor
|
// Constructor
|
||||||
constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", 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.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -72,6 +78,7 @@ export class KGProject {
|
|||||||
this.selectedMode = selectedMode;
|
this.selectedMode = selectedMode;
|
||||||
this.isLooping = isLooping;
|
this.isLooping = isLooping;
|
||||||
this.loopingRange = loopingRange;
|
this.loopingRange = loopingRange;
|
||||||
|
this.barWidthMultiplier = barWidthMultiplier;
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
}
|
}
|
||||||
@@ -165,5 +172,13 @@ export class KGProject {
|
|||||||
public setLoopingRange(loopingRange: [number, number]): void {
|
public setLoopingRange(loopingRange: [number, number]): void {
|
||||||
this.loopingRange = loopingRange;
|
this.loopingRange = loopingRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getBarWidthMultiplier(): number {
|
||||||
|
return this.barWidthMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBarWidthMultiplier(barWidthMultiplier: number): void {
|
||||||
|
this.barWidthMultiplier = barWidthMultiplier;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
|||||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { KGAudioBus } from './KGAudioBus';
|
import { KGAudioBus } from './KGAudioBus';
|
||||||
|
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||||
|
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
import { KGCore } from '../KGCore';
|
import { KGCore } from '../KGCore';
|
||||||
import { ConfigManager } from '../config/ConfigManager';
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
|
|
||||||
@@ -15,6 +17,13 @@ import { ConfigManager } from '../config/ConfigManager';
|
|||||||
* Abstracts audio engine implementation (Tone.js) for potential future replacement
|
* Abstracts audio engine implementation (Tone.js) for potential future replacement
|
||||||
*/
|
*/
|
||||||
export class KGAudioInterface {
|
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 for singleton pattern
|
||||||
private static _instance: KGAudioInterface | null = null;
|
private static _instance: KGAudioInterface | null = null;
|
||||||
|
|
||||||
@@ -25,6 +34,9 @@ export class KGAudioInterface {
|
|||||||
// Track management - now using KGAudioBus
|
// Track management - now using KGAudioBus
|
||||||
private trackAudioBuses: Map<string, KGAudioBus> = new Map();
|
private trackAudioBuses: Map<string, KGAudioBus> = new Map();
|
||||||
|
|
||||||
|
// Audio player buses for audio/wav tracks
|
||||||
|
private trackAudioPlayerBuses: Map<string, KGAudioPlayerBus> = new Map();
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
private isPlaying: boolean = false;
|
private isPlaying: boolean = false;
|
||||||
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
||||||
@@ -126,6 +138,12 @@ export class KGAudioInterface {
|
|||||||
});
|
});
|
||||||
this.trackAudioBuses.clear();
|
this.trackAudioBuses.clear();
|
||||||
|
|
||||||
|
// Dispose of all audio player buses
|
||||||
|
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||||
|
playerBus.dispose();
|
||||||
|
});
|
||||||
|
this.trackAudioPlayerBuses.clear();
|
||||||
|
|
||||||
// Dispose master gain
|
// Dispose master gain
|
||||||
if (this.masterGain) {
|
if (this.masterGain) {
|
||||||
this.masterGain.dispose();
|
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)
|
* 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)
|
// Set project BPM and time signature FIRST (this affects timing calculations)
|
||||||
Tone.Transport.bpm.value = project.getBpm();
|
Tone.Transport.bpm.value = project.getBpm();
|
||||||
const timeSignature = project.getTimeSignature();
|
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];
|
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||||
|
|
||||||
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
||||||
@@ -309,6 +437,7 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
|
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
|
||||||
|
|
||||||
|
// Schedule MIDI track events
|
||||||
if (audioBus && track.getType() === 'MIDI') {
|
if (audioBus && track.getType() === 'MIDI') {
|
||||||
track.getRegions().forEach(region => {
|
track.getRegions().forEach(region => {
|
||||||
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
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`);
|
console.log(`Prepared playback from position ${startPosition} with ${this.scheduledEvents.size} events`);
|
||||||
@@ -405,6 +651,11 @@ export class KGAudioInterface {
|
|||||||
audioBus.releaseAll();
|
audioBus.releaseAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stop all audio player buses
|
||||||
|
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||||
|
playerBus.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
|
|
||||||
console.log('Audio playback stopped');
|
console.log('Audio playback stopped');
|
||||||
@@ -560,10 +811,14 @@ export class KGAudioInterface {
|
|||||||
public setTrackVolume(trackId: string, volume: number): void {
|
public setTrackVolume(trackId: string, volume: number): void {
|
||||||
try {
|
try {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
const audioBus = this.trackAudioBuses.get(trackId);
|
||||||
|
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||||
if (audioBus) {
|
if (audioBus) {
|
||||||
audioBus.setVolume(volume);
|
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}`);
|
console.warn(`No audio bus found for track ${trackId}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -577,14 +832,18 @@ export class KGAudioInterface {
|
|||||||
public setTrackMute(trackId: string, muted: boolean): void {
|
public setTrackMute(trackId: string, muted: boolean): void {
|
||||||
try {
|
try {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
const audioBus = this.trackAudioBuses.get(trackId);
|
||||||
|
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||||
if (audioBus) {
|
if (audioBus) {
|
||||||
audioBus.setMuted(muted);
|
audioBus.setMuted(muted);
|
||||||
console.log(`Set track ${trackId} mute to ${muted}`);
|
}
|
||||||
// Recompute effective volumes across all buses (solo logic)
|
if (playerBus) {
|
||||||
this.updateAllEffectiveVolumes();
|
playerBus.setMuted(muted);
|
||||||
} else {
|
}
|
||||||
|
if (!audioBus && !playerBus) {
|
||||||
console.warn(`No audio bus found for track ${trackId}`);
|
console.warn(`No audio bus found for track ${trackId}`);
|
||||||
}
|
}
|
||||||
|
// Recompute effective volumes across all buses (solo logic)
|
||||||
|
this.updateAllEffectiveVolumes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error setting track ${trackId} mute:`, error);
|
console.error(`Error setting track ${trackId} mute:`, error);
|
||||||
}
|
}
|
||||||
@@ -596,14 +855,18 @@ export class KGAudioInterface {
|
|||||||
public setTrackSolo(trackId: string, solo: boolean): void {
|
public setTrackSolo(trackId: string, solo: boolean): void {
|
||||||
try {
|
try {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
const audioBus = this.trackAudioBuses.get(trackId);
|
||||||
|
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||||
if (audioBus) {
|
if (audioBus) {
|
||||||
audioBus.setSolo(solo);
|
audioBus.setSolo(solo);
|
||||||
console.log(`Set track ${trackId} solo to ${solo}`);
|
}
|
||||||
// Recompute effective volumes across all buses (solo logic)
|
if (playerBus) {
|
||||||
this.updateAllEffectiveVolumes();
|
playerBus.setSolo(solo);
|
||||||
} else {
|
}
|
||||||
|
if (!audioBus && !playerBus) {
|
||||||
console.warn(`No audio bus found for track ${trackId}`);
|
console.warn(`No audio bus found for track ${trackId}`);
|
||||||
}
|
}
|
||||||
|
// Recompute effective volumes across all buses (solo logic)
|
||||||
|
this.updateAllEffectiveVolumes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error setting track ${trackId} solo:`, error);
|
console.error(`Error setting track ${trackId} solo:`, error);
|
||||||
}
|
}
|
||||||
@@ -646,17 +909,20 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
public getTrackVolume(trackId: string): number {
|
public getTrackVolume(trackId: string): number {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
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 {
|
public getTrackMuted(trackId: string): boolean {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
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 {
|
public getTrackSolo(trackId: string): boolean {
|
||||||
const audioBus = this.trackAudioBuses.get(trackId);
|
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 {
|
public getMasterVolume(): number {
|
||||||
@@ -692,7 +958,8 @@ export class KGAudioInterface {
|
|||||||
* Check if any tracks are currently soloed
|
* Check if any tracks are currently soloed
|
||||||
*/
|
*/
|
||||||
private hasSoloedTracks(): boolean {
|
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 {
|
try {
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
this.trackAudioBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
this.trackAudioBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
||||||
|
this.trackAudioPlayerBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating effective volumes:', 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
|
* Convert ToneAudioBuffers to the URL format expected by Tone.Sampler
|
||||||
* This creates a mapping from note names to the actual audio buffers
|
* 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 } = {};
|
const urls: { [key: string]: Tone.ToneAudioBuffer } = {};
|
||||||
|
|
||||||
// Note names in order (using flats instead of sharps where applicable)
|
// Note names in order (using flats instead of sharps where applicable)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { KGCommandHistory } from './KGCommandHistory';
|
|||||||
|
|
||||||
// Track commands
|
// Track commands
|
||||||
export { AddTrackCommand } from './track/AddTrackCommand';
|
export { AddTrackCommand } from './track/AddTrackCommand';
|
||||||
|
export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
|
||||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
||||||
@@ -19,6 +20,7 @@ export { ResizeRegionCommand } from './region/ResizeRegionCommand';
|
|||||||
export { MoveRegionCommand } from './region/MoveRegionCommand';
|
export { MoveRegionCommand } from './region/MoveRegionCommand';
|
||||||
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
||||||
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
||||||
|
export { ImportAudioCommand } from './region/ImportAudioCommand';
|
||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
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 { KGCore } from '../../KGCore';
|
||||||
import { KGRegion } from '../../region/KGRegion';
|
import { KGRegion } from '../../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,11 +24,16 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
originalEndBeat: number;
|
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();
|
super();
|
||||||
this.regionId = regionId;
|
this.regionId = regionId;
|
||||||
this.newStartFromBeat = newStartFromBeat;
|
this.newStartFromBeat = newStartFromBeat;
|
||||||
this.newLength = newLength;
|
this.newLength = newLength;
|
||||||
|
this.newClipStartOffsetSeconds = newClipStartOffsetSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): void {
|
||||||
@@ -57,7 +63,7 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
this.originalStartFromBeat = targetRegion.getStartFromBeat();
|
||||||
this.originalLength = targetRegion.getLength();
|
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) {
|
if (this.newStartFromBeat !== this.originalStartFromBeat && targetRegion instanceof KGMidiRegion) {
|
||||||
const beatOffset = this.newStartFromBeat - this.originalStartFromBeat;
|
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`);
|
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
|
// Apply the resize
|
||||||
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
targetRegion.setStartFromBeat(this.newStartFromBeat);
|
||||||
targetRegion.setLength(this.newLength);
|
targetRegion.setLength(this.newLength);
|
||||||
@@ -108,6 +123,12 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
|
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
|
// Restore original region values
|
||||||
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
this.targetRegion.setStartFromBeat(this.originalStartFromBeat);
|
||||||
this.targetRegion.setLength(this.originalLength);
|
this.targetRegion.setLength(this.originalLength);
|
||||||
@@ -184,12 +205,13 @@ export class ResizeRegionCommand extends KGCommand {
|
|||||||
regionId: string,
|
regionId: string,
|
||||||
newBarNumber: number,
|
newBarNumber: number,
|
||||||
newLengthInBars: number,
|
newLengthInBars: number,
|
||||||
timeSignature: { numerator: number; denominator: number }
|
timeSignature: { numerator: number; denominator: number },
|
||||||
|
newClipStartOffsetSeconds?: number
|
||||||
): ResizeRegionCommand {
|
): ResizeRegionCommand {
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
|
const newStartFromBeat = (newBarNumber - 1) * beatsPerBar;
|
||||||
const newLength = newLengthInBars * 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();
|
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();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
audioInterface.removeTrackSynth(this.trackId.toString());
|
audioInterface.removeTrackSynth(this.trackId.toString());
|
||||||
|
audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
|
||||||
|
|
||||||
// Remove the track from the core model
|
// Remove the track from the core model
|
||||||
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
|
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
|
||||||
@@ -85,11 +86,15 @@ export class RemoveTrackCommand extends KGCommand {
|
|||||||
// Update the core model
|
// Update the core model
|
||||||
currentProject.setTracks(updatedTracks);
|
currentProject.setTracks(updatedTracks);
|
||||||
|
|
||||||
// Recreate audio synth for the track
|
// Recreate audio bus for the track
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
if (this.removedTrack.getCurrentType() === 'KGAudioTrack') {
|
||||||
|
audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
|
||||||
|
} else {
|
||||||
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
|
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 {
|
getDescription(): string {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
|
|||||||
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
|
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
|
||||||
import { isValidProjectName } from '../../util/projectNameUtil';
|
import { isValidProjectName } from '../../util/projectNameUtil';
|
||||||
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
|
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
|
||||||
|
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
|
|
||||||
export class DuplicateEntryError extends Error {
|
export class DuplicateEntryError extends Error {
|
||||||
constructor(name: string) {
|
constructor(name: string) {
|
||||||
@@ -12,10 +13,11 @@ export class DuplicateEntryError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectMeta {
|
export interface ProjectMeta {
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
deletedAt?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +156,137 @@ export class KGProjectStorage {
|
|||||||
return names.sort();
|
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.
|
* Delete a project and all its files.
|
||||||
*/
|
*/
|
||||||
@@ -206,10 +339,31 @@ export class KGProjectStorage {
|
|||||||
project.setName(newName);
|
project.setName(newName);
|
||||||
await this.save(newName, project, false);
|
await this.save(newName, project, false);
|
||||||
|
|
||||||
|
// Copy media files from old to new location
|
||||||
|
await this.copyMediaFiles(oldName, newName);
|
||||||
|
|
||||||
// Delete old location
|
// Delete old location
|
||||||
await this.delete(oldName);
|
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).
|
* Export a project folder as a zip Blob (.kgstudio bundle).
|
||||||
* Includes project.json, meta.json, and all files in media/.
|
* Includes project.json, meta.json, and all files in media/.
|
||||||
@@ -374,6 +528,42 @@ export class KGProjectStorage {
|
|||||||
return candidate;
|
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 ---
|
// --- File I/O helpers ---
|
||||||
|
|
||||||
private async writeFile(
|
private async writeFile(
|
||||||
@@ -395,4 +585,16 @@ export class KGProjectStorage {
|
|||||||
const file = await fileHandle.getFile();
|
const file = await fileHandle.getFile();
|
||||||
return file.text();
|
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 { upgradeToV1 } from './upgradeToV1';
|
||||||
import { upgradeToV2 } from './upgradeToV2';
|
import { upgradeToV2 } from './upgradeToV2';
|
||||||
import { upgradeToV3 } from './upgradeToV3';
|
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.
|
* 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);
|
workingProject = upgradeToV3(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 4: {
|
||||||
|
workingProject = upgradeToV4(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 5: {
|
||||||
|
workingProject = upgradeToV5(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 6: {
|
||||||
|
workingProject = upgradeToV6(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export class KGMainContentState {
|
|||||||
private static _instance: KGMainContentState | null = null;
|
private static _instance: KGMainContentState | null = null;
|
||||||
|
|
||||||
private activeTool: string = "pointer";
|
private activeTool: string = "pointer";
|
||||||
|
private snapping: boolean = true;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
console.log("KGMainContentState initialized");
|
console.log("KGMainContentState initialized");
|
||||||
@@ -26,4 +27,12 @@ export class KGMainContentState {
|
|||||||
public setActiveTool(tool: string): void {
|
public setActiveTool(tool: string): void {
|
||||||
this.activeTool = tool;
|
this.activeTool = tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isSnappingEnabled(): boolean {
|
||||||
|
return this.snapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSnapping(enabled: boolean): void {
|
||||||
|
this.snapping = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Expose, Type } from 'class-transformer';
|
import { Expose, Type } from 'class-transformer';
|
||||||
import { KGRegion } from '../region/KGRegion';
|
import { KGRegion } from '../region/KGRegion';
|
||||||
import { KGMidiRegion } from '../region/KGMidiRegion';
|
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { WithDefault } from '../../types/projectTypes';
|
import { WithDefault } from '../../types/projectTypes';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export class KGTrack {
|
|||||||
subTypes: [
|
subTypes: [
|
||||||
{ value: KGRegion, name: 'KGRegion' },
|
{ value: KGRegion, name: 'KGRegion' },
|
||||||
{ value: KGMidiRegion, name: 'KGMidiRegion' },
|
{ value: KGMidiRegion, name: 'KGMidiRegion' },
|
||||||
|
{ value: KGAudioRegion, name: 'KGAudioRegion' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
|
|||||||
* Handles keyboard shortcuts defined in the configuration
|
* Handles keyboard shortcuts defined in the configuration
|
||||||
*/
|
*/
|
||||||
export const useGlobalKeyboardHandler = () => {
|
export const useGlobalKeyboardHandler = () => {
|
||||||
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName } = useProjectStore();
|
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -39,7 +39,10 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
|
|
||||||
// Early return to handle save immediately
|
// Early return to handle save immediately
|
||||||
try {
|
try {
|
||||||
saveProject(projectName, setStatus);
|
saveProject(projectName, savedProjectName, setStatus, (finalName) => {
|
||||||
|
setSavedProjectName(finalName);
|
||||||
|
if (finalName !== projectName) setProjectName(finalName);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed:', error);
|
console.error('Save failed:', error);
|
||||||
setStatus('Save failed');
|
setStatus('Save failed');
|
||||||
@@ -154,7 +157,10 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
saveProject(projectName, setStatus);
|
saveProject(projectName, savedProjectName, setStatus, (finalName) => {
|
||||||
|
setSavedProjectName(finalName);
|
||||||
|
if (finalName !== projectName) setProjectName(finalName);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed:', error);
|
console.error('Save failed:', error);
|
||||||
setStatus('Save failed');
|
setStatus('Save failed');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { DEBUG_MODE } from '../constants';
|
import { DEBUG_MODE } from '../constants';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGTrack } from '../core/track/KGTrack';
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import type { RegionUI } from '../components/interfaces';
|
import type { RegionUI } from '../components/interfaces';
|
||||||
@@ -47,8 +48,8 @@ export const useRegionOperations = ({
|
|||||||
// Get all selected regions from KGCore
|
// Get all selected regions from KGCore
|
||||||
const selectedItems = core.getSelectedItems();
|
const selectedItems = core.getSelectedItems();
|
||||||
const selectedRegions = selectedItems.filter(item =>
|
const selectedRegions = selectedItems.filter(item =>
|
||||||
item instanceof KGMidiRegion
|
item instanceof KGMidiRegion || item instanceof KGAudioRegion
|
||||||
) as KGMidiRegion[];
|
);
|
||||||
|
|
||||||
if (selectedRegions.length === 0) {
|
if (selectedRegions.length === 0) {
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
|
|||||||
+4
-2
@@ -17,8 +17,10 @@ await KGMidiInput.instance().initialize();
|
|||||||
|
|
||||||
// Attach debugger to global window in development mode
|
// Attach debugger to global window in development mode
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
(window as unknown as { KGDebugger: KGDebugger }).KGDebugger = KGDebugger.instance();
|
const dbg = KGDebugger.instance();
|
||||||
console.log('🔧 KGDebugger attached to window - try: KGDebugger.help()');
|
(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
|
// Start audio context on first user interaction
|
||||||
|
|||||||
+205
-15
@@ -9,10 +9,15 @@ import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
|||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||||
import { KGRegion } from '../core/region/KGRegion';
|
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 { ConfigManager } from '../core/config/ConfigManager';
|
||||||
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
||||||
import { toggleLoop } from '../util/loopUtil';
|
import { toggleLoop } from '../util/loopUtil';
|
||||||
|
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
||||||
|
import * as Tone from 'tone';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* 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());
|
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
|
// Define the store state interface
|
||||||
interface ProjectState {
|
interface ProjectState {
|
||||||
// State
|
// State
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
savedProjectName: string; // OPFS folder name where the project is currently saved
|
||||||
tracks: KGTrack[];
|
tracks: KGTrack[];
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
maxBars: number;
|
maxBars: number;
|
||||||
|
barWidthMultiplier: number;
|
||||||
timeSignature: TimeSignature;
|
timeSignature: TimeSignature;
|
||||||
bpm: number;
|
bpm: number;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
@@ -63,6 +80,10 @@ interface ProjectState {
|
|||||||
showInstrumentSelection: boolean;
|
showInstrumentSelection: boolean;
|
||||||
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
|
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
|
||||||
|
|
||||||
|
// Audio import modal state
|
||||||
|
showAudioImportModal: boolean;
|
||||||
|
audioImportTargetTrackId: string | null;
|
||||||
|
|
||||||
// Settings state
|
// Settings state
|
||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
|
|
||||||
@@ -74,7 +95,12 @@ interface ProjectState {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setProjectName: (name: string) => void;
|
setProjectName: (name: string) => void;
|
||||||
|
setSavedProjectName: (name: string) => void;
|
||||||
addTrack: () => Promise<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>;
|
removeTrack: (id: number) => Promise<void>;
|
||||||
updateTrack: (track: KGTrack) => Promise<void>;
|
updateTrack: (track: KGTrack) => Promise<void>;
|
||||||
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
|
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
|
||||||
@@ -83,13 +109,14 @@ interface ProjectState {
|
|||||||
setStatus: (status: string) => void;
|
setStatus: (status: string) => void;
|
||||||
removeStatus: () => void;
|
removeStatus: () => void;
|
||||||
refreshStatus: () => void;
|
refreshStatus: () => void;
|
||||||
loadProject: (project: KGProject | null) => Promise<void>;
|
loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
|
||||||
setPlayheadPosition: (position: number) => void;
|
setPlayheadPosition: (position: number) => void;
|
||||||
startPlaying: () => Promise<void>;
|
startPlaying: () => Promise<void>;
|
||||||
stopPlaying: () => Promise<void>;
|
stopPlaying: () => Promise<void>;
|
||||||
toggleLoop: () => void;
|
toggleLoop: () => void;
|
||||||
setBpm: (bpm: number) => void;
|
setBpm: (bpm: number) => void;
|
||||||
setMaxBars: (maxBars: number) => void;
|
setMaxBars: (maxBars: number) => void;
|
||||||
|
setBarWidthMultiplier: (multiplier: number) => void;
|
||||||
setTimeSignature: (timeSignature: TimeSignature) => void;
|
setTimeSignature: (timeSignature: TimeSignature) => void;
|
||||||
setKeySignature: (keySignature: KeySignature) => void;
|
setKeySignature: (keySignature: KeySignature) => void;
|
||||||
setSelectedMode: (selectedMode: string) => void;
|
setSelectedMode: (selectedMode: string) => void;
|
||||||
@@ -143,6 +170,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
updateTimeSignatureCSS(currentProject.getTimeSignature());
|
updateTimeSignatureCSS(currentProject.getTimeSignature());
|
||||||
// Initialize CSS variable for max bars on store creation
|
// Initialize CSS variable for max bars on store creation
|
||||||
updateMaxBarsCSS(currentProject.getMaxBars());
|
updateMaxBarsCSS(currentProject.getMaxBars());
|
||||||
|
// Initialize CSS variable for bar width multiplier on store creation
|
||||||
|
updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier());
|
||||||
|
|
||||||
// Get initial ChatBox state from config
|
// Get initial ChatBox state from config
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
@@ -222,9 +251,11 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
return {
|
return {
|
||||||
// Initial state
|
// Initial state
|
||||||
projectName: currentProject.getName(),
|
projectName: currentProject.getName(),
|
||||||
|
savedProjectName: currentProject.getName(),
|
||||||
tracks: currentProject.getTracks() as KGTrack[],
|
tracks: currentProject.getTracks() as KGTrack[],
|
||||||
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
||||||
maxBars: currentProject.getMaxBars(),
|
maxBars: currentProject.getMaxBars(),
|
||||||
|
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
|
||||||
timeSignature: currentProject.getTimeSignature(),
|
timeSignature: currentProject.getTimeSignature(),
|
||||||
bpm: currentProject.getBpm(),
|
bpm: currentProject.getBpm(),
|
||||||
keySignature: currentProject.getKeySignature(),
|
keySignature: currentProject.getKeySignature(),
|
||||||
@@ -250,6 +281,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Initial Instrument Selection panel state
|
// Initial Instrument Selection panel state
|
||||||
showInstrumentSelection: initialShowInstrumentSelection,
|
showInstrumentSelection: initialShowInstrumentSelection,
|
||||||
|
|
||||||
|
// Initial audio import modal state
|
||||||
|
showAudioImportModal: false,
|
||||||
|
audioImportTargetTrackId: null,
|
||||||
|
|
||||||
// Initial Settings state
|
// Initial Settings state
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
|
|
||||||
@@ -276,6 +311,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSavedProjectName: (name: string) => {
|
||||||
|
set({ savedProjectName: name });
|
||||||
|
},
|
||||||
|
|
||||||
addTrack: async () => {
|
addTrack: async () => {
|
||||||
try {
|
try {
|
||||||
// Create and execute the add track command
|
// 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) => {
|
removeTrack: async (id: number) => {
|
||||||
try {
|
try {
|
||||||
// Get the current tracks and find the index of the track being deleted
|
// 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' });
|
set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' });
|
||||||
},
|
},
|
||||||
|
|
||||||
loadProject: async (project: KGProject | null = null) => {
|
loadProject: async (project: KGProject | null = null, savedName?: string) => {
|
||||||
try {
|
try {
|
||||||
const { setPlayheadPosition } = get();
|
const { setPlayheadPosition } = get();
|
||||||
|
|
||||||
@@ -488,28 +638,57 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Setup audio synths for all tracks
|
// Setup audio synths for all tracks
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
|
||||||
// Clear any existing synths first
|
// Clear any existing synths/buses first
|
||||||
tracks.forEach(track => {
|
|
||||||
audioInterface.removeTrackSynth(track.getId().toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create synths for all tracks (with their stored volumes)
|
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
const trackId = track.getId().toString();
|
const trackId = track.getId().toString();
|
||||||
// Get instrument from track model if it's a MIDI track
|
audioInterface.removeTrackSynth(trackId);
|
||||||
let instrument: InstrumentType = 'acoustic_grand_piano'; // Default fallback
|
audioInterface.removeTrackAudioPlayerBus(trackId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create synths/buses for all tracks (with their stored volumes)
|
||||||
|
const projectName = projectToLoad.getName();
|
||||||
|
for (const track of tracks) {
|
||||||
|
const trackId = track.getId().toString();
|
||||||
|
|
||||||
|
if (track.getCurrentType() === 'KGAudioTrack') {
|
||||||
|
// Audio track: create player bus and load audio buffers
|
||||||
|
await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume());
|
||||||
|
|
||||||
|
// Load audio buffers for all regions in this audio track
|
||||||
|
const audioTrack = track as KGAudioTrack;
|
||||||
|
for (const region of audioTrack.getRegions()) {
|
||||||
|
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||||
|
const audioRegion = region as KGAudioRegion;
|
||||||
|
const audioFileId = audioRegion.getAudioFileId();
|
||||||
|
if (audioFileId) {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
|
||||||
|
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||||
|
const decoded = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||||
|
toneBuffer.set(decoded);
|
||||||
|
audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to load audio file ${audioFileId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MIDI track: create sampler-based audio bus
|
||||||
|
let instrument: InstrumentType = 'acoustic_grand_piano';
|
||||||
if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) {
|
if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) {
|
||||||
instrument = (track as KGMidiTrack).getInstrument();
|
instrument = (track as KGMidiTrack).getInstrument();
|
||||||
}
|
}
|
||||||
audioInterface.createTrackSynth(trackId, instrument);
|
audioInterface.createTrackSynth(trackId, instrument);
|
||||||
// Volume is applied during bus creation; ensure sync if bus already existed
|
|
||||||
audioInterface.setTrackVolume(trackId, track.getVolume());
|
audioInterface.setTrackVolume(trackId, track.getVolume());
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update CSS variable for time signature numerator
|
// Update CSS variables
|
||||||
updateTimeSignatureCSS(timeSignature);
|
updateTimeSignatureCSS(timeSignature);
|
||||||
// Update CSS variable for max bars
|
|
||||||
updateMaxBarsCSS(maxBars);
|
updateMaxBarsCSS(maxBars);
|
||||||
|
updateBarWidthMultiplierCSS(projectToLoad.getBarWidthMultiplier());
|
||||||
|
|
||||||
// Log project loading info
|
// Log project loading info
|
||||||
console.log(`Project max bars: ${maxBars}`);
|
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
|
// Force a new array reference for tracks to trigger React/Zustand re-render
|
||||||
set({
|
set({
|
||||||
projectName: projectToLoad.getName(),
|
projectName: projectToLoad.getName(),
|
||||||
|
savedProjectName: savedName ?? projectToLoad.getName(),
|
||||||
tracks: [...tracks],
|
tracks: [...tracks],
|
||||||
maxBars,
|
maxBars,
|
||||||
|
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
|
||||||
timeSignature,
|
timeSignature,
|
||||||
bpm,
|
bpm,
|
||||||
keySignature,
|
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) => {
|
setTimeSignature: (timeSignature: TimeSignature) => {
|
||||||
try {
|
try {
|
||||||
// Create and execute the change project property command
|
// Create and execute the change project property command
|
||||||
@@ -821,6 +1009,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
projectName: project.getName(),
|
projectName: project.getName(),
|
||||||
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
||||||
maxBars: project.getMaxBars(),
|
maxBars: project.getMaxBars(),
|
||||||
|
barWidthMultiplier: project.getBarWidthMultiplier(),
|
||||||
timeSignature: project.getTimeSignature(),
|
timeSignature: project.getTimeSignature(),
|
||||||
bpm: project.getBpm(),
|
bpm: project.getBpm(),
|
||||||
keySignature: project.getKeySignature(),
|
keySignature: project.getKeySignature(),
|
||||||
@@ -830,6 +1019,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Sync CSS variables that affect layout
|
// Sync CSS variables that affect layout
|
||||||
updateTimeSignatureCSS(project.getTimeSignature());
|
updateTimeSignatureCSS(project.getTimeSignature());
|
||||||
updateMaxBarsCSS(project.getMaxBars());
|
updateMaxBarsCSS(project.getMaxBars());
|
||||||
|
updateBarWidthMultiplierCSS(project.getBarWidthMultiplier());
|
||||||
|
|
||||||
// Sync all related state
|
// Sync all related state
|
||||||
const actions = get();
|
const actions = get();
|
||||||
|
|||||||
@@ -123,8 +123,9 @@ export const createMockProject = (overrides: Partial<{
|
|||||||
'ionian', // selectedMode
|
'ionian', // selectedMode
|
||||||
false, // isLooping
|
false, // isLooping
|
||||||
[0, 0], // loopingRange
|
[0, 0], // loopingRange
|
||||||
|
1, // barWidthMultiplier
|
||||||
defaults.tracks, // tracks
|
defaults.tracks, // tracks
|
||||||
3 // projectStructureVersion
|
5 // projectStructureVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|||||||
+49
-28
@@ -2,58 +2,79 @@ import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStora
|
|||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save project utility function
|
* Save project utility function.
|
||||||
* Handles saving the current project with proper error handling and user confirmation
|
* Detects renames (savedProjectName !== projectName) and migrates the OPFS folder,
|
||||||
* @param projectName - The name of the project to save
|
* including media files. Duplicate name conflicts during rename auto-resolve with
|
||||||
* @param setStatus - Function to update the status message
|
* the {name} (1), {name} (2), ... pattern.
|
||||||
* @returns Promise<boolean> - Returns true if save was successful, false otherwise
|
*
|
||||||
|
* @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 (
|
export const saveProject = async (
|
||||||
projectName: string,
|
projectName: string,
|
||||||
setStatus: (status: string) => void
|
savedProjectName: string,
|
||||||
|
setStatus: (status: string) => void,
|
||||||
|
onSaveSuccess: (finalName: string) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const storage = KGProjectStorage.getInstance();
|
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 {
|
try {
|
||||||
await storage.save(
|
await storage.saveWithRename(
|
||||||
projectName,
|
savedProjectName,
|
||||||
|
targetName,
|
||||||
KGCore.instance().getCurrentProject(),
|
KGCore.instance().getCurrentProject(),
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setStatus(`Project "${projectName}" has been saved`);
|
const statusMsg =
|
||||||
console.log("project saved successfully");
|
targetName !== projectName
|
||||||
|
? `Project renamed to "${targetName}" and saved`
|
||||||
|
: `Project "${targetName}" has been saved`;
|
||||||
|
setStatus(statusMsg);
|
||||||
|
onSaveSuccess(targetName);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving project:", 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);
|
||||||
|
setStatus(`Project "${projectName}" has been saved`);
|
||||||
|
onSaveSuccess(projectName);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
if (error instanceof DuplicateEntryError) {
|
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) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
await storage.save(
|
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
|
||||||
projectName,
|
|
||||||
KGCore.instance().getCurrentProject(),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
setStatus(`Project "${projectName}" has been saved`);
|
setStatus(`Project "${projectName}" has been saved`);
|
||||||
console.log("project saved successfully after overwrite");
|
onSaveSuccess(projectName);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (overwriteError) {
|
} catch (overwriteError) {
|
||||||
console.error("Error overwriting project:", overwriteError);
|
console.error('Error overwriting project:', overwriteError);
|
||||||
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// User cancelled the overwrite
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
console.error("Error saving project:", error);
|
console.error('Error saving project:', error);
|
||||||
window.alert(`An unknown error ${error} occurred. Please try again.`);
|
window.alert(`An unknown error ${error} occurred. Please try again.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user