Merge pull request #32 from KGAudioLab/feat/2026-04-15-kgone-integration
Feat/2026 04 15 kgone integration
This commit is contained in:
@@ -2,14 +2,28 @@
|
||||
<img src="./public/logo.png" alt="K.G.Studio Logo" width="160" />
|
||||
</div>
|
||||
|
||||
## K.G.Studio — A Browser-based DAW with AI Assistant
|
||||
# K.G.Studio — A Browser-based DAW with AI Assistant
|
||||
|
||||
## New!
|
||||
|
||||
> ### ✦ [**K.G.One Music Studio is available now.**](https://github.com/KGAudioLab/K.G.One) ✦ <br />
|
||||
> [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) is a fully local, open-source integrated platform built around **K.G.Studio** (this project). It bundles [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) for full-song generation, [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1) for audio clip and MIDI loop generation, and [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator) for stem separation — bringing GPU-accelerated AI music generation directly into your browser-based production workflow.
|
||||
|
||||
## What is K.G.Studio?
|
||||
|
||||
K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with **K.G.Studio Musician Assistant** at its core. It features realistic instrument playback via Tone.js samplers, a piano‑roll editor, track and region management with full undo/redo, project persistence to IndexedDB, a configurable settings panel, and an integrated AI assistant with tool execution.
|
||||
|
||||
**K.G.Studio Musician Assistant** is an AI assistance agent for harmony, arrangement, and note editing — but not full auto‑composition.
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
</div>
|
||||
|
||||
> Note: Full-Song Generation Feature, Audio Clip Generation Feature, and Stem Separation Feature requires [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) integration.
|
||||
|
||||
## Latest Updates
|
||||
|
||||
- **2026.04.24**: Added [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) integration! When K.G.Studio connects to a local K.G.One server, the **K.G.One Music Generator** panel (magic wand button ✦ in the toolbar) becomes available with three AI-powered tools: **Full Song Generation** (powered by ACE-Step 1.5 — generate full-length songs from text prompts), **Clip Generation** (powered by Foundation-1 — generate instrument clips and MIDI loops from text), and **Stem Separation** (powered by python-audio-separator — split any audio into vocals, instrumentals, and more). Generated audio and MIDI can be previewed instantly and dragged directly onto your tracks. K.G.One runs entirely on your own machine (Windows/Linux, CUDA GPU required); see the [K.G.One repository](https://github.com/KGAudioLab/K.G.One) for setup instructions.
|
||||
- **2026.04.11**: Migrated project storage from IndexedDB to OPFS (Origin Private File System) with a folder-based structure for better media file handling. Added audio track support with WAV/MP3 import, playback, looping, and non-destructive region trimming. Added bounce-to-WAV/MP3 export via offline rendering.
|
||||
- **2026.04.05**: Migrated the AI agent from XML-based tool calling to native OpenAI SDK function calling for improved reliability and compatibility. Added new LLM model options including GPT-5.4 series.
|
||||
- **2026.01.23**: Implemented seamless loop playback! Drag on the bar numbers to set loop range, or toggle loop mode with the Loop button in the toolbar. Loop playback uses `Tone.js`'s native looping for sample-accurate, gap-free looping.
|
||||
@@ -52,10 +66,12 @@ This project investigates how AI-human collaboration can enhance creative music-
|
||||
- [Click here to get a free OpenRouter API Key](https://openrouter.ai/keys) (you may need an OpenRouter account).
|
||||
- In **Settings ⚙️ → General → LLM Provider**, select **OpenAI Compatible**.
|
||||
- In **OpenAI Compatible Server → Key**, paste your key. (Note: on non‑localhost, your key isn't persisted by default for security; you can enable "Persist API Keys on Non-Localhost" in Settings to persist them, though this may increase XSS risk.)
|
||||
- In **OpenAI Compatible Server → Model**, enter `qwen/qwen3.6-plus:free`. (Note: this is a free model [qwen3.6-plus](https://openrouter.ai/qwen/qwen3.6-plus:free); non‑free models may require billing; free model providers may collect your data, check the model page for details; this project is not affiliated with OpenRouter or any model provider.)
|
||||
- In **OpenAI Compatible Server → Model**, enter `openai/gpt-oss-120b:free`. (Note: this is a free model; non‑free models may require billing; free model providers may collect your data, check the model page for details; this project is **not** affiliated with OpenRouter or any model provider.)
|
||||
- In **OpenAI Compatible Server → Base URL**, enter `https://openrouter.ai/api/v1`.
|
||||
|
||||
(Alternatively, you can use the official OpenAI API, other OpenAI‑compatible services, or your own hosted LLM server. e.g., Ollama, vLLM)
|
||||
**Tips:**
|
||||
- You can also use the official OpenAI API, other OpenAI-compatible services, or a self-hosted LLM server (e.g., Ollama, vLLM). Note that model quality varies — not all models perform equally well for music editing tasks. For local hosting, we recommend `qwen3.5-35b-a3b` as a good balance between generation quality and hardware requirements.
|
||||
- If you have an active subscription with OpenAI or another LLM provider, you can use [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) to run a local proxy server that routes requests through your existing subscription, without needing a separate API key.
|
||||
|
||||
### Basic DAW operations
|
||||
- Double‑click (or hold Ctrl/Cmd and click) on a track to create a region.
|
||||
@@ -220,7 +236,10 @@ OpenRouter is a platform that provides unified access to a wide range of languag
|
||||
5. Enter your chosen model name in **OpenAI Compatible Server → Model**. Recommended model series include:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — best balance of quality and cost for the Claude series
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — recommended open source model
|
||||
- `Qwen: Qwen3.6 Plus` (FREE MODEL: `qwen/qwen3.6-plus:free`: [Link](https://openrouter.ai/qwen/qwen3.6-plus:free)) — recommended free model; note that free model providers may collect your data, check the model page for details
|
||||
- `Qwen: Qwen3-Next-80B-A3B` (FREE MODEL: `qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free)) — recommended free model
|
||||
- `OpenAI: GPT-OSS 120B` (FREE MODEL: `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free)) — recommended free model
|
||||
- Note: free model providers may collect your data; check the model page for details before use
|
||||
- Note: free model availability changes frequently — for the latest free options, visit the [OpenRouter Models Page](https://openrouter.ai/models) and use the **Prompt Pricing** filter to find currently free models
|
||||
6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**.
|
||||
|
||||
### About the agent and LLM providers
|
||||
@@ -231,6 +250,53 @@ For security, when using K.G.Studio from a non‑local host, API keys are not pe
|
||||
|
||||
K.G.Studio does not provide or host any of the models listed above, nor is it affiliated with any model provider. All data is stored locally on your device; K.G.Studio does not collect or transmit your data. You are solely responsible for any data you provide to third‑party model providers.
|
||||
|
||||
## K.G.One Music Generator
|
||||
|
||||
> **Requires [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One) integration.** The K.G.One Music Generator panel is only available when K.G.Studio is connected to a running K.G.One server. See the [K.G.One repository](https://github.com/KGAudioLab/K.G.One) for setup instructions.
|
||||
|
||||
The **K.G.One Music Generator** panel provides three GPU-accelerated AI tools for music generation and audio processing. Click the **✦ (magic wand)** button in the toolbar to open it. The panel is mutually exclusive with the AI Assistant chatbox — opening one will close the other.
|
||||
|
||||
> **Note:** The first time you use each tool, the server needs to load the corresponding AI model, which can take 60 seconds or longer depending on your hardware. Switching between tabs may also trigger a model reload.
|
||||
|
||||
### Full Song Generation
|
||||
|
||||
Generate a complete, full-length song from a text description and optional lyrics. Powered by [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5).
|
||||
|
||||
- In the **Full Song** tab, enter a **Caption** describing the desired style, mood, tempo, instrumentation, and structure in natural language. For example: `Genre: Eurodance, 90s dance-pop, upbeat electronic. Tempo: ~130 BPM. Instrumentation: driving kick drum, eurodance bassline...`
|
||||
- Optionally, enter **Lyrics**. Use `[Intro]`, `[Verse]`, `[Chorus]`, `[Bridge]` tags to mark sections. Check **Instrumental** to skip vocals entirely.
|
||||
- Click **Generate Song**. A progress indicator shows the generation stage and percentage in real time.
|
||||
- Once complete, a preview player appears. Drag it onto an **audio track** to import the song as a region.
|
||||
- Dropping onto a MIDI track is not supported for full song generation.
|
||||
- **Advanced Settings** (expandable): Inference Steps, Guidance Scale, Seed, and Thinking (CoT metadata generation).
|
||||
|
||||
### Clip Generation
|
||||
|
||||
Generate short instrument clips and MIDI loops from text descriptions. Powered by [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1).
|
||||
|
||||
- In the **Clip** tab, enter a **Prompt** describing the clip using comma-separated tags covering instrument family, sub-type, timbre, effects, length, BPM, and key. For example: `Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, High Reverb, 8 Bars, 140 BPM, E minor`
|
||||
- Optionally, enter a **Negative Prompt** to steer the generation away from unwanted characteristics (e.g. `distortion, noise`).
|
||||
- Select **Bars**: 4 or 8. The BPM and key signature are pre-filled from your project settings and can be adjusted in **Advanced Settings**.
|
||||
- Click **Generate Clip**. Once complete, a preview player appears with a drag handle on the left and a download button on the right.
|
||||
- **To import**: drag the player onto a track on the timeline.
|
||||
- Drop onto an **audio track** to import as a WAV audio region (recommended).
|
||||
- Drop onto a **MIDI track** to import as a MIDI region. Note that the MIDI is transcribed from the audio and may not be perfectly accurate.
|
||||
- **Advanced Settings** (expandable): Note, Scale, BPM, Steps, CFG Scale, Seed (`-1` for random), Sampler Type, Sigma Min/Max, and CFG Rescale.
|
||||
|
||||
### Stem Separation
|
||||
|
||||
Split an existing audio region into individual stems (e.g. vocals, instruments, drums). Powered by [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator).
|
||||
|
||||
- **Select an audio region** on the timeline before opening this tab. The selected region and its track name are shown at the top of the **Separator** tab. Only audio regions are supported — MIDI regions cannot be separated.
|
||||
- Choose a **Separation Model**:
|
||||
- **Vocal and Instrument (Medium Accuracy)** (`UVR-MDX-NET-Inst_HQ_3`) — fast two-stem split (vocal / instrumental).
|
||||
- **Vocal and Instrument (High Accuracy)** (`MDX23C-8KFFT-InstVoc_HQ`) — higher-quality two-stem split, slower.
|
||||
- **Vocal, Drums, Bass, Guitar, Piano, and Others** (`htdemucs_6s`) — full six-stem separation.
|
||||
- Click **Separate Stems**. If the selected region has a clip start offset or is trimmed, the audio is automatically sliced to match the region range before uploading.
|
||||
- Once complete, each stem appears as a labelled preview player with a drag handle. You can preview each stem individually before importing.
|
||||
- **To import stems**:
|
||||
- Drag each stem player individually onto an **audio track** to place it where you want.
|
||||
- Or click **Import All Stems to Timeline** to create one new audio track per stem automatically, positioned immediately below the source track and aligned to the same start beat as the original region. This is a single undoable operation.
|
||||
|
||||
## Upcoming Features
|
||||
|
||||
Feature priorities might change.
|
||||
@@ -240,7 +306,7 @@ Feature priorities might change.
|
||||
- [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D)
|
||||
- [ ] Support track control automations (e.g. sustain, volume, pan, etc.)
|
||||
- [ ] Support MIDI control events (e.g. CC, pitch bend, etc.)
|
||||
- [ ] Support WAV audio tracks
|
||||
- [X] Support WAV audio tracks
|
||||
- [ ] Filters and effects
|
||||
- [ ] MCP Support
|
||||
- [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.9.0-build.20260406",
|
||||
"version": "0.10.0-build.20260411",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.9.0-build.20260406",
|
||||
"version": "0.10.0-build.20260411",
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -3938,9 +3938,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
},
|
||||
"soundfont": {
|
||||
"base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
|
||||
},
|
||||
"kgone": {
|
||||
"enabled": false,
|
||||
"base_url": "http://127.0.0.1:8000"
|
||||
}
|
||||
},
|
||||
"hotkeys": {
|
||||
|
||||
+37
-4
@@ -10,6 +10,7 @@ import InstrumentSelection from './components/InstrumentSelection';
|
||||
import ChatBox from './components/ChatBox';
|
||||
import { SettingsPanel } from './components/settings';
|
||||
import LoadingOverlay from './components/common/LoadingOverlay';
|
||||
import KGOnePanel from './components/KGOnePanel';
|
||||
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
||||
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
||||
import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer';
|
||||
@@ -17,6 +18,9 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
|
||||
import { KGCore } from './core/KGCore';
|
||||
import { ConfigManager } from './core/config/ConfigManager';
|
||||
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
||||
import { showAlert } from './components/common/DialogProvider';
|
||||
import { KGProjectStorage } from './core/io/KGProjectStorage';
|
||||
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
|
||||
|
||||
function App() {
|
||||
// Enable global keyboard handler for copy/paste and undo/redo
|
||||
@@ -26,7 +30,7 @@ function App() {
|
||||
const {
|
||||
refreshStatus,
|
||||
loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig,
|
||||
showInstrumentSelection
|
||||
showInstrumentSelection, showKGOnePanel
|
||||
} = useProjectStore();
|
||||
|
||||
// Track if app has been initialized to prevent multiple initializations
|
||||
@@ -41,12 +45,41 @@ function App() {
|
||||
hasInitialized.current = true;
|
||||
|
||||
const initializeApp = async () => {
|
||||
// Wipe the reserved "Untitled Project" OPFS folder on every startup so it stays ephemeral
|
||||
try {
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
if (await storage.exists(RESERVED_PROJECT_NAME)) {
|
||||
await storage.delete(RESERVED_PROJECT_NAME);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not clear Untitled Project folder on startup:', error);
|
||||
}
|
||||
|
||||
// Load the current project from KGCore
|
||||
loadProject(null);
|
||||
|
||||
// Initialize ConfigManager first to load config.json and user settings
|
||||
await ConfigManager.instance().initialize();
|
||||
|
||||
// Check for kgone-server.json (managed deployment override)
|
||||
try {
|
||||
const kgoneServerResponse = await fetch(`${import.meta.env.BASE_URL}kgone-server.json?ts=${Date.now()}`);
|
||||
const contentType = kgoneServerResponse.headers.get('Content-Type') ?? '';
|
||||
if (kgoneServerResponse.ok && contentType.includes('application/json')) {
|
||||
const data = await kgoneServerResponse.json();
|
||||
if (data.base_url) {
|
||||
ConfigManager.instance().setKGOneManagedByServer(data.base_url);
|
||||
console.log('K.G.One: server-managed config loaded from kgone-server.json, base URL:', data.base_url);
|
||||
}
|
||||
if (data.soundfont) {
|
||||
ConfigManager.instance().setSoundfontManagedByServer(data.soundfont);
|
||||
console.log('Soundfont: server-managed config loaded from kgone-server.json, base URL:', data.soundfont);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File not present — user configures manually
|
||||
}
|
||||
|
||||
// Initialize store from config after ConfigManager is ready
|
||||
await initializeFromConfig();
|
||||
|
||||
@@ -134,6 +167,7 @@ function App() {
|
||||
<MainContent />
|
||||
</>
|
||||
)}
|
||||
<KGOnePanel isVisible={showKGOnePanel && !showSettings} />
|
||||
<ChatBox isVisible={showChatBox && !showSettings} />
|
||||
</div>
|
||||
|
||||
@@ -179,12 +213,11 @@ const GlobalLoadingOverlayContainer: React.FC = () => {
|
||||
useEffectReact(() => {
|
||||
// When loading starts, start a 30s timer if not already overdue/timed
|
||||
if (loadingCount > 0 && !overdue && timeoutRef.current === null) {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = window.setTimeout(async () => {
|
||||
// Only trigger if still loading
|
||||
if (loadingCount > 0) {
|
||||
setOverdue(true);
|
||||
// Friendly alert to the user
|
||||
window.alert(
|
||||
await showAlert(
|
||||
'Loading resources is taking longer than expected and may have partially failed. If you notice any playback issues, please refresh the page to retry downloading the audio files.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
.chatbox-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* left title, right actions */
|
||||
justify-content: space-between;
|
||||
/* left title, right actions */
|
||||
background-color: #3a3a3a;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
@@ -148,7 +149,7 @@
|
||||
}
|
||||
|
||||
.chatbox-input:focus {
|
||||
border-color: #6a6a6a;
|
||||
border-color: #5a9fd4;
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
@@ -229,7 +230,7 @@
|
||||
.abort-link {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
color: #7b68ee !important;
|
||||
color: #5a9fd4 !important;
|
||||
text-decoration: underline !important;
|
||||
cursor: pointer !important;
|
||||
padding: 0 !important;
|
||||
@@ -288,14 +289,12 @@
|
||||
|
||||
/* Processing wave animation */
|
||||
.processing-wave {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
background: linear-gradient(90deg,
|
||||
rgba(135, 206, 250, 0.5) 0%,
|
||||
rgba(135, 206, 250, 0.8) 25%,
|
||||
rgb(171, 218, 250) 50%,
|
||||
rgba(135, 206, 250, 0.8) 75%,
|
||||
rgba(135, 206, 250, 0.5) 100%
|
||||
);
|
||||
rgba(135, 206, 250, 0.5) 100%);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
@@ -307,6 +306,7 @@
|
||||
0% {
|
||||
background-position: 200% 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/* KGOnePanel — mirrors ChatBox layout and style */
|
||||
.kgone-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--chat-box-width);
|
||||
background-color: #2d2d2d;
|
||||
border-left: 1px solid #3a3a3a;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kgone-panel.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.kgone-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #3a3a3a;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
padding: 0 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kgone-panel-header h3 {
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tab switcher */
|
||||
.kgone-tabs {
|
||||
display: flex;
|
||||
background-color: #2d2d2d;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kgone-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.kgone-tab:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.kgone-tab.active {
|
||||
color: #e0e0e0;
|
||||
border-bottom-color: #5a9fd4;
|
||||
}
|
||||
|
||||
/* Scrollable body */
|
||||
.kgone-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Field groups */
|
||||
.kgone-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kgone-label {
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kgone-input,
|
||||
.kgone-textarea,
|
||||
.kgone-select {
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.kgone-input:focus,
|
||||
.kgone-textarea:focus,
|
||||
.kgone-select:focus {
|
||||
border-color: #5a9fd4;
|
||||
}
|
||||
|
||||
.kgone-textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.kgone-input::placeholder,
|
||||
.kgone-textarea::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Hint text below textarea */
|
||||
.kgone-hint {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.kgone-hint strong {
|
||||
color: #999;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kgone-powered-by {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.kgone-powered-by a {
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kgone-powered-by a:hover {
|
||||
color: #aaa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Two-column row (e.g. note + scale) */
|
||||
.kgone-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kgone-row .kgone-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Advanced expander */
|
||||
.kgone-expander-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.kgone-expander-toggle:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.kgone-expander-toggle .arrow {
|
||||
font-size: 9px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.kgone-expander-toggle .arrow.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.kgone-expander-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* Separator tab */
|
||||
.kgone-separator-hint {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 20px 8px;
|
||||
background-color: #252525;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.kgone-region-info {
|
||||
background-color: #252525;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kgone-region-info-label {
|
||||
color: #777;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.kgone-region-info-value {
|
||||
color: #e0e0e0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Generate / Separate button */
|
||||
.kgone-btn-generate {
|
||||
background-color: #4a5fa0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.kgone-btn-generate:hover:not(:disabled) {
|
||||
background-color: #5a70b8;
|
||||
}
|
||||
|
||||
.kgone-btn-generate:disabled {
|
||||
background-color: #3a3a3a;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Audio player ──────────────────────────────────────────────────────────── */
|
||||
.kgone-audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #252525;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.kgone-player-play-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.kgone-player-play-btn:hover {
|
||||
color: #5a9fd4;
|
||||
}
|
||||
|
||||
.kgone-player-progress-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kgone-player-progress-track:hover {
|
||||
height: 6px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.kgone-player-progress-fill {
|
||||
height: 100%;
|
||||
background-color: #5a9fd4;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.kgone-player-time {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kgone-player-drag-handle {
|
||||
color: #555;
|
||||
cursor: grab;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kgone-player-drag-handle:hover {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.kgone-player-download-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kgone-player-download-btn:hover {
|
||||
color: #5a9fd4;
|
||||
}
|
||||
|
||||
/* ── Generation state feedback ─────────────────────────────────────────────── */
|
||||
.kgone-gen-hint {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.kgone-error-msg {
|
||||
color: #e07a7a;
|
||||
font-size: 11px;
|
||||
background-color: #2a1a1a;
|
||||
border: 1px solid #5a2a2a;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Spinner on button */
|
||||
.kgone-spinner {
|
||||
animation: kgone-spin 0.8s linear infinite;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@keyframes kgone-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stem players (Separator tab) */
|
||||
.kgone-stems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kgone-stem-player {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Checkbox row (instrumental toggle) */
|
||||
.kgone-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kgone-checkbox-row input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #5a9fd4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kgone-checkbox-row label {
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
updateTrackProperties,
|
||||
timeSignature,
|
||||
setPlayheadPosition,
|
||||
playheadPosition,
|
||||
isPlaying,
|
||||
autoScrollEnabled,
|
||||
setAutoScrollEnabled,
|
||||
clearAllSelections,
|
||||
setSelectedTrack,
|
||||
showPianoRoll,
|
||||
@@ -38,7 +42,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setShowPianoRoll,
|
||||
setActiveRegionId,
|
||||
addTrack,
|
||||
addAudioTrack
|
||||
addAudioTrack,
|
||||
projectName,
|
||||
} = useProjectStore();
|
||||
|
||||
// State to store regions
|
||||
@@ -79,6 +84,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Refs to track pending updates for verification
|
||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||
|
||||
// Refs for auto-scroll during playback
|
||||
const mainContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const expectedScrollLeftRef = useRef<number>(-1);
|
||||
const isPlayingRef = useRef(false);
|
||||
|
||||
// Refs for bar numbers and loop range drag functionality
|
||||
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLoopDraggingRef = useRef(false);
|
||||
@@ -86,6 +96,52 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const loopDragStartXRef = useRef<number | null>(null);
|
||||
const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null);
|
||||
|
||||
// Sync isPlayingRef for use inside scroll event closure
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Detect manual horizontal scroll during playback
|
||||
useEffect(() => {
|
||||
const container = mainContentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!isPlayingRef.current) return;
|
||||
if (Math.abs(container.scrollLeft - expectedScrollLeftRef.current) < 1) return;
|
||||
useProjectStore.getState().setAutoScrollEnabled(false);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to keep playhead centered during playback
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !autoScrollEnabled) return;
|
||||
|
||||
const container = mainContentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const barPosition = playheadPosition / beatsPerBar;
|
||||
const barWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||
) || 40;
|
||||
const playheadPixel = barPosition * barWidth;
|
||||
|
||||
// Center the playhead in the visible grid area (excluding the 200px sticky info panel)
|
||||
const infoWidth = 200;
|
||||
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
|
||||
const clampedScrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
|
||||
);
|
||||
|
||||
expectedScrollLeftRef.current = clampedScrollLeft;
|
||||
container.scrollLeft = clampedScrollLeft;
|
||||
}, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]);
|
||||
|
||||
// Effect to verify track updates
|
||||
useEffect(() => {
|
||||
// Check for pending updates
|
||||
@@ -239,6 +295,27 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Handle regions dropped from K.G.One panel (external drag-and-drop)
|
||||
const handleExternalDropComplete = (trackIndex: number, regionUI: RegionUI) => {
|
||||
const track = tracks[trackIndex];
|
||||
if (!track) return;
|
||||
updateTrack(track);
|
||||
setSelectedTrack(track.getId().toString());
|
||||
|
||||
// Sync maxBars from core model — ImportAudioCommand may have expanded it
|
||||
const coreMaxBars = KGCore.instance().getCurrentProject().getMaxBars();
|
||||
if (coreMaxBars > maxBars) {
|
||||
useProjectStore.setState({ maxBars: coreMaxBars });
|
||||
document.documentElement.style.setProperty('--max-number-of-bars', coreMaxBars.toString());
|
||||
}
|
||||
|
||||
setRegions(prev => {
|
||||
const updated = [...prev, regionUI];
|
||||
selectRegion(regionUI.id, updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle region updates (resize, move, etc.)
|
||||
const handleRegionUpdated = (
|
||||
regionId: string,
|
||||
@@ -700,7 +777,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
|
||||
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`} ref={mainContentRef}>
|
||||
<div className="main-content-wrapper">
|
||||
{/* Top-left spacer */}
|
||||
<div className="top-left-spacer">
|
||||
@@ -742,10 +819,12 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
draggedTrackIndex={draggedTrackIndex}
|
||||
dragOverTrackIndex={dragOverTrackIndex}
|
||||
selectedRegionId={selectedRegionId}
|
||||
projectName={projectName}
|
||||
onRegionCreated={handleRegionCreated}
|
||||
onRegionUpdated={handleRegionUpdated}
|
||||
onRegionClick={handleRegionClick}
|
||||
onOpenPianoRoll={handleOpenPianoRoll}
|
||||
onExternalDropComplete={handleExternalDropComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-center, .toolbar-right {
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -44,7 +46,8 @@
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
margin: 0 5px;
|
||||
padding: 5px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -63,6 +66,11 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar button:active {
|
||||
background-color: #5a5a5a;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
+161
-49
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import './Toolbar.css';
|
||||
import { saveProject } from '../util/saveUtil';
|
||||
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
||||
import { isValidProjectName } from '../util/projectNameUtil';
|
||||
import { isValidProjectName, isReservedProjectName, RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { DEBUG_MODE } from '../constants/uiConstants';
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||
FaPlay, FaPause, FaComments, FaSync,
|
||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||
FaCog, FaMagnet
|
||||
FaCog, FaMagnet, FaCut
|
||||
} from 'react-icons/fa';
|
||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
|
||||
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
|
||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
|
||||
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||
@@ -28,6 +29,9 @@ import FileImportModal from './common/FileImportModal';
|
||||
import OpenProjectModal from './common/OpenProjectModal';
|
||||
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
||||
import PianoIcon from './common/icons/PianoIcon';
|
||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './common/DialogProvider';
|
||||
|
||||
const Toolbar: React.FC = () => {
|
||||
const {
|
||||
@@ -40,11 +44,13 @@ const Toolbar: React.FC = () => {
|
||||
barWidthMultiplier, setBarWidthMultiplier,
|
||||
isLooping, toggleLoop,
|
||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||
toggleChatBox, toggleSettings, cleanupProjectState,
|
||||
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
|
||||
// Piano roll state/actions
|
||||
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
||||
// Selection state
|
||||
selectedRegionIds
|
||||
selectedRegionIds,
|
||||
// Playhead and refresh
|
||||
playheadPosition, refreshProjectState
|
||||
} = useProjectStore();
|
||||
|
||||
// State for main content tools
|
||||
@@ -85,15 +91,43 @@ const Toolbar: React.FC = () => {
|
||||
// Export options
|
||||
const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"];
|
||||
|
||||
const handleProjectNameClick = () => {
|
||||
const newName = prompt("Enter project name:", projectName);
|
||||
if (newName) {
|
||||
const handleProjectNameClick = async () => {
|
||||
const newName = await showPrompt("Enter project name:", projectName);
|
||||
if (!newName) return;
|
||||
if (!isValidProjectName(newName)) {
|
||||
window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
|
||||
await showAlert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
|
||||
return;
|
||||
}
|
||||
setProjectName(newName);
|
||||
if (isReservedProjectName(newName)) {
|
||||
await showAlert(`"${RESERVED_PROJECT_NAME}" is a reserved project name. Please choose a different name.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict check: only relevant when targeting a different OPFS folder
|
||||
if (newName !== savedProjectName) {
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const exists = await storage.exists(newName);
|
||||
if (exists) {
|
||||
const confirmed = await showConfirm(
|
||||
`Project "${newName}" already exists. Do you want to overwrite it?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
// Confirmed: update in-memory name then save immediately, overwriting the existing project
|
||||
setProjectName(newName);
|
||||
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
||||
setSavedProjectName(finalName);
|
||||
if (finalName !== newName) setProjectName(finalName);
|
||||
}, true /* forceOverwrite */);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Name is available — update and save immediately
|
||||
setProjectName(newName);
|
||||
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
||||
setSavedProjectName(finalName);
|
||||
if (finalName !== newName) setProjectName(finalName);
|
||||
});
|
||||
};
|
||||
|
||||
// Common project loading logic extracted for reuse
|
||||
@@ -125,7 +159,7 @@ const Toolbar: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error(`Error loading project from ${sourceDescription}:`, error);
|
||||
setStatus(`Failed to load project: ${error}`);
|
||||
window.alert(`An error occurred while loading the project: ${error}`);
|
||||
await showAlert(`An error occurred while loading the project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,8 +184,8 @@ const Toolbar: React.FC = () => {
|
||||
};
|
||||
|
||||
// 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.");
|
||||
const handleNewProject = async () => {
|
||||
const confirmed = await showConfirm("Are you sure you want to create a new project? Any unsaved changes will be lost.");
|
||||
if (confirmed) {
|
||||
createNewProject();
|
||||
}
|
||||
@@ -170,14 +204,14 @@ const Toolbar: React.FC = () => {
|
||||
const loadedProject = await storage.load(projectNameToLoad);
|
||||
|
||||
if (!loadedProject) {
|
||||
window.alert(`Project "${projectNameToLoad}" not found.`);
|
||||
await showAlert(`Project "${projectNameToLoad}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
|
||||
} catch (error) {
|
||||
console.error("Error loading project:", error);
|
||||
window.alert(`An error occurred while loading the project: ${error}`);
|
||||
await showAlert(`An error occurred while loading the project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -244,11 +278,11 @@ const Toolbar: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error("Error exporting KGStudio file:", error);
|
||||
setStatus(`Error exporting project: ${error}`);
|
||||
window.alert(`Failed to export project: ${error}`);
|
||||
await showAlert(`Failed to export project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportMIDI = () => {
|
||||
const handleExportMIDI = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("exporting to MIDI file");
|
||||
}
|
||||
@@ -286,7 +320,7 @@ const Toolbar: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error("Error exporting MIDI:", error);
|
||||
setStatus(`Error exporting MIDI: ${error}`);
|
||||
window.alert(`Failed to export project as MIDI: ${error}`);
|
||||
await showAlert(`Failed to export project as MIDI: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,7 +336,7 @@ const Toolbar: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error("Error bouncing to WAV:", error);
|
||||
setStatus(`Error exporting WAV: ${error}`);
|
||||
window.alert(`Failed to export project as WAV: ${error}`);
|
||||
await showAlert(`Failed to export project as WAV: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -318,7 +352,7 @@ const Toolbar: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error("Error bouncing to MP3:", error);
|
||||
setStatus(`Error exporting MP3: ${error}`);
|
||||
window.alert(`Failed to export project as MP3: ${error}`);
|
||||
await showAlert(`Failed to export project as MP3: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -354,7 +388,7 @@ const Toolbar: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error("Error importing file:", error);
|
||||
setStatus(`Failed to import file: ${error}`);
|
||||
window.alert(`Failed to import project file: ${error}`);
|
||||
await showAlert(`Failed to import project file: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -376,7 +410,7 @@ const Toolbar: React.FC = () => {
|
||||
console.log("KGStudio file imported successfully:", projectName);
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(`The .kgstudio file is corrupted or invalid: ${error}`);
|
||||
await showAlert(`The .kgstudio file is corrupted or invalid: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -503,31 +537,31 @@ const Toolbar: React.FC = () => {
|
||||
};
|
||||
|
||||
// Prompt to change max bars when clicking on current-time display
|
||||
const handleCurrentTimeClick = () => {
|
||||
const handleCurrentTimeClick = async () => {
|
||||
const MIN_BARS = 16;
|
||||
const newMaxBarsStr = prompt(`Enter new max bars (>= ${MIN_BARS}):`, String(maxBars ?? 32));
|
||||
const newMaxBarsStr = await showPrompt(`Enter new max bars (>= ${MIN_BARS}):`, String(maxBars ?? 32));
|
||||
if (newMaxBarsStr === null) {
|
||||
return; // cancelled
|
||||
}
|
||||
const parsed = parseInt(newMaxBarsStr.trim(), 10);
|
||||
if (isNaN(parsed)) {
|
||||
alert('Invalid input. Please enter a valid number.');
|
||||
await showAlert('Invalid input. Please enter a valid number.');
|
||||
return;
|
||||
}
|
||||
if (parsed < MIN_BARS) {
|
||||
alert(`Invalid value. Please enter a number >= ${MIN_BARS}.`);
|
||||
await showAlert(`Invalid value. Please enter a number >= ${MIN_BARS}.`);
|
||||
return;
|
||||
}
|
||||
setMaxBars(parsed);
|
||||
setStatus(`Max bars changed to ${parsed}`);
|
||||
};
|
||||
|
||||
const handleBpmClick = () => {
|
||||
const handleBpmClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("BPM clicked, current BPM:", bpm);
|
||||
}
|
||||
|
||||
const newBpmStr = prompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, bpm.toString());
|
||||
const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, bpm.toString());
|
||||
|
||||
// Check if user cancelled
|
||||
if (newBpmStr === null) {
|
||||
@@ -539,13 +573,13 @@ const Toolbar: React.FC = () => {
|
||||
|
||||
// Check if it's a valid number
|
||||
if (isNaN(newBpm)) {
|
||||
alert("Invalid input. Please enter a valid number.");
|
||||
await showAlert("Invalid input. Please enter a valid number.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's within valid range
|
||||
if (newBpm <= TIME_CONSTANTS.MIN_BPM || newBpm >= TIME_CONSTANTS.MAX_BPM) {
|
||||
alert(`Invalid BPM. Please enter a value between ${TIME_CONSTANTS.MIN_BPM} and ${TIME_CONSTANTS.MAX_BPM}.`);
|
||||
await showAlert(`Invalid BPM. Please enter a value between ${TIME_CONSTANTS.MIN_BPM} and ${TIME_CONSTANTS.MAX_BPM}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,33 +592,25 @@ const Toolbar: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeSignatureClick = () => {
|
||||
const handleTimeSignatureClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("Time signature clicked, current:", `${timeSignature.numerator}/${timeSignature.denominator}`);
|
||||
}
|
||||
|
||||
const currentTimeSignatureStr = `${timeSignature.numerator}/${timeSignature.denominator}`;
|
||||
const newTimeSignatureStr = prompt(`Enter new time signature (numerator/denominator):`, currentTimeSignatureStr);
|
||||
|
||||
// Check if user cancelled
|
||||
if (newTimeSignatureStr === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and validate time signature
|
||||
const newTimeSignature = parseTimeSignature(newTimeSignatureStr);
|
||||
const result = await showTimeSigPrompt('Set the time signature:', timeSignature);
|
||||
if (result === null) return;
|
||||
|
||||
const newTimeSignature = parseTimeSignature(`${result.numerator}/${result.denominator}`);
|
||||
if (newTimeSignature === null) {
|
||||
alert(getTimeSignatureErrorMessage());
|
||||
await showAlert(getTimeSignatureErrorMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update time signature
|
||||
setTimeSignature(newTimeSignature);
|
||||
setStatus(`Time signature changed to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log(`Time signature updated from ${currentTimeSignatureStr} to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
|
||||
console.log(`Time signature updated to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -680,14 +706,60 @@ const Toolbar: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle split region button click
|
||||
const handleSplitClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("Split button clicked");
|
||||
}
|
||||
|
||||
if (selectedRegionIds.length === 0) {
|
||||
await showAlert("Please select a region to split.");
|
||||
return;
|
||||
}
|
||||
if (selectedRegionIds.length > 1) {
|
||||
await showAlert("Please select exactly one region to split.");
|
||||
return;
|
||||
}
|
||||
|
||||
const regionId = selectedRegionIds[0];
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
let targetRegion = null;
|
||||
for (const track of tracks) {
|
||||
const found = track.getRegions().find(r => r.getId() === regionId);
|
||||
if (found) { targetRegion = found; break; }
|
||||
}
|
||||
|
||||
if (!targetRegion) {
|
||||
await showAlert("Selected region not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const regionStart = targetRegion.getStartFromBeat();
|
||||
const regionEnd = regionStart + targetRegion.getLength();
|
||||
|
||||
if (playheadPosition <= regionStart || playheadPosition >= regionEnd) {
|
||||
await showAlert("The playhead is not inside the selected region. Move the playhead inside the region before splitting.");
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new SplitRegionCommand(regionId, playheadPosition);
|
||||
KGCore.instance().executeCommand(command);
|
||||
refreshProjectState();
|
||||
setStatus(`Split region at beat ${playheadPosition.toFixed(2)}`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log(`Split region ${regionId} at beat ${playheadPosition}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle undo button click
|
||||
const handleUndoClick = () => {
|
||||
const handleUndoClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("Undo button clicked");
|
||||
}
|
||||
|
||||
if (!canUndo) {
|
||||
alert("Nothing to undo");
|
||||
await showAlert("Nothing to undo");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -701,13 +773,13 @@ const Toolbar: React.FC = () => {
|
||||
};
|
||||
|
||||
// Handle redo button click
|
||||
const handleRedoClick = () => {
|
||||
const handleRedoClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("Redo button clicked");
|
||||
}
|
||||
|
||||
if (!canRedo) {
|
||||
alert("Nothing to redo");
|
||||
await showAlert("Nothing to redo");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -740,8 +812,18 @@ const Toolbar: React.FC = () => {
|
||||
setStatus("Settings toggled");
|
||||
};
|
||||
|
||||
// K.G.One panel toggle
|
||||
const isKGOneEnabled = ConfigManager.instance().get('general.kgone.enabled') as boolean ?? false;
|
||||
|
||||
const handleKGOneClick = () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("K.G.One button clicked");
|
||||
}
|
||||
toggleKGOnePanel();
|
||||
};
|
||||
|
||||
// Handle Piano button click: open piano roll if closed, targeting active or selected region
|
||||
const handlePianoButtonClick = () => {
|
||||
const handlePianoButtonClick = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log('Piano button clicked');
|
||||
}
|
||||
@@ -760,6 +842,7 @@ const Toolbar: React.FC = () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log('No active or selected region; piano roll will not open');
|
||||
}
|
||||
await showAlert('Please select a MIDI region to open the Piano Roll.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -770,6 +853,13 @@ const Toolbar: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetronomeToggle = () => {
|
||||
toggleMetronome();
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log('Metronome toggled');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
@@ -829,6 +919,12 @@ const Toolbar: React.FC = () => {
|
||||
>
|
||||
<FaPencil />
|
||||
</button>
|
||||
<button
|
||||
title="Split Region at Playhead"
|
||||
onClick={handleSplitClick}
|
||||
>
|
||||
<FaCut />
|
||||
</button>
|
||||
<button
|
||||
title="Snap to Grid"
|
||||
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||
@@ -855,6 +951,13 @@ const Toolbar: React.FC = () => {
|
||||
<FaSync />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button
|
||||
title="Metronome"
|
||||
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
|
||||
onClick={handleMetronomeToggle}
|
||||
>
|
||||
<MetronomeIcon />
|
||||
</button>
|
||||
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
|
||||
{/* <button title="Record"><FaCircle className="record-btn" /></button>
|
||||
<button title="Metronome">🎵</button> */}
|
||||
@@ -916,6 +1019,15 @@ const Toolbar: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<button title="Settings" onClick={handleSettingsClick}><FaCog /></button>
|
||||
<button
|
||||
title={isKGOneEnabled ? 'K.G.One Music Generator' : 'K.G.One integration is disabled — enable it in Settings'}
|
||||
onClick={handleKGOneClick}
|
||||
disabled={!isKGOneEnabled}
|
||||
className={showKGOnePanel ? 'active' : ''}
|
||||
style={!isKGOneEnabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<FaWandMagicSparkles />
|
||||
</button>
|
||||
<button title="Chat" onClick={handleChatClick}><FaComments /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog-modal {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 90%;
|
||||
max-width: 440px;
|
||||
overflow: hidden;
|
||||
animation: dialogFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dialogFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialogFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.dialog-overlay-closing {
|
||||
animation: overlayFadeOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.dialog-modal-closing {
|
||||
animation: dialogFadeOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b0b0b0;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-close-btn:hover {
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 24px 20px;
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-input:focus {
|
||||
border-color: #5a9fd4;
|
||||
}
|
||||
|
||||
.dialog-timesig-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.dialog-timesig-input {
|
||||
width: 72px;
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-timesig-sep {
|
||||
color: #b0b0b0;
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-btn-primary {
|
||||
background-color: #5a9fd4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-btn-primary:hover {
|
||||
background-color: #4a8fc4;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3);
|
||||
}
|
||||
|
||||
.dialog-btn-cancel {
|
||||
background-color: #3a3a3a;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.dialog-btn-cancel:hover {
|
||||
background-color: #4a4a4a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import './DialogProvider.css';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
export interface PromptOptions {
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface TimeSigResult {
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
}
|
||||
|
||||
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
|
||||
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
|
||||
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
|
||||
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
||||
|
||||
export function showAlert(message: string): Promise<void> {
|
||||
if (!_showAlertFn) {
|
||||
window.alert(message);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _showAlertFn(message);
|
||||
}
|
||||
|
||||
export function showConfirm(message: string, options?: ConfirmOptions): Promise<boolean> {
|
||||
if (!_showConfirmFn) {
|
||||
return Promise.resolve(window.confirm(message));
|
||||
}
|
||||
return _showConfirmFn(message, options);
|
||||
}
|
||||
|
||||
export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> {
|
||||
if (!_showPromptFn) {
|
||||
return Promise.resolve(window.prompt(message, defaultValue));
|
||||
}
|
||||
return _showPromptFn(message, defaultValue, options);
|
||||
}
|
||||
|
||||
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
|
||||
if (!_showTimeSigFn) {
|
||||
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
|
||||
if (!raw) return Promise.resolve(null);
|
||||
const [n, d] = raw.split('/').map(Number);
|
||||
if (!n || !d) return Promise.resolve(null);
|
||||
return Promise.resolve({ numerator: n, denominator: d });
|
||||
}
|
||||
return _showTimeSigFn(message, defaultValue);
|
||||
}
|
||||
|
||||
interface DialogInfo {
|
||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
|
||||
message: string;
|
||||
options?: ConfirmOptions | PromptOptions;
|
||||
defaultValue?: string;
|
||||
defaultTimeSig?: TimeSigResult;
|
||||
}
|
||||
|
||||
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [dialog, setDialog] = useState<DialogInfo | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [timeSigNumerator, setTimeSigNumerator] = useState('');
|
||||
const [timeSigDenominator, setTimeSigDenominator] = useState('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolveRef = useRef<((value: any) => void) | null>(null);
|
||||
const pendingValueRef = useRef<unknown>(undefined);
|
||||
|
||||
const openAlert = useCallback((message: string): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setDialog({ type: 'alert', message });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openConfirm = useCallback((message: string, options?: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setDialog({ type: 'confirm', message, options });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openPrompt = useCallback((message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setInputValue(defaultValue ?? '');
|
||||
setDialog({ type: 'prompt', message, options, defaultValue });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openTimeSig = useCallback((message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> => {
|
||||
return new Promise<TimeSigResult | null>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setTimeSigNumerator(String(defaultValue?.numerator ?? 4));
|
||||
setTimeSigDenominator(String(defaultValue?.denominator ?? 4));
|
||||
setDialog({ type: 'timesig', message, defaultTimeSig: defaultValue });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback((value: unknown) => {
|
||||
pendingValueRef.current = value;
|
||||
setIsClosing(true);
|
||||
}, []);
|
||||
|
||||
const handleAnimationEnd = useCallback((e: React.AnimationEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (!isClosing) return;
|
||||
setIsClosing(false);
|
||||
setDialog(null);
|
||||
setInputValue('');
|
||||
setTimeSigNumerator('');
|
||||
setTimeSigDenominator('');
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(pendingValueRef.current);
|
||||
resolveRef.current = null;
|
||||
}
|
||||
}, [isClosing]);
|
||||
|
||||
const mouseDownOnOverlay = useRef(false);
|
||||
|
||||
const registered = useRef(false);
|
||||
if (!registered.current) {
|
||||
registered.current = true;
|
||||
_showAlertFn = openAlert;
|
||||
_showConfirmFn = openConfirm;
|
||||
_showPromptFn = openPrompt;
|
||||
_showTimeSigFn = openTimeSig;
|
||||
}
|
||||
|
||||
if (!dialog) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const isAlert = dialog.type === 'alert';
|
||||
const isPrompt = dialog.type === 'prompt';
|
||||
const isTimeSig = dialog.type === 'timesig';
|
||||
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
|
||||
|
||||
const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm';
|
||||
|
||||
const handleOverlayMouseDown = (e: React.MouseEvent) => {
|
||||
mouseDownOnOverlay.current = e.target === e.currentTarget;
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
|
||||
close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isAlert) { close(undefined); return; }
|
||||
if (isPrompt) { close(inputValue); return; }
|
||||
if (isTimeSig) {
|
||||
close({ numerator: Number(timeSigNumerator), denominator: Number(timeSigDenominator) });
|
||||
return;
|
||||
}
|
||||
close(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div className={`dialog-overlay${isClosing ? ' dialog-overlay-closing' : ''}`} onMouseDown={handleOverlayMouseDown} onClick={handleOverlayClick} onAnimationEnd={handleAnimationEnd}>
|
||||
<div className={`dialog-modal${isClosing ? ' dialog-modal-closing' : ''}`}>
|
||||
<div className="dialog-header">
|
||||
<h3 className="dialog-title">{title}</h3>
|
||||
<button
|
||||
className="dialog-close-btn"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<p className="dialog-message">{dialog.message}</p>
|
||||
{isPrompt && (
|
||||
<input
|
||||
className="dialog-input"
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder={promptOptions?.placeholder}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirm();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{isTimeSig && (
|
||||
<div className="dialog-timesig-row">
|
||||
<input
|
||||
className="dialog-input dialog-timesig-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={timeSigNumerator}
|
||||
onChange={(e) => setTimeSigNumerator(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirm();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<span className="dialog-timesig-sep">/</span>
|
||||
<input
|
||||
className="dialog-input dialog-timesig-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={timeSigDenominator}
|
||||
onChange={(e) => setTimeSigDenominator(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirm();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
{!isAlert && (
|
||||
<button
|
||||
className="dialog-btn dialog-btn-cancel"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="dialog-btn dialog-btn-primary"
|
||||
onClick={handleConfirm}
|
||||
autoFocus={!isPrompt && !isTimeSig}
|
||||
>
|
||||
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogProvider;
|
||||
@@ -30,12 +30,43 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fileImportFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fileImportOverlayFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-import-overlay-closing {
|
||||
animation: fileImportOverlayFadeOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.file-import-modal-closing {
|
||||
animation: fileImportFadeOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.file-import-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -82,12 +113,12 @@
|
||||
}
|
||||
|
||||
.file-import-drop-zone:hover {
|
||||
border-color: #7b68ee;
|
||||
border-color: #5a9fd4;
|
||||
background-color: rgba(123, 104, 238, 0.05);
|
||||
}
|
||||
|
||||
.file-import-drop-zone.drag-over {
|
||||
border-color: #7b68ee;
|
||||
border-color: #5a9fd4;
|
||||
background-color: rgba(123, 104, 238, 0.1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import './FileImportModal.css';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { showAlert } from './DialogProvider';
|
||||
|
||||
interface FileImportModalProps {
|
||||
isVisible: boolean;
|
||||
@@ -20,6 +21,17 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
|
||||
description = 'Drag and drop your project file here'
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const mouseDownOnOverlay = useRef(false);
|
||||
|
||||
const startClose = useCallback(() => setIsClosing(true), []);
|
||||
|
||||
const handleAnimationEnd = useCallback((e: React.AnimationEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (!isClosing) return;
|
||||
setIsClosing(false);
|
||||
onClose();
|
||||
}, [isClosing, onClose]);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -30,7 +42,6 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set drag over to false if we're leaving the drop zone entirely
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
@@ -41,7 +52,7 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
@@ -49,45 +60,51 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
// Check if file type is accepted
|
||||
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (acceptedTypes.includes(fileExtension)) {
|
||||
onFileImport(file);
|
||||
onClose();
|
||||
startClose();
|
||||
} else {
|
||||
alert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
|
||||
await showAlert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}, [acceptedTypes, onFileImport, onClose]);
|
||||
}, [acceptedTypes, onFileImport, startClose]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileImport(files[0]);
|
||||
onClose();
|
||||
startClose();
|
||||
}
|
||||
}, [onFileImport, onClose]);
|
||||
}, [onFileImport, startClose]);
|
||||
|
||||
const handleOverlayMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
mouseDownOnOverlay.current = e.target === e.currentTarget;
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
// Only close if clicking on the overlay itself, not the modal content
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
|
||||
startClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [startClose]);
|
||||
|
||||
if (!isVisible) {
|
||||
if (!isVisible && !isClosing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-import-overlay" onClick={handleOverlayClick}>
|
||||
<div className="file-import-modal">
|
||||
<div
|
||||
className={`file-import-overlay${isClosing ? ' file-import-overlay-closing' : ''}`}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onClick={handleOverlayClick}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
<div className={`file-import-modal${isClosing ? ' file-import-modal-closing' : ''}`}>
|
||||
<div className="file-import-header">
|
||||
<h3 className="file-import-title">{title}</h3>
|
||||
<button
|
||||
className="file-import-close-btn"
|
||||
onClick={onClose}
|
||||
onClick={startClose}
|
||||
aria-label="Close import modal"
|
||||
>
|
||||
<FaTimes />
|
||||
|
||||
@@ -1,49 +1,108 @@
|
||||
/* Overlay — blocks and blurs content below */
|
||||
/* Overlay — centered, 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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* Open Project Panel — follows PianoRoll panel pattern */
|
||||
/* Open Project Panel — centered dialog */
|
||||
.open-project-panel {
|
||||
position: fixed;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: openProjectFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Header — draggable */
|
||||
@keyframes openProjectFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes openProjectFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes openProjectOverlayFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.open-project-overlay-closing {
|
||||
animation: openProjectOverlayFadeOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.open-project-panel-closing {
|
||||
animation: openProjectFadeOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.open-project-header {
|
||||
height: 30px;
|
||||
background-color: #1e1e1e;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
padding: 20px;
|
||||
background-color: #252525;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.open-project-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
text-transform: uppercase;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.open-project-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b0b0b0;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.open-project-close-btn:hover {
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Filter bar — sticky at top */
|
||||
.open-project-filter {
|
||||
padding: 10px 12px;
|
||||
background-color: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -66,7 +125,7 @@
|
||||
}
|
||||
|
||||
.open-project-filter input:focus {
|
||||
border-color: #7b68ee;
|
||||
border-color: #5a9fd4;
|
||||
}
|
||||
|
||||
.open-project-filter input::placeholder {
|
||||
@@ -76,8 +135,7 @@
|
||||
/* View toggle (Projects / Trash) */
|
||||
.open-project-view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -86,14 +144,11 @@
|
||||
background-color: #1e1e1e;
|
||||
color: #999;
|
||||
border: none;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.open-project-toggle-btn:not(:last-child) {
|
||||
border-right: 1px solid #3a3a3a;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.open-project-toggle-btn:hover {
|
||||
@@ -101,7 +156,7 @@
|
||||
}
|
||||
|
||||
.open-project-toggle-btn.active {
|
||||
background-color: #7b68ee;
|
||||
background-color: #5a9fd4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -109,7 +164,6 @@
|
||||
.open-project-trash-hint {
|
||||
padding: 6px 12px;
|
||||
background-color: #3a2a1a;
|
||||
border-bottom: 1px solid #5a3a1a;
|
||||
color: #d4a054;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
@@ -121,7 +175,6 @@
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background-color: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
@@ -144,7 +197,7 @@
|
||||
}
|
||||
|
||||
.open-project-sort-col.active {
|
||||
color: #7b68ee;
|
||||
color: #5a9fd4;
|
||||
}
|
||||
|
||||
.open-project-sort-col-name {
|
||||
@@ -169,7 +222,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #333;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
@@ -211,32 +263,37 @@
|
||||
|
||||
/* Open button */
|
||||
.open-project-item-open-btn {
|
||||
background-color: #7b68ee;
|
||||
background-color: #5a9fd4;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.open-project-item-open-btn:hover {
|
||||
background-color: #6a58d6;
|
||||
background-color: #4a8fc4;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3);
|
||||
}
|
||||
|
||||
/* Icon action buttons (duplicate, delete, restore, perm-delete) */
|
||||
.open-project-item-action-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
color: #bbb;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.1s, color 0.1s, border-color 0.1s;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.open-project-item-action-btn:hover {
|
||||
@@ -245,12 +302,18 @@
|
||||
border-color: #777;
|
||||
}
|
||||
|
||||
.open-project-item-permdelete-btn:hover {
|
||||
.open-project-item-delete-btn:hover {
|
||||
background-color: #5a2020;
|
||||
color: #ff6666;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.open-project-item-permdelete-btn:hover {
|
||||
background-color: #7a1010;
|
||||
color: #ff4444;
|
||||
border-color: #ff2222;
|
||||
}
|
||||
|
||||
.open-project-item-restore-btn:hover {
|
||||
background-color: #1a3a2a;
|
||||
color: #66cc88;
|
||||
@@ -268,18 +331,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { FaTimes, 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';
|
||||
import { showAlert, showConfirm, showPrompt } from './DialogProvider';
|
||||
|
||||
interface OpenProjectModalProps {
|
||||
onClose: () => void;
|
||||
@@ -18,10 +18,6 @@ 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, '');
|
||||
@@ -49,50 +45,31 @@ const formatDate = (timestamp: number): string => {
|
||||
};
|
||||
|
||||
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('');
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const mouseDownOnOverlay = useRef(false);
|
||||
|
||||
// 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 startClose = useCallback(() => setIsClosing(true), []);
|
||||
|
||||
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;
|
||||
const handleAnimationEnd = useCallback((e: React.AnimationEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (!isClosing) return;
|
||||
setIsClosing(false);
|
||||
onClose();
|
||||
}, [isClosing, onClose]);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -110,58 +87,14 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
fetchProjects(viewMode);
|
||||
}, [viewMode, fetchProjects]);
|
||||
|
||||
// Escape key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') startClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
}, [startClose]);
|
||||
|
||||
// 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'));
|
||||
@@ -171,7 +104,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
}
|
||||
}, [sortField]);
|
||||
|
||||
// Filter + sort projects
|
||||
const filteredProjects = React.useMemo(() => {
|
||||
const filtered = projects.filter(p => matchesFilter(p.name, filter));
|
||||
|
||||
@@ -188,21 +120,19 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
return filtered;
|
||||
}, [projects, filter, sortField, sortDirection]);
|
||||
|
||||
// --- Action handlers ---
|
||||
|
||||
const handleOpen = async (projectName: string) => {
|
||||
await onOpenProject(projectName);
|
||||
onClose();
|
||||
startClose();
|
||||
};
|
||||
|
||||
const handleDuplicate = async (e: React.MouseEvent, projectName: string) => {
|
||||
e.stopPropagation();
|
||||
const newName = window.prompt('Enter a name for the duplicated project:', projectName);
|
||||
const newName = await showPrompt('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.');
|
||||
await showAlert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,12 +140,11 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const finalName = await storage.resolveUniqueName(trimmed);
|
||||
await storage.duplicate(projectName, finalName);
|
||||
// Open the duplicated project
|
||||
await onOpenProject(finalName);
|
||||
onClose();
|
||||
startClose();
|
||||
} catch (error) {
|
||||
console.error('Error duplicating project:', error);
|
||||
window.alert(`Failed to duplicate project: ${error}`);
|
||||
await showAlert(`Failed to duplicate project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -224,11 +153,10 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
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}`);
|
||||
await showAlert(`Failed to delete project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,13 +168,13 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
await fetchProjects(viewMode);
|
||||
} catch (error) {
|
||||
console.error('Error restoring project:', error);
|
||||
window.alert(`Failed to restore project: ${error}`);
|
||||
await showAlert(`Failed to restore project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => {
|
||||
e.stopPropagation();
|
||||
const confirmed = window.confirm(
|
||||
const confirmed = await showConfirm(
|
||||
`Are you sure you want to permanently delete "${projectName}"?\n\nThis operation cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
@@ -256,13 +184,12 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
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}`);
|
||||
await showAlert(`Failed to permanently delete project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,34 +198,36 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
setFilter('');
|
||||
};
|
||||
|
||||
const handleOverlayMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
mouseDownOnOverlay.current = e.target === e.currentTarget;
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
|
||||
startClose();
|
||||
}
|
||||
}, [startClose]);
|
||||
|
||||
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}>
|
||||
return (
|
||||
<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,
|
||||
}}
|
||||
className={`open-project-overlay${isClosing ? ' open-project-overlay-closing' : ''}`}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
onClick={handleOverlayClick}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="open-project-header" onMouseDown={(e) => handleMouseDown(e, 'drag')}>
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<div className={`open-project-panel${isClosing ? ' open-project-panel-closing' : ''}`}>
|
||||
<div className="open-project-header">
|
||||
<h3 className="open-project-title">Open Project</h3>
|
||||
<button className="open-project-close-btn" onClick={startClose} aria-label="Close">
|
||||
<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
|
||||
@@ -325,14 +254,12 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
</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' : ''}`}
|
||||
@@ -354,7 +281,6 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Project list */}
|
||||
{isLoading ? (
|
||||
<div className="open-project-loading">Loading projects...</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
@@ -427,16 +353,9 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({ onClose, onOpenProj
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
<div className="resize-handle" onMouseDown={(e) => handleMouseDown(e, 'resize')}>
|
||||
<FaGripLines />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default OpenProjectModal;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
type MetronomeIconProps = React.SVGProps<SVGSVGElement>;
|
||||
|
||||
/**
|
||||
* MetronomeIcon – metronome silhouette in a Font Awesome-like solid style
|
||||
* - Uses currentColor
|
||||
* - Scales with font size (1em)
|
||||
*/
|
||||
const MetronomeIcon: React.FC<MetronomeIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{/*
|
||||
Trapezoidal body with hollow interior, pendulum arm, and weight.
|
||||
fillRule=evenodd: regions covered by an odd number of sub-paths are filled.
|
||||
- Outer trapezoid: 1 crossing → filled (solid walls)
|
||||
- Inner hole: 2 crossings → transparent
|
||||
- Pendulum: 3 crossings → filled (visible inside hole)
|
||||
- Weight: 3 crossings → filled (visible inside hole, no overlap with pendulum)
|
||||
*/}
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="
|
||||
M 208 80 L 304 80 L 432 464 L 80 464 Z
|
||||
M 226 112 L 286 112 L 396 432 L 116 432 Z
|
||||
M 250 112 L 262 112 L 244 330 L 232 330 Z
|
||||
M 222 330 L 264 330 L 260 362 L 226 362 Z
|
||||
"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default MetronomeIcon;
|
||||
@@ -3,3 +3,5 @@ export { default as Playhead } from './Playhead';
|
||||
export { default as FileImportModal } from './FileImportModal';
|
||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||
export { default as OpenProjectModal } from './OpenProjectModal';
|
||||
export { default as DialogProvider, showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './DialogProvider';
|
||||
export type { ConfirmOptions, PromptOptions, TimeSigResult } from './DialogProvider';
|
||||
@@ -1,6 +1,6 @@
|
||||
/* For the piano roll (to be implemented later) */
|
||||
.piano-roll {
|
||||
background-color: #7b68ee;
|
||||
background-color: #5a9fd4;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
/* Piano notes */
|
||||
.piano-note {
|
||||
position: absolute;
|
||||
background-color: #ff5555; /* Red color */
|
||||
background-color: #ff5555;
|
||||
/* Red color */
|
||||
border: 1px solid #999;
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
@@ -72,7 +73,8 @@
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */
|
||||
z-index: 100;
|
||||
/* Ensure toolbar and its dropdowns appear above piano roll content */
|
||||
}
|
||||
|
||||
/* Override pointer-events for piano roll toolbar sections */
|
||||
@@ -138,7 +140,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
|
||||
cursor: pointer;
|
||||
/* Show pointer cursor on hover to indicate interactivity */
|
||||
}
|
||||
|
||||
.piano-grid-header:hover {
|
||||
@@ -153,8 +156,10 @@
|
||||
|
||||
.piano-roll-body {
|
||||
display: flex;
|
||||
min-height: calc(8 * 12 * var(--region-piano-key-height)); /* 8 octaves * 12 notes * piano key height */
|
||||
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width)); /* 32 bars * 160px width + piano keys width */
|
||||
min-height: calc(8 * 12 * var(--region-piano-key-height));
|
||||
/* 8 octaves * 12 notes * piano key height */
|
||||
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width));
|
||||
/* 32 bars * 160px width + piano keys width */
|
||||
}
|
||||
|
||||
.piano-keys-container {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { beatsToBar } from '../../util/midiUtil';
|
||||
import { UpdateRegionCommand } from '../../core/commands';
|
||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||
import { showAlert, showPrompt } from '../common/DialogProvider';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
@@ -29,7 +30,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
initialPosition,
|
||||
initialSize
|
||||
}) => {
|
||||
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode } = useProjectStore();
|
||||
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled } = useProjectStore();
|
||||
|
||||
// Tool state for piano roll
|
||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||
@@ -59,6 +60,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const pianoGridRef = useRef<HTMLDivElement>(null);
|
||||
const wasDraggingRef = useRef<boolean>(false);
|
||||
|
||||
// Refs for auto-scroll during playback
|
||||
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
|
||||
const pianoRollIsPlayingRef = useRef(false);
|
||||
|
||||
// Ref for storing the setNoteUpdateCounter function
|
||||
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||
|
||||
@@ -246,7 +251,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}, [isDragging, isResizing, dragOffset, position]);
|
||||
|
||||
// Handle title click to rename the region
|
||||
const handleTitleClick = () => {
|
||||
const handleTitleClick = async () => {
|
||||
// If we were just dragging, don't show the rename dialog
|
||||
if (wasDraggingRef.current) {
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
@@ -258,7 +263,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
if (!activeRegion) return;
|
||||
|
||||
// Show a prompt to get the new name
|
||||
const newName = window.prompt("Enter a new name for the region:", activeRegion.getName());
|
||||
const newName = await showPrompt("Enter a new name for the region:", activeRegion.getName());
|
||||
|
||||
// If the user clicked Cancel or entered an empty string, do nothing
|
||||
if (!newName || newName.trim() === '' || newName === activeRegion.getName()) return;
|
||||
@@ -278,8 +283,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error renaming region:', error);
|
||||
// Optionally show user-friendly error message
|
||||
alert('Failed to rename region. Please try again.');
|
||||
await showAlert('Failed to rename region. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -621,6 +625,52 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync isPlayingRef for use inside scroll event closure
|
||||
useEffect(() => {
|
||||
pianoRollIsPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
// Detect manual horizontal scroll during playback
|
||||
useEffect(() => {
|
||||
const container = pianoRollContentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!pianoRollIsPlayingRef.current) return;
|
||||
if (Math.abs(container.scrollLeft - pianoRollExpectedScrollLeftRef.current) < 1) return;
|
||||
useProjectStore.getState().setAutoScrollEnabled(false);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to keep playhead centered during playback
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !autoScrollEnabled) return;
|
||||
|
||||
const container = pianoRollContentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const beatWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
||||
) || 40;
|
||||
const playheadPixel = playheadPosition * beatWidth;
|
||||
|
||||
// Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel)
|
||||
const keysWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
|
||||
) || 60;
|
||||
const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2;
|
||||
const clampedScrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
|
||||
);
|
||||
|
||||
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
|
||||
container.scrollLeft = clampedScrollLeft;
|
||||
}, [playheadPosition, isPlaying, autoScrollEnabled]);
|
||||
|
||||
// Scroll horizontally to the active region's starting bar
|
||||
useEffect(() => {
|
||||
if (pianoRollContentRef.current && activeRegion) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import { showAlert } from '../../common/DialogProvider';
|
||||
|
||||
const ChordGuideSettings: React.FC = () => {
|
||||
const [chordDefinition, setChordDefinition] = useState<string>('');
|
||||
@@ -88,7 +89,7 @@ const ChordGuideSettings: React.FC = () => {
|
||||
console.log('Loaded default chord template');
|
||||
} catch (error) {
|
||||
console.error('Failed to load default template:', error);
|
||||
alert('Failed to load default template. Please check the console for details.');
|
||||
await showAlert('Failed to load default template. Please check the console for details.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ const GeneralSettings: React.FC = () => {
|
||||
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
||||
const [compatibleModel, setCompatibleModel] = useState<string>('');
|
||||
const [soundfontBaseUrl, setSoundfontBaseUrl] = useState<string>('');
|
||||
const [kgoneEnabled, setKgoneEnabled] = useState<boolean>(false);
|
||||
const [kgoneBaseUrl, setKgoneBaseUrl] = useState<string>('');
|
||||
const [kgoneServerManaged, setKgoneServerManaged] = useState<boolean>(false);
|
||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
|
||||
@@ -58,6 +62,10 @@ const GeneralSettings: React.FC = () => {
|
||||
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
||||
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
||||
setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || '');
|
||||
setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false);
|
||||
setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || '');
|
||||
setKgoneServerManaged(configManager.isKGOneServerManaged());
|
||||
setSoundfontServerManaged(configManager.isSoundfontServerManaged());
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
@@ -175,6 +183,20 @@ const GeneralSettings: React.FC = () => {
|
||||
debouncedSave('general.soundfont.base_url', value);
|
||||
};
|
||||
|
||||
const handleKgoneEnabledChange = async (value: boolean) => {
|
||||
setKgoneEnabled(value);
|
||||
try {
|
||||
await configManager.set('general.kgone.enabled', value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save K.G.One enabled:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKgoneBaseUrlChange = (value: string) => {
|
||||
setKgoneBaseUrl(value);
|
||||
debouncedSave('general.kgone.base_url', value);
|
||||
};
|
||||
|
||||
// NOTE: Gemini and Claude are not supported yet due to CORS issues.
|
||||
return (
|
||||
<div className="settings-section">
|
||||
@@ -433,6 +455,52 @@ const GeneralSettings: React.FC = () => {
|
||||
value={compatibleBaseUrl}
|
||||
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
|
||||
/>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Quick presets:{' '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCompatibleBaseUrlChange('http://localhost:11434/v1/chat/completions');
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
Ollama
|
||||
</a>
|
||||
{' | '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCompatibleBaseUrlChange('http://localhost:8080/v1/chat/completions');
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
llama.cpp
|
||||
</a>
|
||||
{' | '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCompatibleBaseUrlChange('http://127.0.0.1:8317/v1/chat/completions');
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
CLIProxyAPI
|
||||
</a>
|
||||
{' | '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCompatibleBaseUrlChange('https://openrouter.ai/api/v1/chat/completions');
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
OpenRouter
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
@@ -452,6 +520,12 @@ const GeneralSettings: React.FC = () => {
|
||||
<div className="settings-group">
|
||||
<h4>Soundfont Settings</h4>
|
||||
|
||||
{soundfontServerManaged && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
||||
Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Base URL
|
||||
@@ -462,9 +536,62 @@ const GeneralSettings: React.FC = () => {
|
||||
placeholder="e.g. https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
|
||||
value={soundfontBaseUrl}
|
||||
onChange={(e) => handleSoundfontBaseUrlChange(e.target.value)}
|
||||
disabled={soundfontServerManaged}
|
||||
/>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.
|
||||
Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.{' '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSoundfontBaseUrlChange('https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/');
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
Restore default
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>K.G.One Settings</h4>
|
||||
|
||||
{kgoneServerManaged && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
||||
K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Enable K.G.One Integration
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={kgoneEnabled ? 'true' : 'false'}
|
||||
onChange={(e) => handleKgoneEnabledChange(e.target.value === 'true')}
|
||||
disabled={kgoneServerManaged}
|
||||
>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Server Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
placeholder="e.g. http://127.0.0.1:8000"
|
||||
value={kgoneBaseUrl}
|
||||
onChange={(e) => handleKgoneBaseUrlChange(e.target.value)}
|
||||
disabled={kgoneServerManaged}
|
||||
/>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Base URL of a running K.G.One server. Used for full-song generation, clip generation, and stem separation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -548,7 +548,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
data-is-dragging={isDragging}
|
||||
>
|
||||
<div className="region-header">
|
||||
{audioRegion ? audioRegion.getAudioFileName() : name}
|
||||
{name}
|
||||
</div>
|
||||
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
||||
{!audioRegion && (
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
.track-grid {
|
||||
display: block;
|
||||
height: 120px;
|
||||
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* 32 bars * --track-grid-bar-width */
|
||||
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width));
|
||||
/* 32 bars * --track-grid-bar-width */
|
||||
background-size: var(--track-grid-bar-width) 120px;
|
||||
background-image:
|
||||
/* Vertical lines for bars */
|
||||
linear-gradient(to right,
|
||||
transparent calc(var(--track-grid-bar-width) - 1px), #3a3a3a calc(var(--track-grid-bar-width) - 1px), #3a3a3a var(--track-grid-bar-width)
|
||||
);
|
||||
transparent calc(var(--track-grid-bar-width) - 1px), #3a3a3a calc(var(--track-grid-bar-width) - 1px), #3a3a3a var(--track-grid-bar-width));
|
||||
position: relative;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
@@ -73,11 +73,11 @@
|
||||
|
||||
/* Drag and drop styles */
|
||||
.track-info.drag-over {
|
||||
border-top: 2px solid #7b68ee;
|
||||
border-top: 2px solid #5a9fd4;
|
||||
}
|
||||
|
||||
.track-grid.drag-over {
|
||||
border-top: 2px solid #7b68ee;
|
||||
border-top: 2px solid #5a9fd4;
|
||||
}
|
||||
|
||||
/* Dragging state */
|
||||
@@ -97,10 +97,13 @@
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-name:hover {
|
||||
color: #7b68ee;
|
||||
color: #5a9fd4;
|
||||
}
|
||||
|
||||
.track-name:hover::before {
|
||||
@@ -197,11 +200,13 @@
|
||||
}
|
||||
|
||||
.pan-controls button.solo.active {
|
||||
background: #c8a400; /* yellow */
|
||||
background: #c8a400;
|
||||
/* yellow */
|
||||
color: #101010;
|
||||
}
|
||||
|
||||
.pan-controls button.mute.active {
|
||||
background: #d32f2f; /* red */
|
||||
background: #d32f2f;
|
||||
/* red */
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ interface TrackGridItemProps {
|
||||
onRegionClick?: (regionId: string) => void;
|
||||
onOpenPianoRoll?: (regionId: string) => void;
|
||||
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
||||
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||
}
|
||||
|
||||
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
@@ -46,7 +47,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
onRegionDragEnd,
|
||||
onRegionClick,
|
||||
onOpenPianoRoll,
|
||||
allTracks
|
||||
allTracks,
|
||||
onKGOneClipDrop,
|
||||
}) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||
@@ -509,6 +511,18 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
onDoubleClick={(e) => onDoubleClick(e, index)}
|
||||
onClick={(e) => onClick && onClick(e, index)}
|
||||
ref={trackElementRef}
|
||||
onDragOver={(e) => {
|
||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
||||
e.preventDefault();
|
||||
onKGOneClipDrop?.(e, index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Render regions for this track */}
|
||||
{trackRegions.map(region => {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { KGTrack, TrackType } from '../../core/track/KGTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import TrackGridItem from './TrackGridItem';
|
||||
import { Playhead } from '../common';
|
||||
import { Playhead, FileImportModal } from '../common';
|
||||
import type { RegionUI } from '../interfaces';
|
||||
import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, ImportAudioCommand, ImportMidiClipCommand } from '../../core/commands';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { generateNewRegionName } from '../../util/miscUtil';
|
||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||
import { showAlert } from '../common/DialogProvider';
|
||||
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
interface TrackGridPanelProps {
|
||||
tracks: KGTrack[];
|
||||
@@ -21,10 +25,12 @@ interface TrackGridPanelProps {
|
||||
draggedTrackIndex: number | null;
|
||||
dragOverTrackIndex: number | null;
|
||||
selectedRegionId: string | null;
|
||||
projectName: string;
|
||||
onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void;
|
||||
onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void;
|
||||
onRegionClick?: (regionId: string) => void;
|
||||
onOpenPianoRoll?: (regionId: string) => void;
|
||||
onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void;
|
||||
}
|
||||
|
||||
const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
@@ -35,15 +41,19 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
draggedTrackIndex,
|
||||
dragOverTrackIndex,
|
||||
selectedRegionId,
|
||||
projectName,
|
||||
onRegionCreated,
|
||||
onRegionUpdated,
|
||||
onRegionClick,
|
||||
onOpenPianoRoll
|
||||
onOpenPianoRoll,
|
||||
onExternalDropComplete,
|
||||
}) => {
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||
const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null);
|
||||
|
||||
// Utility function to create a region at a specific position
|
||||
const createRegionAtPosition = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
|
||||
const createRegionAtPosition = async (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
|
||||
// Get the grid container element
|
||||
const gridContainer = e.currentTarget.closest('.grid-container');
|
||||
if (!gridContainer) return;
|
||||
@@ -67,8 +77,13 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
const track = tracks[trackIndex];
|
||||
const trackId = track.getId().toString();
|
||||
|
||||
// Don't allow manual region creation on audio tracks
|
||||
// For audio tracks, show the file import modal instead of creating a blank region
|
||||
if (track.getType() === TrackType.Wave) {
|
||||
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||
const rawBar = relativeX / barWidth + 1;
|
||||
const snappedBarNumber = Math.max(1, snap ? Math.round(rawBar) : rawBar);
|
||||
pendingAudioImportRef.current = { barNumber: snappedBarNumber, trackIndex };
|
||||
setShowAudioImportModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,7 +109,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`Cannot create region at bar ${barNumber}: overlaps with existing region`);
|
||||
}
|
||||
alert('Cannot create region: overlaps with existing region');
|
||||
await showAlert('Cannot create region: overlaps with existing region');
|
||||
return; // Don't create the region if it overlaps
|
||||
}
|
||||
|
||||
@@ -128,6 +143,81 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle audio file import after the user picks a file from the modal
|
||||
const handleAudioFileImport = async (file: File) => {
|
||||
setShowAudioImportModal(false);
|
||||
const pending = pendingAudioImportRef.current;
|
||||
if (!pending) return;
|
||||
pendingAudioImportRef.current = null;
|
||||
|
||||
const { barNumber, trackIndex } = pending;
|
||||
const track = tracks[trackIndex];
|
||||
if (!track) return;
|
||||
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const fileId = KGAudioFileStorage.generateAudioFileId(file.name);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||
audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
(decoded) => { toneBuffer.set(decoded); resolve(); },
|
||||
(err) => reject(err)
|
||||
);
|
||||
});
|
||||
|
||||
const audioDurationSeconds = toneBuffer.duration;
|
||||
await KGAudioFileStorage.storeAudioFile(projectName, fileId, file);
|
||||
KGAudioInterface.instance().loadAudioBufferForTrack(
|
||||
track.getId().toString(),
|
||||
fileId,
|
||||
toneBuffer
|
||||
);
|
||||
|
||||
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
||||
const durationInBeats = audioDurationSeconds * (bpm / 60);
|
||||
const insertBeat = (barNumber - 1) * beatsPerBar;
|
||||
const lengthInBars = Math.max(1, Math.ceil(durationInBeats / beatsPerBar));
|
||||
const prevMaxBars = maxBars;
|
||||
const newMaxBars = Math.max(maxBars, barNumber + lengthInBars - 1);
|
||||
|
||||
const cmd = new ImportAudioCommand(
|
||||
track.getId() as unknown as number,
|
||||
trackIndex,
|
||||
fileId,
|
||||
file.name,
|
||||
audioDurationSeconds,
|
||||
insertBeat,
|
||||
durationInBeats,
|
||||
prevMaxBars,
|
||||
newMaxBars
|
||||
);
|
||||
KGCore.instance().executeCommand(cmd);
|
||||
|
||||
const created = cmd.getCreatedRegion();
|
||||
if (created && onExternalDropComplete) {
|
||||
const regionUI: RegionUI = {
|
||||
id: created.getId(),
|
||||
trackId: track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
length: lengthInBars,
|
||||
name: created.getName(),
|
||||
};
|
||||
onExternalDropComplete(trackIndex, regionUI);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`[TrackGrid] Imported audio "${file.name}" to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TrackGrid] Audio import from click failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle double click on track grid to create region
|
||||
const handleTrackGridDoubleClick = (e: React.MouseEvent<HTMLDivElement>, trackIndex: number) => {
|
||||
// Only allow double-click creation in pointer mode
|
||||
@@ -396,6 +486,146 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle external K.G.One clip drop onto a track row
|
||||
const handleExternalDrop = async (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => {
|
||||
const raw = e.dataTransfer.getData('application/kgone-clip');
|
||||
if (!raw) return;
|
||||
|
||||
let dropData: { midiUrl?: string; audioUrl: string; audioDurationSeconds: number; audioFileName: string };
|
||||
try {
|
||||
dropData = JSON.parse(raw);
|
||||
} catch {
|
||||
console.error('[KGOne] Invalid drop data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate drop bar position
|
||||
if (!gridContainerRef.current) return;
|
||||
const gridRect = gridContainerRef.current.getBoundingClientRect();
|
||||
const relativeX = e.clientX - gridRect.left;
|
||||
const barWidth = gridContainerRef.current.clientWidth / maxBars;
|
||||
const rawBar = relativeX / barWidth + 1;
|
||||
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||
const barNumber = Math.max(1, snap ? Math.round(rawBar) : Math.floor(rawBar));
|
||||
|
||||
const track = tracks[trackIndex];
|
||||
if (!track) return;
|
||||
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
|
||||
try {
|
||||
if (track.getType() === TrackType.MIDI) {
|
||||
if (!dropData.midiUrl) {
|
||||
await showAlert(
|
||||
'This audio clip can only be imported into an audio track.\n' +
|
||||
'Please drag it onto an audio track instead.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ── MIDI track: fetch MIDI file and create a KGMidiRegion ──────────
|
||||
const midiResp = await fetch(dropData.midiUrl);
|
||||
if (!midiResp.ok) throw new Error(`MIDI fetch failed (${midiResp.status})`);
|
||||
const buf = await midiResp.arrayBuffer();
|
||||
const { notes, totalBeats } = parseMidiFirstTrackNotes(new Uint8Array(buf));
|
||||
const lengthInBars = Math.max(1, Math.ceil(totalBeats / beatsPerBar));
|
||||
|
||||
const cmd = ImportMidiClipCommand.fromBarCoordinates(
|
||||
track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
lengthInBars,
|
||||
beatsPerBar,
|
||||
notes,
|
||||
'KGOne Clip'
|
||||
);
|
||||
KGCore.instance().executeCommand(cmd);
|
||||
|
||||
const created = cmd.getCreatedRegion();
|
||||
if (created && onExternalDropComplete) {
|
||||
const regionUI: RegionUI = {
|
||||
id: created.getId(),
|
||||
trackId: track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
length: lengthInBars,
|
||||
name: created.getName(),
|
||||
};
|
||||
onExternalDropComplete(trackIndex, regionUI);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`[KGOne] Imported MIDI clip to track ${trackIndex}, bar ${barNumber}, ${notes.length} notes`);
|
||||
}
|
||||
|
||||
} else if (track.getType() === TrackType.Wave) {
|
||||
// ── Audio track: save blob to OPFS and create a KGAudioRegion ──────
|
||||
const blob = await fetch(dropData.audioUrl).then(r => r.blob());
|
||||
const audioFile = new File([blob], dropData.audioFileName, { type: 'audio/wav' });
|
||||
const fileId = `kgone_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// Decode audio to get accurate duration and load into player bus
|
||||
const arrayBuffer = await audioFile.arrayBuffer();
|
||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||
audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
(decoded) => { toneBuffer.set(decoded); resolve(); },
|
||||
(err) => reject(err)
|
||||
);
|
||||
});
|
||||
|
||||
const audioDurationSeconds = toneBuffer.duration;
|
||||
|
||||
await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile);
|
||||
KGAudioInterface.instance().loadAudioBufferForTrack(
|
||||
track.getId().toString(),
|
||||
fileId,
|
||||
toneBuffer
|
||||
);
|
||||
|
||||
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
||||
const durationInBeats = audioDurationSeconds * (bpm / 60);
|
||||
const insertBeat = (barNumber - 1) * beatsPerBar;
|
||||
const lengthInBars = Math.max(1, Math.ceil(durationInBeats / beatsPerBar));
|
||||
const prevMaxBars = maxBars;
|
||||
const newMaxBars = Math.max(maxBars, barNumber + lengthInBars - 1);
|
||||
|
||||
const cmd = new ImportAudioCommand(
|
||||
track.getId() as unknown as number,
|
||||
trackIndex,
|
||||
fileId,
|
||||
dropData.audioFileName,
|
||||
audioDurationSeconds,
|
||||
insertBeat,
|
||||
durationInBeats,
|
||||
prevMaxBars,
|
||||
newMaxBars
|
||||
);
|
||||
KGCore.instance().executeCommand(cmd);
|
||||
|
||||
const created = cmd.getCreatedRegion();
|
||||
if (created && onExternalDropComplete) {
|
||||
const regionUI: RegionUI = {
|
||||
id: created.getId(),
|
||||
trackId: track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
length: lengthInBars,
|
||||
name: created.getName(),
|
||||
};
|
||||
onExternalDropComplete(trackIndex, regionUI);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`[KGOne] Imported audio clip to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[KGOne] Drop import failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-container" ref={gridContainerRef}>
|
||||
{/* Playhead */}
|
||||
@@ -422,8 +652,18 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
onRegionClick={handleRegionClick}
|
||||
onOpenPianoRoll={onOpenPianoRoll}
|
||||
allTracks={tracks}
|
||||
onKGOneClipDrop={handleExternalDrop}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { showAlert, showConfirm, showPrompt } from '../common/DialogProvider';
|
||||
interface TrackInfoItemProps {
|
||||
track: KGTrack;
|
||||
index: number;
|
||||
@@ -91,15 +92,16 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
|
||||
// Sync mute/solo UI with audio interface state on track/project changes
|
||||
useEffect(() => {
|
||||
setMuted(false);
|
||||
setSolo(false);
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
setMuted(audioInterface.getTrackMuted(track.getId().toString()));
|
||||
setSolo(audioInterface.getTrackSolo(track.getId().toString()));
|
||||
}, [allTracks, track]);
|
||||
|
||||
// Handle track name edit within the component
|
||||
const handleTrackNameClick = (e: React.MouseEvent) => {
|
||||
const handleTrackNameClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent opening piano roll when clicking track name
|
||||
|
||||
const newName = prompt("Enter track name:", track.getName());
|
||||
const newName = await showPrompt("Enter track name:", track.getName());
|
||||
if (newName) {
|
||||
// Call the parent handler with the new name
|
||||
onTrackNameEdit(track, newName);
|
||||
@@ -232,7 +234,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
// Handle settings action
|
||||
const handleSettingsAction = async (action: string) => {
|
||||
if (action === 'Delete Track') {
|
||||
const confirmed = window.confirm(`Are you sure you want to delete track "${track.getName()}"?`);
|
||||
const confirmed = await showConfirm(`Are you sure you want to delete track "${track.getName()}"?`);
|
||||
if (confirmed) {
|
||||
try {
|
||||
if (DEBUG_MODE.TRACK_INFO) {
|
||||
@@ -251,7 +253,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
setShowSettingsDropdown(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete track:', error);
|
||||
alert('Failed to delete track. Please try again.');
|
||||
await showAlert('Failed to delete track. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,6 +294,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<div
|
||||
className="track-name"
|
||||
onClick={handleTrackNameClick}
|
||||
title={track.getName()}
|
||||
>
|
||||
{track.getName()}
|
||||
</div>
|
||||
|
||||
@@ -108,3 +108,9 @@ export const CONFIG_UPGRADER_CONSTANTS = {
|
||||
export const URL_CONSTANTS = {
|
||||
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
export const KGONE_CONSTANTS = {
|
||||
MAX_RETRY_ATTEMPTS: 3, // max retries for polling & download calls
|
||||
RETRY_INITIAL_DELAY_MS: 1000, // first wait before retry (ms)
|
||||
RETRY_BACKOFF_MULTIPLIER: 2, // doubles each attempt: 1 s → 2 s → 4 s
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export const DEBUG_MODE = {
|
||||
REGION_ITEM: true,
|
||||
PIANO_ROLL: true,
|
||||
MIDI_IMPORT: true,
|
||||
KGONE: true,
|
||||
};
|
||||
|
||||
// Toolbar related constants
|
||||
|
||||
@@ -29,6 +29,8 @@ export class KGDebugger {
|
||||
'testExtractXMLFromString(input)',
|
||||
'testToolCall(jsonInput)',
|
||||
'inputChatBox(content, interval?)',
|
||||
'inputKGOneCaption(content, interval?)',
|
||||
'inputKGOneLyrics(content, interval?)',
|
||||
'opfs(command)',
|
||||
'startShell()',
|
||||
]);
|
||||
@@ -346,6 +348,8 @@ export class KGDebugger {
|
||||
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
|
||||
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
|
||||
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
|
||||
console.log(" inputKGOneCaption(content, interval?) - Type into KGOne Caption textarea (Full Song tab)");
|
||||
console.log(" inputKGOneLyrics(content, interval?) - Type into KGOne Lyrics textarea (Full Song tab)");
|
||||
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");
|
||||
@@ -359,6 +363,10 @@ export class KGDebugger {
|
||||
console.log(' await KGStudio.KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start":0,"length":8}}\')');
|
||||
console.log(' await KGStudio.KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start:0,length:1}]}})');
|
||||
console.log(' await KGStudio.KGDebugger.testToolCall([{name:"remove_notes",arguments:{start:0,end_beat:4}},{name:"read_music",arguments:{}}])');
|
||||
console.log("");
|
||||
console.log("💡 KGOne input examples:");
|
||||
console.log(' await KGStudio.KGDebugger.inputKGOneCaption("Genre: Eurodance, 90s dance-pop, upbeat electronic...", 30)');
|
||||
console.log(' await KGStudio.KGDebugger.inputKGOneLyrics("[Verse 1]\\nYour lyrics here...\\n\\n[Chorus]\\n...", 30)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,6 +459,146 @@ export class KGDebugger {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type content into K.G.One Caption textarea character-by-character.
|
||||
* Honors auto-resize (by dispatching 'input' events).
|
||||
* Note: Unlike ChatBox, this does NOT auto-submit (no Enter key dispatch).
|
||||
* @param content - The text to type into the caption input
|
||||
* @param interval - Delay in ms between characters (default 30ms)
|
||||
*/
|
||||
public async inputKGOneCaption(content: string, interval: number = 30): Promise<void> {
|
||||
try {
|
||||
const label = Array.from(document.querySelectorAll('label.kgone-label')).find(
|
||||
el => el.textContent?.trim() === 'Caption'
|
||||
);
|
||||
if (!label) {
|
||||
console.error('❌ Caption label not found. Ensure KGOne panel is visible with Full Song tab selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = label.nextElementSibling as HTMLTextAreaElement | null;
|
||||
if (!textarea || textarea.tagName !== 'TEXTAREA') {
|
||||
console.error('❌ Caption textarea not found after the label.');
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
const setValue = (val: string) => {
|
||||
if (valueSetter) {
|
||||
valueSetter.call(textarea, val);
|
||||
} else {
|
||||
textarea.value = val;
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchInput = (data?: string) => {
|
||||
const ev = typeof InputEvent !== 'undefined'
|
||||
? new InputEvent('input', { bubbles: true, data, inputType: 'insertText' })
|
||||
: new Event('input', { bubbles: true });
|
||||
textarea.dispatchEvent(ev);
|
||||
};
|
||||
|
||||
setValue('');
|
||||
dispatchInput('');
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let typed = '';
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
typed += content[i];
|
||||
setValue(typed);
|
||||
dispatchInput(content[i]);
|
||||
|
||||
try {
|
||||
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
||||
} catch {
|
||||
}
|
||||
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
|
||||
if (interval > 0) {
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(Math.max(30, interval));
|
||||
} catch (error) {
|
||||
console.error('❌ Error in inputKGOneCaption:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type content into K.G.One Lyrics textarea character-by-character.
|
||||
* Honors auto-resize (by dispatching 'input' events).
|
||||
* Note: Unlike ChatBox, this does NOT auto-submit (no Enter key dispatch).
|
||||
* @param content - The text to type into the lyrics input
|
||||
* @param interval - Delay in ms between characters (default 30ms)
|
||||
*/
|
||||
public async inputKGOneLyrics(content: string, interval: number = 30): Promise<void> {
|
||||
try {
|
||||
const label = Array.from(document.querySelectorAll('label.kgone-label')).find(
|
||||
el => el.textContent?.trim() === 'Lyrics'
|
||||
);
|
||||
if (!label) {
|
||||
console.error('❌ Lyrics label not found. Ensure KGOne panel is visible with Full Song tab selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = label.nextElementSibling as HTMLTextAreaElement | null;
|
||||
if (!textarea || textarea.tagName !== 'TEXTAREA') {
|
||||
console.error('❌ Lyrics textarea not found after the label.');
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
const setValue = (val: string) => {
|
||||
if (valueSetter) {
|
||||
valueSetter.call(textarea, val);
|
||||
} else {
|
||||
textarea.value = val;
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchInput = (data?: string) => {
|
||||
const ev = typeof InputEvent !== 'undefined'
|
||||
? new InputEvent('input', { bubbles: true, data, inputType: 'insertText' })
|
||||
: new Event('input', { bubbles: true });
|
||||
textarea.dispatchEvent(ev);
|
||||
};
|
||||
|
||||
setValue('');
|
||||
dispatchInput('');
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let typed = '';
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
typed += content[i];
|
||||
setValue(typed);
|
||||
dispatchInput(content[i]);
|
||||
|
||||
try {
|
||||
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
||||
} catch {
|
||||
}
|
||||
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
|
||||
if (interval > 0) {
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(Math.max(30, interval));
|
||||
} catch (error) {
|
||||
console.error('❌ Error in inputKGOneLyrics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an interactive OPFS shell using browser prompt() dialogs.
|
||||
* Each prompt shows the current working directory. Type commands and click OK.
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { KGCore } from '../KGCore';
|
||||
import { ConfigManager } from '../config/ConfigManager';
|
||||
import { KGMetronome } from './KGMetronome';
|
||||
|
||||
/**
|
||||
* KGAudioInterface - Audio engine interface for the DAW
|
||||
@@ -45,6 +46,10 @@ export class KGAudioInterface {
|
||||
// Master volume control
|
||||
private masterGain: Tone.Gain | null = null;
|
||||
|
||||
// Metronome
|
||||
private metronome: KGMetronome = new KGMetronome();
|
||||
private isMetronomeEnabled = false;
|
||||
|
||||
// Audio capture for screen sharing
|
||||
private captureDestination: MediaStreamAudioDestinationNode | null = null;
|
||||
private captureStream: MediaStream | null = null;
|
||||
@@ -88,6 +93,11 @@ export class KGAudioInterface {
|
||||
Tone.Transport.bpm.value = TIME_CONSTANTS.DEFAULT_BPM; // Default BPM
|
||||
Tone.Transport.timeSignature = [TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.numerator, TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.denominator]; // Default time signature
|
||||
|
||||
// Initialize metronome sampler in background (non-blocking)
|
||||
this.metronome.initialize(this.masterGain!).catch(err => {
|
||||
console.error('Failed to initialize metronome:', err);
|
||||
});
|
||||
|
||||
// Check config and setup audio capture if enabled
|
||||
const enableCapture = configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean;
|
||||
|
||||
@@ -144,6 +154,9 @@ export class KGAudioInterface {
|
||||
});
|
||||
this.trackAudioPlayerBuses.clear();
|
||||
|
||||
// Dispose metronome
|
||||
this.metronome.dispose();
|
||||
|
||||
// Dispose master gain
|
||||
if (this.masterGain) {
|
||||
this.masterGain.dispose();
|
||||
@@ -430,6 +443,11 @@ export class KGAudioInterface {
|
||||
// Set transport position (convert beats to Tone.js format)
|
||||
this.setTransportPosition(startPosition);
|
||||
|
||||
// Start metronome if enabled
|
||||
if (this.isMetronomeEnabled) {
|
||||
this.metronome.start(startPosition, timeSignature.numerator, playbackDelay);
|
||||
}
|
||||
|
||||
// Schedule all MIDI events
|
||||
project.getTracks().forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
@@ -645,6 +663,7 @@ export class KGAudioInterface {
|
||||
public stopPlayback(): void {
|
||||
try {
|
||||
Tone.Transport.stop();
|
||||
this.metronome.stop();
|
||||
|
||||
// Release all currently playing notes
|
||||
this.trackAudioBuses.forEach(audioBus => {
|
||||
@@ -791,6 +810,23 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== METRONOME =====
|
||||
|
||||
public setMetronomeEnabled(enabled: boolean): void {
|
||||
this.isMetronomeEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Start the metronome mid-playback without restarting the transport. */
|
||||
public startMetronomeDuringPlayback(currentPositionBeats: number, beatsPerBar: number): void {
|
||||
const playbackDelay = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
||||
this.metronome.start(currentPositionBeats, beatsPerBar, playbackDelay);
|
||||
}
|
||||
|
||||
/** Stop the metronome mid-playback without stopping the transport. */
|
||||
public stopMetronomeDuringPlayback(): void {
|
||||
this.metronome.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transport BPM
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as Tone from 'tone';
|
||||
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
||||
|
||||
/**
|
||||
* KGMetronome - click track synchronized with Tone.Transport
|
||||
* Uses Tone.Loop so it automatically respects loop points and play position.
|
||||
*/
|
||||
export class KGMetronome {
|
||||
private loop: Tone.Loop | null = null;
|
||||
private sampler: Tone.Sampler | null = null;
|
||||
|
||||
/**
|
||||
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
|
||||
* runs in background (caller should not await).
|
||||
*/
|
||||
async initialize(masterOutput: Tone.ToneAudioNode): Promise<void> {
|
||||
try {
|
||||
this.sampler = await KGToneSamplerFactory.instance().createSampler('woodblock');
|
||||
this.sampler.connect(masterOutput);
|
||||
console.log('KGMetronome: woodblock sampler loaded');
|
||||
} catch (error) {
|
||||
console.error('KGMetronome: failed to load woodblock sampler', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metronome click loop.
|
||||
* @param startPositionBeats - Transport start position in beats (unused beyond logging)
|
||||
* @param beatsPerBar - numerator of the time signature
|
||||
* @param playbackDelay - seconds to offset audio trigger, matching MIDI note scheduling delay
|
||||
*/
|
||||
start(startPositionBeats: number, beatsPerBar: number, playbackDelay = 0): void {
|
||||
this.stop();
|
||||
|
||||
const ppq = Tone.Transport.PPQ;
|
||||
|
||||
this.loop = new Tone.Loop((time) => {
|
||||
if (this.sampler?.loaded) {
|
||||
// Derive bar position from the exact Transport tick count at the
|
||||
// scheduled audio time — no beatCount tracking needed, which avoids
|
||||
// all phase initialisation errors when playing from mid-bar.
|
||||
const ticks = Tone.Transport.getTicksAtTime(time);
|
||||
const beatNumber = Math.round(ticks / ppq);
|
||||
const note = beatNumber % beatsPerBar === 0 ? 'C5' : 'C4';
|
||||
this.sampler.triggerAttackRelease(note, '16n', time + playbackDelay);
|
||||
}
|
||||
}, '4n');
|
||||
|
||||
this.loop.start(0);
|
||||
console.log(`KGMetronome: started at beat ${startPositionBeats} (${beatsPerBar} beats/bar), delay ${playbackDelay}s`);
|
||||
}
|
||||
|
||||
/** Stop and dispose the loop only — sampler is kept alive for reuse. */
|
||||
stop(): void {
|
||||
if (this.loop) {
|
||||
this.loop.dispose();
|
||||
this.loop = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
if (this.sampler) {
|
||||
this.sampler.dispose();
|
||||
this.sampler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SAMPLER_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { ConfigManager } from '../config/ConfigManager';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
/**
|
||||
@@ -115,18 +116,23 @@ export class KGToneBuffersPool {
|
||||
* Create ToneAudioBuffers for an instrument
|
||||
*/
|
||||
private async createToneAudioBuffers(name: string): Promise<Tone.ToneAudioBuffers> {
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Get instrument configuration from constants
|
||||
const fluidConfig = SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID;
|
||||
const instrumentName = name;
|
||||
|
||||
if (!instrumentName) {
|
||||
throw new Error(`Unknown instrument: ${name}`);
|
||||
}
|
||||
|
||||
// Generate URL mapping for all keys from A0 to Bb7
|
||||
const urls = this.generateKeyUrls(fluidConfig.url, instrumentName);
|
||||
const baseUrl = (ConfigManager.instance().get('general.soundfont.base_url') as string)
|
||||
|| SAMPLER_CONSTANTS.TONE_SAMPLERS.FLUID.url;
|
||||
|
||||
const urls = this.generateKeyUrls(baseUrl, instrumentName);
|
||||
|
||||
console.log(`Loading ToneAudioBuffers for ${name} (${instrumentName})...`);
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ export { MoveRegionCommand } from './region/MoveRegionCommand';
|
||||
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
||||
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
||||
export { ImportAudioCommand } from './region/ImportAudioCommand';
|
||||
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
||||
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
||||
export type { StemImportEntry } from './region/ImportStemsCommand';
|
||||
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
||||
|
||||
// Note commands
|
||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import type { RawMidiNote } from '../../../util/midiUtil';
|
||||
|
||||
/**
|
||||
* Command to insert a MIDI clip (from K.G.One generation) into a MIDI track.
|
||||
* All note data is stored so that redo recreates the full region with notes.
|
||||
*/
|
||||
export class ImportMidiClipCommand extends KGCommand {
|
||||
private trackId: string;
|
||||
private trackIndex: number;
|
||||
private regionId: string;
|
||||
private regionName: string;
|
||||
private startBeat: number;
|
||||
private lengthInBeats: number;
|
||||
private rawNotes: RawMidiNote[];
|
||||
private createdRegion: KGMidiRegion | null = null;
|
||||
|
||||
constructor(
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
startBeat: number,
|
||||
lengthInBeats: number,
|
||||
rawNotes: RawMidiNote[],
|
||||
regionName?: string,
|
||||
regionId?: string
|
||||
) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.trackIndex = trackIndex;
|
||||
this.startBeat = startBeat;
|
||||
this.lengthInBeats = lengthInBeats;
|
||||
this.rawNotes = rawNotes;
|
||||
this.regionId = regionId || generateUniqueId('KGMidiRegion');
|
||||
this.regionName = regionName || 'KGOne Clip';
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const track = currentProject.getTracks().find(t => t.getId().toString() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track ${this.trackId} not found`);
|
||||
}
|
||||
|
||||
// Create the region
|
||||
this.createdRegion = new KGMidiRegion(
|
||||
this.regionId,
|
||||
this.trackId,
|
||||
this.trackIndex,
|
||||
this.regionName,
|
||||
this.startBeat,
|
||||
this.lengthInBeats
|
||||
);
|
||||
|
||||
// Populate notes
|
||||
for (const raw of this.rawNotes) {
|
||||
const note = new KGMidiNote(
|
||||
generateUniqueId('KGMidiNote'),
|
||||
raw.startBeat,
|
||||
raw.endBeat,
|
||||
raw.pitch,
|
||||
raw.velocity
|
||||
);
|
||||
this.createdRegion.addNote(note);
|
||||
}
|
||||
|
||||
track.addRegion(this.createdRegion);
|
||||
console.log(`Imported MIDI clip "${this.regionName}" (${this.rawNotes.length} notes) at beat ${this.startBeat}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const track = currentProject.getTracks().find(t => t.getId().toString() === this.trackId);
|
||||
if (track) {
|
||||
track.removeRegion(this.regionId);
|
||||
}
|
||||
console.log(`Undid MIDI clip import "${this.regionName}"`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Import MIDI clip "${this.regionName}"`;
|
||||
}
|
||||
|
||||
getCreatedRegion(): KGMidiRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory from bar-based coordinates (common UI pattern).
|
||||
*/
|
||||
static fromBarCoordinates(
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
barNumber: number,
|
||||
lengthInBars: number,
|
||||
beatsPerBar: number,
|
||||
rawNotes: RawMidiNote[],
|
||||
regionName?: string,
|
||||
regionId?: string
|
||||
): ImportMidiClipCommand {
|
||||
const startBeat = (barNumber - 1) * beatsPerBar;
|
||||
const lengthInBeats = lengthInBars * beatsPerBar;
|
||||
return new ImportMidiClipCommand(
|
||||
trackId, trackIndex, startBeat, lengthInBeats,
|
||||
rawNotes, regionName, regionId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as Tone from 'tone';
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGAudioTrack } from '../../track/KGAudioTrack';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
|
||||
|
||||
/**
|
||||
* Per-stem data passed to ImportStemsCommand.
|
||||
* All async work (OPFS storage, audio decode) must be completed before
|
||||
* constructing the command — execute() is synchronous.
|
||||
*/
|
||||
export interface StemImportEntry {
|
||||
trackName: string;
|
||||
regionName: string;
|
||||
audioFileId: string;
|
||||
audioFileName: string;
|
||||
audioDurationSeconds: number;
|
||||
toneBuffer: Tone.ToneAudioBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite command that atomically:
|
||||
* 1. Creates one KGAudioTrack per stem (appended to end)
|
||||
* 2. Reorders each new track to sit immediately below the original source track
|
||||
* 3. Creates one KGAudioRegion per stem, all starting at the same beat
|
||||
* 4. Expands maxBars if needed
|
||||
*
|
||||
* This is a single undo/redo step.
|
||||
*/
|
||||
export class ImportStemsCommand extends KGCommand {
|
||||
private readonly originalTrackCount: number;
|
||||
private readonly originalTrackIndex: number;
|
||||
private readonly insertBeat: number;
|
||||
private readonly stems: StemImportEntry[];
|
||||
private readonly originalMaxBars: number;
|
||||
|
||||
private createdTrackIds: number[] = [];
|
||||
private createdRegionIds: string[] = [];
|
||||
private finalMaxBars: number;
|
||||
|
||||
constructor(
|
||||
originalTrackCount: number,
|
||||
originalTrackIndex: number,
|
||||
insertBeat: number,
|
||||
stems: StemImportEntry[],
|
||||
originalMaxBars: number,
|
||||
) {
|
||||
super();
|
||||
this.originalTrackCount = originalTrackCount;
|
||||
this.originalTrackIndex = originalTrackIndex;
|
||||
this.insertBeat = insertBeat;
|
||||
this.stems = stems;
|
||||
this.originalMaxBars = originalMaxBars;
|
||||
this.finalMaxBars = originalMaxBars;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
this.createdTrackIds = [];
|
||||
this.createdRegionIds = [];
|
||||
|
||||
// ── 1. Compute starting track ID ──────────────────────────────────────
|
||||
const existingTracks = project.getTracks();
|
||||
let nextId = existingTracks.length > 0
|
||||
? Math.max(...existingTracks.map(t => t.getId())) + 1
|
||||
: 1;
|
||||
|
||||
// ── 2. Append one audio track per stem ────────────────────────────────
|
||||
for (const stem of this.stems) {
|
||||
const trackId = nextId++;
|
||||
this.createdTrackIds.push(trackId);
|
||||
|
||||
const track = new KGAudioTrack(stem.trackName, trackId);
|
||||
track.setTrackIndex(project.getTracks().length);
|
||||
project.setTracks([...project.getTracks(), track]);
|
||||
|
||||
// Bus creation is async; chain buffer load so it runs once the bus is ready
|
||||
audioInterface.createTrackAudioPlayerBus(trackId.toString()).then(() => {
|
||||
audioInterface.loadAudioBufferForTrack(trackId.toString(), stem.audioFileId, stem.toneBuffer);
|
||||
}).catch(err => {
|
||||
console.error(`[ImportStemsCommand] Failed to create bus for track ${trackId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Reorder each stem track to sit just below the original ─────────
|
||||
// For stem i: sourceIndex = originalTrackCount + i, destIndex = originalTrackIndex + 1 + i
|
||||
// Each reorder shifts the remaining appended stems left by 1 in the tail, but their
|
||||
// absolute indices stay at originalTrackCount + i because each move keeps them packed.
|
||||
for (let i = 0; i < this.stems.length; i++) {
|
||||
const srcIdx = this.originalTrackCount + i;
|
||||
const destIdx = this.originalTrackIndex + 1 + i;
|
||||
|
||||
if (srcIdx === destIdx) continue; // no-op when original is the last track
|
||||
|
||||
const tracks = project.getTracks();
|
||||
const updated = [...tracks];
|
||||
const [moved] = updated.splice(srcIdx, 1);
|
||||
updated.splice(destIdx, 0, moved);
|
||||
updated.forEach((t, idx) => t.setTrackIndex(idx));
|
||||
project.setTracks(updated);
|
||||
}
|
||||
|
||||
// ── 4. Create one audio region per stem ───────────────────────────────
|
||||
const bpm = project.getBpm();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
let currentMaxBars = this.originalMaxBars;
|
||||
|
||||
for (let i = 0; i < this.stems.length; i++) {
|
||||
const stem = this.stems[i];
|
||||
const trackId = this.createdTrackIds[i];
|
||||
|
||||
// Look up the track by ID so we use the post-reorder trackIndex
|
||||
const track = project.getTracks().find(t => t.getId() === trackId);
|
||||
if (!track) continue;
|
||||
|
||||
const durationInBeats = stem.audioDurationSeconds * (bpm / 60);
|
||||
const endBeat = this.insertBeat + durationInBeats;
|
||||
const requiredBars = Math.ceil(endBeat / beatsPerBar);
|
||||
const newMaxBars = Math.max(currentMaxBars, requiredBars);
|
||||
|
||||
const regionId = `audio_region_${Date.now()}_${Math.random().toString(36).substring(2, 8)}_${i}`;
|
||||
this.createdRegionIds.push(regionId);
|
||||
|
||||
const region = new KGAudioRegion(
|
||||
regionId,
|
||||
trackId.toString(),
|
||||
track.getTrackIndex(),
|
||||
stem.regionName,
|
||||
this.insertBeat,
|
||||
durationInBeats,
|
||||
stem.audioFileId,
|
||||
stem.audioFileName,
|
||||
stem.audioDurationSeconds,
|
||||
);
|
||||
track.addRegion(region);
|
||||
currentMaxBars = newMaxBars;
|
||||
}
|
||||
|
||||
// ── 5. Expand project maxBars if needed ───────────────────────────────
|
||||
this.finalMaxBars = currentMaxBars;
|
||||
if (this.finalMaxBars > this.originalMaxBars) {
|
||||
project.setMaxBars(this.finalMaxBars);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
// Remove all created tracks and their audio buses
|
||||
const remaining = project.getTracks().filter(t => !this.createdTrackIds.includes(t.getId()));
|
||||
remaining.forEach((t, idx) => t.setTrackIndex(idx));
|
||||
project.setTracks(remaining);
|
||||
|
||||
for (const trackId of this.createdTrackIds) {
|
||||
audioInterface.removeTrackAudioPlayerBus(trackId.toString());
|
||||
}
|
||||
|
||||
// Revert maxBars if it was expanded
|
||||
if (this.finalMaxBars > this.originalMaxBars) {
|
||||
project.setMaxBars(this.originalMaxBars);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const n = this.stems.length;
|
||||
return `Import ${n} stem${n !== 1 ? 's' : ''} to timeline`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGRegion } from '../../region/KGRegion';
|
||||
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { KGTrack } from '../../track/KGTrack';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
/**
|
||||
* Command to split a region into two at the given beat position.
|
||||
*
|
||||
* MIDI regions: notes whose startBeat is before the split stay in region 1
|
||||
* (endBeat is NOT clamped even if it crosses the boundary). Notes whose
|
||||
* startBeat is at or after the split go to region 2 with their beat
|
||||
* positions shifted so they are relative to the new region start.
|
||||
*
|
||||
* Audio regions: both halves share the same audioFileId / audioFileName /
|
||||
* audioDurationSeconds (no file copy). Region 2 gets a new
|
||||
* clipStartOffsetSeconds = original + splitOffsetSeconds.
|
||||
*/
|
||||
export class SplitRegionCommand extends KGCommand {
|
||||
private regionId: string;
|
||||
private splitAtBeat: number; // absolute beat position of the playhead
|
||||
|
||||
// Stored during execute(), used during undo()
|
||||
private originalRegion: KGRegion | null = null;
|
||||
private region1: KGRegion | null = null;
|
||||
private region2: KGRegion | null = null;
|
||||
private targetTrack: KGTrack | null = null;
|
||||
private originalRegionIndex: number = -1;
|
||||
|
||||
constructor(regionId: string, splitAtBeat: number) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.splitAtBeat = splitAtBeat;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
let targetTrack: KGTrack | null = null;
|
||||
let originalRegion: KGRegion | null = null;
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const idx = regions.findIndex(r => r.getId() === this.regionId);
|
||||
if (idx !== -1) {
|
||||
targetTrack = track;
|
||||
originalRegion = regions[idx];
|
||||
this.originalRegionIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTrack || !originalRegion) {
|
||||
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.targetTrack = targetTrack;
|
||||
this.originalRegion = originalRegion;
|
||||
|
||||
const regionStart = originalRegion.getStartFromBeat();
|
||||
const regionLength = originalRegion.getLength();
|
||||
const splitOffsetBeats = this.splitAtBeat - regionStart;
|
||||
|
||||
if (splitOffsetBeats <= 0 || splitOffsetBeats >= regionLength) {
|
||||
throw new Error(
|
||||
`Split point ${this.splitAtBeat} is not within region range [${regionStart}, ${regionStart + regionLength})`
|
||||
);
|
||||
}
|
||||
|
||||
const trackId = targetTrack.getId().toString();
|
||||
const trackIdx = targetTrack.getTrackIndex();
|
||||
|
||||
if (originalRegion instanceof KGMidiRegion) {
|
||||
const region1 = new KGMidiRegion(
|
||||
generateUniqueId('KGMidiRegion'),
|
||||
trackId,
|
||||
trackIdx,
|
||||
originalRegion.getName(),
|
||||
regionStart,
|
||||
splitOffsetBeats
|
||||
);
|
||||
|
||||
const region2 = new KGMidiRegion(
|
||||
generateUniqueId('KGMidiRegion'),
|
||||
trackId,
|
||||
trackIdx,
|
||||
originalRegion.getName() + ' (2)',
|
||||
this.splitAtBeat,
|
||||
regionLength - splitOffsetBeats
|
||||
);
|
||||
|
||||
for (const note of originalRegion.getNotes()) {
|
||||
if (note.getStartBeat() < splitOffsetBeats) {
|
||||
// Stays in region 1 — copy as-is, do not clamp endBeat
|
||||
region1.addNote(new KGMidiNote(
|
||||
generateUniqueId('KGMidiNote'),
|
||||
note.getStartBeat(),
|
||||
note.getEndBeat(),
|
||||
note.getPitch(),
|
||||
note.getVelocity()
|
||||
));
|
||||
} else {
|
||||
// Goes into region 2 — shift both beats relative to new region start
|
||||
region2.addNote(new KGMidiNote(
|
||||
generateUniqueId('KGMidiNote'),
|
||||
note.getStartBeat() - splitOffsetBeats,
|
||||
note.getEndBeat() - splitOffsetBeats,
|
||||
note.getPitch(),
|
||||
note.getVelocity()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
this.region1 = region1;
|
||||
this.region2 = region2;
|
||||
|
||||
} else if (originalRegion instanceof KGAudioRegion) {
|
||||
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
||||
const splitOffsetSeconds = splitOffsetBeats * (60 / bpm);
|
||||
|
||||
this.region1 = new KGAudioRegion(
|
||||
generateUniqueId('KGAudioRegion'),
|
||||
trackId,
|
||||
trackIdx,
|
||||
originalRegion.getName(),
|
||||
regionStart,
|
||||
splitOffsetBeats,
|
||||
originalRegion.getAudioFileId(),
|
||||
originalRegion.getAudioFileName(),
|
||||
originalRegion.getAudioDurationSeconds(),
|
||||
originalRegion.getClipStartOffsetSeconds()
|
||||
);
|
||||
|
||||
this.region2 = new KGAudioRegion(
|
||||
generateUniqueId('KGAudioRegion'),
|
||||
trackId,
|
||||
trackIdx,
|
||||
originalRegion.getName() + ' (2)',
|
||||
this.splitAtBeat,
|
||||
regionLength - splitOffsetBeats,
|
||||
originalRegion.getAudioFileId(),
|
||||
originalRegion.getAudioFileName(),
|
||||
originalRegion.getAudioDurationSeconds(),
|
||||
originalRegion.getClipStartOffsetSeconds() + splitOffsetSeconds
|
||||
);
|
||||
|
||||
} else {
|
||||
throw new Error(`Unsupported region type for splitting: ${originalRegion.getCurrentType()}`);
|
||||
}
|
||||
|
||||
// Replace original region with the two new regions
|
||||
const regions = targetTrack.getRegions();
|
||||
regions.splice(this.originalRegionIndex, 1, this.region1!, this.region2!);
|
||||
targetTrack.setRegions(regions);
|
||||
|
||||
// Close piano roll if it is open for the region being split
|
||||
const { activeRegionId, showPianoRoll, setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||
if (showPianoRoll && activeRegionId === this.regionId) {
|
||||
setShowPianoRoll(false);
|
||||
setActiveRegionId(null);
|
||||
console.log(`Closed piano roll because active region ${this.regionId} is being split`);
|
||||
}
|
||||
|
||||
console.log(`Split region "${originalRegion.getName()}" at beat ${this.splitAtBeat} (offset ${splitOffsetBeats} beats)`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.targetTrack || !this.originalRegion || !this.region1 || !this.region2) {
|
||||
throw new Error('Cannot undo: split was never executed');
|
||||
}
|
||||
|
||||
// Find region1 by ID (robust to any reordering)
|
||||
const regions = this.targetTrack.getRegions();
|
||||
const idx1 = regions.findIndex(r => r.getId() === this.region1!.getId());
|
||||
if (idx1 === -1) {
|
||||
throw new Error('Cannot undo: split regions not found in track');
|
||||
}
|
||||
|
||||
// Remove the two produced regions and restore the original
|
||||
regions.splice(idx1, 2, this.originalRegion);
|
||||
this.targetTrack.setRegions(regions);
|
||||
|
||||
console.log(`Restored region "${this.originalRegion.getName()}" (undo split)`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const name = this.originalRegion ? this.originalRegion.getName() : `Region ${this.regionId}`;
|
||||
return `Split region "${name}"`;
|
||||
}
|
||||
|
||||
public getRegionId(): string {
|
||||
return this.regionId;
|
||||
}
|
||||
|
||||
public getCreatedRegion1(): KGRegion | null {
|
||||
return this.region1;
|
||||
}
|
||||
|
||||
public getCreatedRegion2(): KGRegion | null {
|
||||
return this.region2;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ interface AppConfig {
|
||||
soundfont: {
|
||||
base_url: string;
|
||||
};
|
||||
kgone: {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
};
|
||||
};
|
||||
hotkeys: {
|
||||
main: {
|
||||
@@ -100,6 +104,8 @@ export class ConfigManager {
|
||||
private config: AppConfig;
|
||||
private storage: KGConfigStorage;
|
||||
private isInitialized: boolean = false;
|
||||
private kgoneServerManaged: boolean = false;
|
||||
private soundfontServerManaged: boolean = false;
|
||||
private defaultConfig: AppConfig | null = null;
|
||||
private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
|
||||
|
||||
@@ -199,6 +205,10 @@ export class ConfigManager {
|
||||
},
|
||||
soundfont: {
|
||||
base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'
|
||||
},
|
||||
kgone: {
|
||||
enabled: false,
|
||||
base_url: 'http://127.0.0.1:8000'
|
||||
}
|
||||
},
|
||||
hotkeys: {
|
||||
@@ -548,6 +558,35 @@ export class ConfigManager {
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at startup when kgone-server.json contains a base_url field.
|
||||
* Forces enabled=true and overrides base_url in-memory only (not persisted).
|
||||
*/
|
||||
public setKGOneManagedByServer(url: string): void {
|
||||
this.kgoneServerManaged = true;
|
||||
this.setInObject(this.config as Record<string, unknown>, 'general.kgone.enabled', true);
|
||||
this.setInObject(this.config as Record<string, unknown>, 'general.kgone.base_url', url);
|
||||
this.notifyChangeListeners(['general.kgone.enabled', 'general.kgone.base_url']);
|
||||
}
|
||||
|
||||
public isKGOneServerManaged(): boolean {
|
||||
return this.kgoneServerManaged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at startup when kgone-server.json contains a soundfont field.
|
||||
* Overrides general.soundfont.base_url in-memory only (not persisted).
|
||||
*/
|
||||
public setSoundfontManagedByServer(url: string): void {
|
||||
this.soundfontServerManaged = true;
|
||||
this.setInObject(this.config as Record<string, unknown>, 'general.soundfont.base_url', url);
|
||||
this.notifyChangeListeners(['general.soundfont.base_url']);
|
||||
}
|
||||
|
||||
public isSoundfontServerManaged(): boolean {
|
||||
return this.soundfontServerManaged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default configuration
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,13 @@ export class KGProjectStorage {
|
||||
public async initialize(): Promise<void> {
|
||||
if (this._initialized) return;
|
||||
|
||||
if (!navigator.storage?.getDirectory) {
|
||||
throw new Error(
|
||||
'OPFS is unavailable. K.G.Studio requires a secure context (HTTPS or localhost). ' +
|
||||
'Access via https:// or use localhost/127.0.0.1 instead of an IP address.',
|
||||
);
|
||||
}
|
||||
|
||||
this.rootDirHandle = await navigator.storage.getDirectory();
|
||||
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
|
||||
OPFS_CONSTANTS.ROOT_DIR,
|
||||
@@ -351,11 +358,11 @@ export class KGProjectStorage {
|
||||
* 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> {
|
||||
public async saveWithRename(oldName: string, newName: string, data: KGProject, overwrite: boolean = false): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
// Save project JSON to the new folder
|
||||
await this.save(newName, data, false);
|
||||
await this.save(newName, data, overwrite);
|
||||
|
||||
// Migrate media files only if the old folder exists
|
||||
if (await this.exists(oldName)) {
|
||||
|
||||
+2
-10
@@ -19,7 +19,8 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -49,15 +50,6 @@ button {
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 2px auto rgba(100, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 12px;
|
||||
|
||||
+50
-1
@@ -4,11 +4,57 @@ import { createRoot } from 'react-dom/client';
|
||||
import './styles/variables.css';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import DialogProvider from './components/common/DialogProvider';
|
||||
import { KGCore } from './core/KGCore';
|
||||
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
|
||||
import { KGMidiInput } from './core/midi-input/KGMidiInput';
|
||||
import { KGDebugger } from './core/KGDebugger';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
|
||||
// OPFS requires a secure context (HTTPS or localhost). Show a clear error instead of crashing.
|
||||
if (!window.isSecureContext) {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
gap: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: 'rgba(255,255,255,0.87)',
|
||||
background: '#1e1e1e',
|
||||
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px' }}>🔒</div>
|
||||
<h1 style={{ fontSize: '22px', fontWeight: 600, margin: 0 }}>Secure Context Required</h1>
|
||||
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.55)', maxWidth: '480px', lineHeight: 1.6 }}>
|
||||
K.G.Studio uses the browser's Origin Private File System (OPFS) to store projects,
|
||||
which requires a <strong style={{ color: 'rgba(255,255,255,0.8)' }}>secure context</strong>.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: '#2a2a2a',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '8px',
|
||||
padding: '16px 24px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 2,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
<div>Use <code style={{ color: '#7cb8ff' }}>https://</code> instead of <code style={{ color: '#f28b82' }}>http://</code></div>
|
||||
<div>or access via <code style={{ color: '#7cb8ff' }}>localhost</code> / <code style={{ color: '#7cb8ff' }}>127.0.0.1</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</StrictMode>,
|
||||
);
|
||||
} else {
|
||||
// Initialize KGCore instance
|
||||
await KGCore.instance().initialize();
|
||||
|
||||
@@ -87,8 +133,11 @@ window.addEventListener('beforeunload', (event) => {
|
||||
// Note: Custom messages are no longer supported in modern browsers for security reasons
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
} // end isSecureContext else
|
||||
|
||||
@@ -59,9 +59,11 @@ interface ProjectState {
|
||||
keySignature: KeySignature;
|
||||
selectedMode: string;
|
||||
isLooping: boolean;
|
||||
isMetronomeEnabled: boolean;
|
||||
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
|
||||
playheadPosition: number; // in beats
|
||||
isPlaying: boolean;
|
||||
autoScrollEnabled: boolean;
|
||||
currentTime: string; // formatted time string
|
||||
|
||||
// Selection state for UI reactivity
|
||||
@@ -76,6 +78,9 @@ interface ProjectState {
|
||||
// ChatBox state
|
||||
showChatBox: boolean;
|
||||
|
||||
// K.G.One panel state
|
||||
showKGOnePanel: boolean;
|
||||
|
||||
// Instrument selection panel state
|
||||
showInstrumentSelection: boolean;
|
||||
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
|
||||
@@ -111,9 +116,11 @@ interface ProjectState {
|
||||
refreshStatus: () => void;
|
||||
loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
|
||||
setPlayheadPosition: (position: number) => void;
|
||||
setAutoScrollEnabled: (enabled: boolean) => void;
|
||||
startPlaying: () => Promise<void>;
|
||||
stopPlaying: () => Promise<void>;
|
||||
toggleLoop: () => void;
|
||||
toggleMetronome: () => void;
|
||||
setBpm: (bpm: number) => void;
|
||||
setMaxBars: (maxBars: number) => void;
|
||||
setBarWidthMultiplier: (multiplier: number) => void;
|
||||
@@ -137,6 +144,9 @@ interface ProjectState {
|
||||
setShowChatBox: (show: boolean) => void;
|
||||
toggleChatBox: () => void;
|
||||
|
||||
// K.G.One panel actions
|
||||
toggleKGOnePanel: () => void;
|
||||
|
||||
// Instrument selection panel actions
|
||||
openInstrumentSelectionForTrack: () => void;
|
||||
toggleInstrumentSelectionForTrack: () => void;
|
||||
@@ -261,9 +271,11 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
keySignature: currentProject.getKeySignature(),
|
||||
selectedMode: currentProject.getSelectedMode(),
|
||||
isLooping: currentProject.getIsLooping(),
|
||||
isMetronomeEnabled: false,
|
||||
loopingRange: currentProject.getLoopingRange(),
|
||||
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
||||
isPlaying: KGCore.instance().getIsPlaying(),
|
||||
autoScrollEnabled: true,
|
||||
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
|
||||
|
||||
// Initial selection state
|
||||
@@ -278,6 +290,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
// Initial ChatBox state
|
||||
showChatBox: initialChatBoxState,
|
||||
|
||||
// Initial K.G.One panel state
|
||||
showKGOnePanel: false,
|
||||
|
||||
// Initial Instrument Selection panel state
|
||||
showInstrumentSelection: initialShowInstrumentSelection,
|
||||
|
||||
@@ -743,9 +758,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
});
|
||||
},
|
||||
|
||||
setAutoScrollEnabled: (enabled: boolean) => {
|
||||
set({ autoScrollEnabled: enabled });
|
||||
},
|
||||
|
||||
startPlaying: async () => {
|
||||
await KGCore.instance().startPlaying();
|
||||
set({ isPlaying: true });
|
||||
set({ isPlaying: true, autoScrollEnabled: true });
|
||||
},
|
||||
|
||||
stopPlaying: async () => {
|
||||
@@ -764,6 +783,24 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
toggleLoop(isLooping, loopingRange, maxBars);
|
||||
},
|
||||
|
||||
toggleMetronome: () => {
|
||||
const { isMetronomeEnabled, isPlaying, timeSignature } = get();
|
||||
const newValue = !isMetronomeEnabled;
|
||||
set({ isMetronomeEnabled: newValue });
|
||||
|
||||
const audio = KGAudioInterface.instance();
|
||||
audio.setMetronomeEnabled(newValue);
|
||||
|
||||
if (isPlaying) {
|
||||
if (newValue) {
|
||||
const currentBeat = KGCore.instance().getPlayheadPosition();
|
||||
audio.startMetronomeDuringPlayback(currentBeat, timeSignature.numerator);
|
||||
} else {
|
||||
audio.stopMetronomeDuringPlayback();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setBpm: (bpm: number) => {
|
||||
try {
|
||||
// Create and execute the change project property command
|
||||
@@ -898,7 +935,12 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
|
||||
toggleChatBox: () => {
|
||||
const { showChatBox } = get();
|
||||
set({ showChatBox: !showChatBox });
|
||||
set({ showChatBox: !showChatBox, showKGOnePanel: false });
|
||||
},
|
||||
|
||||
toggleKGOnePanel: () => {
|
||||
const { showKGOnePanel } = get();
|
||||
set({ showKGOnePanel: !showKGOnePanel, showChatBox: false });
|
||||
},
|
||||
|
||||
// Instrument selection panel actions
|
||||
|
||||
+14
-5
@@ -1,6 +1,7 @@
|
||||
/* Shared toolbar layout classes (used by Toolbar and PianoRollToolbar) */
|
||||
|
||||
.toolbar-left, .toolbar-right {
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
@@ -26,7 +27,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1005;
|
||||
margin-left: -50px;
|
||||
margin-left: -120px;
|
||||
}
|
||||
|
||||
/* Tool buttons (used by Toolbar and PianoRollToolbar) */
|
||||
@@ -119,7 +120,15 @@
|
||||
}
|
||||
|
||||
@keyframes buttonBlink {
|
||||
0% { background-color: #333; }
|
||||
50% { background-color: #555; }
|
||||
100% { background-color: #333; }
|
||||
0% {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: #333;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
function writeString(view: DataView, offset: number, str: string) {
|
||||
for (let i = 0; i < str.length; i++)
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
|
||||
export async function sliceAudioToWav(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
startSeconds: number,
|
||||
durationSeconds: number
|
||||
): Promise<ArrayBuffer> {
|
||||
const audioCtx = new AudioContext();
|
||||
const decoded = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
audioCtx.close();
|
||||
|
||||
const { sampleRate, numberOfChannels: numCh } = decoded;
|
||||
const startSample = Math.floor(startSeconds * sampleRate);
|
||||
const numSamples = Math.min(
|
||||
Math.floor(durationSeconds * sampleRate),
|
||||
decoded.length - startSample
|
||||
);
|
||||
|
||||
const bytesPerSample = 2; // 16-bit PCM
|
||||
const dataSize = numSamples * numCh * bytesPerSample;
|
||||
const buf = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buf);
|
||||
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, numCh, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * numCh * bytesPerSample, true);
|
||||
view.setUint16(32, numCh * bytesPerSample, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let offset = 44;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
for (let ch = 0; ch < numCh; ch++) {
|
||||
const s = decoded.getChannelData(ch)[startSample + i];
|
||||
const c = Math.max(-1, Math.min(1, s));
|
||||
view.setInt16(offset, c < 0 ? c * 32768 : c * 32767, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
@@ -465,6 +465,40 @@ function getKeySignatureBytes(keySignature: KeySignature): Uint8Array {
|
||||
return new Uint8Array([0, 0]);
|
||||
}
|
||||
|
||||
// ─── K.G.One clip MIDI import helpers ────────────────────────────────────────
|
||||
|
||||
export interface RawMidiNote {
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a MIDI binary and returns all notes from the first track that has
|
||||
* notes, with beat offsets normalised so the earliest note starts at beat 0.
|
||||
* Used by the K.G.One Clip drag-to-MIDI-track feature.
|
||||
*/
|
||||
export function parseMidiFirstTrackNotes(data: Uint8Array): {
|
||||
notes: RawMidiNote[];
|
||||
totalBeats: number;
|
||||
} {
|
||||
const midiFile = parseMidiFile(data);
|
||||
const firstTrack = midiFile.tracks.find(t => t.notes.length > 0);
|
||||
if (!firstTrack || firstTrack.notes.length === 0) {
|
||||
return { notes: [], totalBeats: 0 };
|
||||
}
|
||||
const minBeat = Math.min(...firstTrack.notes.map(n => n.startBeat));
|
||||
const notes: RawMidiNote[] = firstTrack.notes.map(n => ({
|
||||
startBeat: n.startBeat - minBeat,
|
||||
endBeat: n.endBeat - minBeat,
|
||||
pitch: n.pitch,
|
||||
velocity: n.velocity,
|
||||
}));
|
||||
const totalBeats = Math.max(...notes.map(n => n.endBeat));
|
||||
return { notes, totalBeats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a MIDI binary file to a KGSP project
|
||||
* @param midiData - The MIDI file data as Uint8Array
|
||||
|
||||
@@ -9,6 +9,12 @@ const VALID_PROJECT_NAME_REGEX = /^[a-zA-Z0-9 \-_.()\u00C0-\u024F]+$/;
|
||||
*/
|
||||
const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g;
|
||||
|
||||
/**
|
||||
* The reserved project name used for unsaved/new projects.
|
||||
* Users cannot rename a project to this name, and its OPFS folder is wiped on every startup.
|
||||
*/
|
||||
export const RESERVED_PROJECT_NAME = "Untitled Project";
|
||||
|
||||
/**
|
||||
* Validate whether a project name contains only allowed characters.
|
||||
* Does NOT check for empty string — caller should check that separately.
|
||||
@@ -19,6 +25,13 @@ export function isValidProjectName(name: string): boolean {
|
||||
return VALID_PROJECT_NAME_REGEX.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given name matches the reserved "Untitled Project" name.
|
||||
*/
|
||||
export function isReservedProjectName(name: string): boolean {
|
||||
return name.trim() === RESERVED_PROJECT_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a project name by replacing disallowed characters with underscores,
|
||||
* collapsing consecutive underscores/spaces, and trimming.
|
||||
@@ -37,7 +50,7 @@ export function sanitizeProjectName(name: string): string {
|
||||
|
||||
// If everything was stripped, provide a fallback
|
||||
if (sanitized.length === 0) {
|
||||
sanitized = 'Untitled Project';
|
||||
sanitized = RESERVED_PROJECT_NAME;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { KGONE_CONSTANTS } from '../constants/coreConstants';
|
||||
|
||||
export interface RetryConfig {
|
||||
maxAttempts?: number;
|
||||
initialDelayMs?: number;
|
||||
backoffMultiplier?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps fetch() with exponential-backoff retry on non-200 responses or
|
||||
* network errors. AbortErrors propagate immediately — no retry.
|
||||
*
|
||||
* Use only for idempotent calls (polling, downloads).
|
||||
* Do NOT use for load-model or job-submission calls.
|
||||
*/
|
||||
export async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
config: RetryConfig = {}
|
||||
): Promise<Response> {
|
||||
const {
|
||||
maxAttempts = KGONE_CONSTANTS.MAX_RETRY_ATTEMPTS,
|
||||
initialDelayMs = KGONE_CONSTANTS.RETRY_INITIAL_DELAY_MS,
|
||||
backoffMultiplier = KGONE_CONSTANTS.RETRY_BACKOFF_MULTIPLIER,
|
||||
} = config;
|
||||
|
||||
const signal = options.signal as AbortSignal | undefined;
|
||||
let delay = initialDelayMs;
|
||||
let lastError: Error = new Error('Unknown error');
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const resp = await fetch(url, options);
|
||||
if (resp.ok) return resp;
|
||||
|
||||
const body = await resp.text().catch(() => '');
|
||||
lastError = new Error(`HTTP ${resp.status}${body ? `: ${body}` : ''}`);
|
||||
} catch (err) {
|
||||
// Propagate abort immediately — user cancelled, do not retry
|
||||
if (err instanceof DOMException && err.name === 'AbortError') throw err;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts - 1) {
|
||||
// Abort-aware sleep before the next attempt
|
||||
await new Promise<void>(resolve => {
|
||||
const t = setTimeout(resolve, delay);
|
||||
signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
|
||||
});
|
||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
delay *= backoffMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
+23
-26
@@ -1,5 +1,7 @@
|
||||
import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage';
|
||||
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { RESERVED_PROJECT_NAME } from './projectNameUtil';
|
||||
import { showAlert } from '../components/common/DialogProvider';
|
||||
|
||||
/**
|
||||
* Save project utility function.
|
||||
@@ -17,14 +19,27 @@ export const saveProject = async (
|
||||
savedProjectName: string,
|
||||
setStatus: (status: string) => void,
|
||||
onSaveSuccess: (finalName: string) => void,
|
||||
forceOverwrite: boolean = false,
|
||||
): Promise<boolean> => {
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
|
||||
// Auto-rename reserved "Untitled Project" to "Untitled Project (1)", "(2)", etc.
|
||||
if (projectName === RESERVED_PROJECT_NAME) {
|
||||
let counter = 1;
|
||||
let autoName = `${RESERVED_PROJECT_NAME} (${counter})`;
|
||||
while (await storage.exists(autoName)) {
|
||||
counter++;
|
||||
autoName = `${RESERVED_PROJECT_NAME} (${counter})`;
|
||||
}
|
||||
projectName = autoName;
|
||||
}
|
||||
|
||||
const isRename = savedProjectName !== projectName;
|
||||
|
||||
if (isRename) {
|
||||
// Determine the target name, resolving conflicts automatically
|
||||
// Determine the target name; skip conflict resolution when caller already confirmed overwrite
|
||||
let targetName = projectName;
|
||||
if (await storage.exists(projectName)) {
|
||||
if (!forceOverwrite && await storage.exists(projectName)) {
|
||||
targetName = await storage.resolveUniqueName(projectName);
|
||||
}
|
||||
|
||||
@@ -33,6 +48,7 @@ export const saveProject = async (
|
||||
savedProjectName,
|
||||
targetName,
|
||||
KGCore.instance().getCurrentProject(),
|
||||
forceOverwrite,
|
||||
);
|
||||
|
||||
const statusMsg =
|
||||
@@ -44,39 +60,20 @@ export const saveProject = async (
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving renamed project:', error);
|
||||
window.alert(`An error occurred while saving: ${error}`);
|
||||
await showAlert(`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) {
|
||||
const confirmed = window.confirm(
|
||||
`Project "${projectName}" already exists. Do you want to overwrite it?`,
|
||||
);
|
||||
if (confirmed) {
|
||||
// Same name — overwrite silently (user hasn't changed the name, so no confirmation needed)
|
||||
try {
|
||||
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
onSaveSuccess(projectName);
|
||||
return true;
|
||||
} catch (overwriteError) {
|
||||
console.error('Error overwriting project:', overwriteError);
|
||||
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
} catch (error) {
|
||||
console.error('Error saving project:', error);
|
||||
window.alert(`An unknown error ${error} occurred. Please try again.`);
|
||||
await showAlert(`An error occurred while saving: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+10
-9
@@ -1,13 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
// Read version from package.json
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const packageJson = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'))
|
||||
const version = packageJson.version
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const packageJson = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
|
||||
const version = packageJson.version;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -17,7 +17,8 @@ export default defineConfig({
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
// IMPORTANT: MAKE SURE TO UPDATE YOUR OS HOSTS FILE TO POINT `testlocal.com` TO YOUR LOCAL IP (e.g. 127.0.0.1).
|
||||
allowedHosts: ['testlocal.com', '.testlocal.com', 'localhost', '127.0.0.1'],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -59,4 +59,4 @@ export default defineConfig({
|
||||
// Retry failed tests once
|
||||
retry: 1
|
||||
}
|
||||
})
|
||||
});
|
||||
Reference in New Issue
Block a user