Merge pull request #36 from KGAudioLab/feat/2026-04-30-midi-device-recording

Feat/2026 04 30 midi device recording
This commit is contained in:
Xiaohan-Tian
2026-05-02 16:21:05 -07:00
committed by GitHub
26 changed files with 1607 additions and 221 deletions
+1
View File
@@ -23,6 +23,7 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
## Latest Updates ## Latest Updates
- **2026.05.02**: Added **audio track spectrogram visualization** — audio regions now display a real-time spectrogram overlay in the track grid. Added **Piano Roll hybrid mode**: open the piano roll on a MIDI region while an adjacent audio region's spectrogram is shown as a reference layer, letting you edit MIDI notes against the visual shape of the audio. Added **piano roll zoom in/out** with viewport-position preservation so the view stays anchored to the current playhead. Added **fine-tune region position**: nudge regions by small increments for precise placement. Also added cross-component playhead scroll synchronization so the main grid and piano roll stay in sync during playback.
- **2026.04.29**: Added **Remix** and **Repaint** to the K.G.One Music Generator panel (powered by ACE-Step 1.5). **Remix** lets you cover an existing audio region in a new style — select an audio region, describe the target style and optionally provide new lyrics, and ACE-Step will re-perform the song with the prompted instrumentation and feel. **Repaint** lets you surgically re-generate a specific section of a song — set a loop range on the timeline to define the repaint window, then describe what you want that section to sound like; the rest of the song stays untouched. Both tools support the same import workflow as the other K.G.One tabs: preview the result in the built-in player, drag it onto a track, or click **Import Aligned to Source** to automatically place it below the original region in a new track. - **2026.04.29**: Added **Remix** and **Repaint** to the K.G.One Music Generator panel (powered by ACE-Step 1.5). **Remix** lets you cover an existing audio region in a new style — select an audio region, describe the target style and optionally provide new lyrics, and ACE-Step will re-perform the song with the prompted instrumentation and feel. **Repaint** lets you surgically re-generate a specific section of a song — set a loop range on the timeline to define the repaint window, then describe what you want that section to sound like; the rest of the song stays untouched. Both tools support the same import workflow as the other K.G.One tabs: preview the result in the built-in player, drag it onto a track, or click **Import Aligned to Source** to automatically place it below the original region in a new track.
- **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.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.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.
+9 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.10.0-build.20260411", "version": "0.12.0-build.20260430",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.10.0-build.20260411", "version": "0.12.0-build.20260430",
"dependencies": { "dependencies": {
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"fft.js": "^4.0.4",
"idb": "^8.0.3", "idb": "^8.0.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"openai": "^6.33.0", "openai": "^6.33.0",
@@ -4691,6 +4692,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fft.js": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz",
"integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==",
"license": "MIT"
},
"node_modules/figures": { "node_modules/figures": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"fft.js": "^4.0.4",
"idb": "^8.0.3", "idb": "^8.0.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"openai": "^6.33.0", "openai": "^6.33.0",
+4 -4
View File
@@ -10,7 +10,7 @@
.main-content-wrapper { .main-content-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: calc(200px + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */ min-width: calc(var(--track-info-panel-width) + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */
min-height: fit-content; min-height: fit-content;
position: relative; position: relative;
} }
@@ -19,7 +19,7 @@
position: fixed; position: fixed;
top: 50px; top: 50px;
left: 0; left: 0;
width: 200px; width: var(--track-info-panel-width);
height: 20px; height: 20px;
background-color: #2d2d2d; background-color: #2d2d2d;
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
@@ -42,7 +42,7 @@
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d; background-color: #2d2d2d;
z-index: 20; z-index: 20;
margin-left: 200px; /* Offset for info-container */ margin-left: var(--track-info-panel-width); /* Offset for info-container */
width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */ width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */ cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
} }
@@ -80,7 +80,7 @@
.info-container { .info-container {
position: sticky; position: sticky;
left: 0; left: 0;
width: 200px; width: var(--track-info-panel-width);
z-index: 1002; z-index: 1002;
background-color: #2d2d2d; background-color: #2d2d2d;
align-self: flex-start; align-self: flex-start;
+147 -11
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import './MainContent.css'; import './MainContent.css';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
@@ -10,7 +10,7 @@ import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel'; import TrackGridPanel from './track/TrackGridPanel';
import PianoRoll from './piano-roll/PianoRoll'; import PianoRoll from './piano-roll/PianoRoll';
import type { RegionUI } from './interfaces'; import type { RegionUI } from './interfaces';
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants'; import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constants';
import { useRegionOperations } from '../hooks/useRegionOperations'; import { useRegionOperations } from '../hooks/useRegionOperations';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
import { KGMainContentState } from '../core/state/KGMainContentState'; import { KGMainContentState } from '../core/state/KGMainContentState';
@@ -26,6 +26,7 @@ const MainContent: React.FC<MainContentProps> = ({
const { const {
tracks, tracks,
maxBars, maxBars,
barWidthMultiplier,
reorderTracks, reorderTracks,
updateTrack, updateTrack,
updateTrackProperties, updateTrackProperties,
@@ -41,9 +42,17 @@ const MainContent: React.FC<MainContentProps> = ({
activeRegionId, activeRegionId,
setShowPianoRoll, setShowPianoRoll,
setActiveRegionId, setActiveRegionId,
pianoRollMode,
openMidiPianoRoll,
openSpectrogramViewer,
openHybridMode,
hybridAudioRegionId,
addTrack, addTrack,
addAudioTrack, addAudioTrack,
projectName, projectName,
savedProjectName,
requestPianoRollScroll,
mainContentScrollRequest,
} = useProjectStore(); } = useProjectStore();
// State to store regions // State to store regions
@@ -88,6 +97,7 @@ const MainContent: React.FC<MainContentProps> = ({
const mainContentRef = useRef<HTMLDivElement | null>(null); const mainContentRef = useRef<HTMLDivElement | null>(null);
const expectedScrollLeftRef = useRef<number>(-1); const expectedScrollLeftRef = useRef<number>(-1);
const isPlayingRef = useRef(false); const isPlayingRef = useRef(false);
const previousBarWidthMultiplierRef = useRef(barWidthMultiplier);
// Refs for bar numbers and loop range drag functionality // Refs for bar numbers and loop range drag functionality
const barNumbersRef = useRef<HTMLDivElement | null>(null); const barNumbersRef = useRef<HTMLDivElement | null>(null);
@@ -101,6 +111,37 @@ const MainContent: React.FC<MainContentProps> = ({
isPlayingRef.current = isPlaying; isPlayingRef.current = isPlaying;
}, [isPlaying]); }, [isPlaying]);
useLayoutEffect(() => {
const previousMultiplier = previousBarWidthMultiplierRef.current;
if (previousMultiplier === barWidthMultiplier) return;
previousBarWidthMultiplierRef.current = barWidthMultiplier;
const container = mainContentRef.current;
if (!container) return;
const infoWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width')
) || 200;
const visibleMusicWidth = Math.max(0, container.clientWidth - infoWidth);
const previousBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * previousMultiplier;
const nextBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
if (visibleMusicWidth === 0 || previousBarWidth === 0 || nextBarWidth === 0) return;
const centerPixelBeforeZoom = container.scrollLeft + visibleMusicWidth / 2;
const anchorBeat = (centerPixelBeforeZoom / previousBarWidth) * timeSignature.numerator;
const targetPixel = (anchorBeat / timeSignature.numerator) * nextBarWidth;
const targetScrollLeft = targetPixel - visibleMusicWidth / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
expectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft;
}, [barWidthMultiplier, timeSignature]);
// Detect manual horizontal scroll during playback // Detect manual horizontal scroll during playback
useEffect(() => { useEffect(() => {
const container = mainContentRef.current; const container = mainContentRef.current;
@@ -130,8 +171,10 @@ const MainContent: React.FC<MainContentProps> = ({
) || 40; ) || 40;
const playheadPixel = barPosition * barWidth; const playheadPixel = barPosition * barWidth;
// Center the playhead in the visible grid area (excluding the 200px sticky info panel) // Center the playhead in the visible grid area (excluding the sticky info panel)
const infoWidth = 200; const infoWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width')
) || 200;
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2; const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
const clampedScrollLeft = Math.max( const clampedScrollLeft = Math.max(
0, 0,
@@ -142,6 +185,36 @@ const MainContent: React.FC<MainContentProps> = ({
container.scrollLeft = clampedScrollLeft; container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]); }, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]);
// Handle scroll requests from piano roll header clicks
useEffect(() => {
if (mainContentScrollRequest === null) return;
const container = mainContentRef.current;
if (!container) return;
const beatsPerBar = timeSignature.numerator;
const barPosition = mainContentScrollRequest / 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 sticky info panel)
const infoWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width')
) || 200;
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
container.scrollLeft = clampedScrollLeft;
// Clear the request after handling
useProjectStore.setState({ mainContentScrollRequest: null });
}, [mainContentScrollRequest, timeSignature]);
// Effect to verify track updates // Effect to verify track updates
useEffect(() => { useEffect(() => {
// Check for pending updates // Check for pending updates
@@ -433,8 +506,9 @@ const MainContent: React.FC<MainContentProps> = ({
} }
} }
// If piano roll is visible, set this region as the active region // Keep the active piano roll region in sync when that same region is updated.
if (showPianoRoll) { // Do not switch the editor to an unrelated region from generic move/resize updates.
if (showPianoRoll && activeRegionId === regionId) {
setActiveRegionId(regionId); setActiveRegionId(regionId);
if (DEBUG_MODE.MAIN_CONTENT) { if (DEBUG_MODE.MAIN_CONTENT) {
@@ -508,6 +582,16 @@ const MainContent: React.FC<MainContentProps> = ({
const track = tracks.find(t => t.getId().toString() === region.trackId); const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return; if (!track) return;
setSelectedTrack(track.getId().toString()); setSelectedTrack(track.getId().toString());
// If the piano roll window is already open, follow the selected region's type
if (showPianoRoll) {
const coreRegion = track.getRegions().find(r => r.getId() === regionId);
if (coreRegion?.getCurrentType() === 'KGAudioRegion') {
openSpectrogramViewer(regionId);
} else if (coreRegion?.getCurrentType() === 'KGMidiRegion') {
openMidiPianoRoll(regionId);
}
}
}; };
// Handle explicit pencil action: select region and open piano roll // Handle explicit pencil action: select region and open piano roll
@@ -528,11 +612,29 @@ const MainContent: React.FC<MainContentProps> = ({
// Reuse selection logic // Reuse selection logic
handleRegionClick(regionId); handleRegionClick(regionId);
// Activate and show piano roll // Activate and show piano roll in midi-edit mode
setActiveRegionId(regionId); openMidiPianoRoll(regionId);
setShowPianoRoll(true);
}; };
// Handle spectrogram viewer open
const handleOpenSpectrogram = (regionId: string) => {
handleRegionClick(regionId);
openSpectrogramViewer(regionId);
};
// Handle hybrid mode open (+ button clicked on opposite-type region)
const handleOpenHybrid = (regionId: string) => {
if (pianoRollMode === 'midi-edit' && activeRegionId) {
openHybridMode(activeRegionId, regionId);
} else if (pianoRollMode === 'spectrogram' && activeRegionId) {
openHybridMode(regionId, activeRegionId);
}
};
// + button is visible only when piano roll is open and mode is not hybrid
const showHybridButtonForAudio = showPianoRoll && pianoRollMode === 'midi-edit';
const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram';
// Handle piano roll close // Handle piano roll close
const handlePianoRollClose = () => { const handlePianoRollClose = () => {
setShowPianoRoll(false); setShowPianoRoll(false);
@@ -739,6 +841,7 @@ const MainContent: React.FC<MainContentProps> = ({
const clickPosition = calculatePlayheadFromMouse(e.clientX); const clickPosition = calculatePlayheadFromMouse(e.clientX);
if (clickPosition !== null) { if (clickPosition !== null) {
setPlayheadPosition(clickPosition); setPlayheadPosition(clickPosition);
requestPianoRollScroll(clickPosition);
if (DEBUG_MODE.MAIN_CONTENT) { if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`); console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`);
@@ -764,7 +867,7 @@ const MainContent: React.FC<MainContentProps> = ({
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
}, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition]); }, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition, requestPianoRollScroll]);
const { showInstrumentSelection, isLooping, loopingRange } = useProjectStore(); const { showInstrumentSelection, isLooping, loopingRange } = useProjectStore();
@@ -824,16 +927,49 @@ const MainContent: React.FC<MainContentProps> = ({
onRegionUpdated={handleRegionUpdated} onRegionUpdated={handleRegionUpdated}
onRegionClick={handleRegionClick} onRegionClick={handleRegionClick}
onOpenPianoRoll={handleOpenPianoRoll} onOpenPianoRoll={handleOpenPianoRoll}
onOpenSpectrogram={handleOpenSpectrogram}
showHybridButtonForAudio={showHybridButtonForAudio}
showHybridButtonForMidi={showHybridButtonForMidi}
onOpenHybrid={handleOpenHybrid}
onExternalDropComplete={handleExternalDropComplete} onExternalDropComplete={handleExternalDropComplete}
/> />
</div> </div>
</div> </div>
{/* Piano Roll - render using portal */} {/* Piano Roll / Spectrogram Viewer - render using portal */}
{showPianoRoll && createPortal( {showPianoRoll && createPortal(
<PianoRoll <PianoRoll
onClose={handlePianoRollClose} onClose={handlePianoRollClose}
regionId={activeRegionId} regionId={activeRegionId}
mode={pianoRollMode}
audioRegion={(() => {
// spectrogram mode: audio region IS the activeRegionId
// hybrid mode: audio region is hybridAudioRegionId
const audioId = pianoRollMode === 'spectrogram' ? activeRegionId
: pianoRollMode === 'hybrid' ? hybridAudioRegionId
: null;
if (!audioId) return undefined;
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === audioId);
if (region && region.getCurrentType() === 'KGAudioRegion') {
return region as unknown as KGAudioRegion;
}
}
return undefined;
})()}
trackId={(() => {
const audioId = pianoRollMode === 'spectrogram' ? activeRegionId
: pianoRollMode === 'hybrid' ? hybridAudioRegionId
: null;
if (!audioId) return undefined;
for (const track of tracks) {
if (track.getRegions().some(r => r.getId() === audioId)) {
return track.getId().toString();
}
}
return undefined;
})()}
projectName={savedProjectName}
/>, />,
document.body document.body
)} )}
+35 -2
View File
@@ -6,6 +6,8 @@ import { isModifierKeyPressed } from '../../util/osUtil';
import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil'; import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import SpectrogramCanvas from './SpectrogramCanvas';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
interface PianoGridProps { interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>; gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -24,6 +26,15 @@ interface PianoGridProps {
selectedMode: string; selectedMode: string;
keySignature: KeySignature; keySignature: KeySignature;
chordGuide: string; chordGuide: string;
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
bpm?: number;
spectrogramThresholdDb?: number;
spectrogramPower?: number;
pianoRollZoom?: number;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
onSpectrogramLoadingChange?: (loading: boolean) => void;
} }
interface CursorPosition { interface CursorPosition {
@@ -44,7 +55,15 @@ const PianoGrid: React.FC<PianoGridProps> = ({
regionStartBeat = 0, regionStartBeat = 0,
selectedMode, selectedMode,
keySignature, keySignature,
chordGuide chordGuide,
audioRegion,
trackId,
projectName,
bpm = 120,
spectrogramThresholdDb = -25,
spectrogramPower = 0.5,
pianoRollZoom = 1,
onSpectrogramLoadingChange,
}) => { }) => {
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null); const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
const [isModifierPressed, setIsModifierPressed] = useState(false); const [isModifierPressed, setIsModifierPressed] = useState(false);
@@ -209,13 +228,27 @@ const PianoGrid: React.FC<PianoGridProps> = ({
<div <div
className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`} className={`piano-grid ${isModifierPressed ? 'pencil-cursor' : ''}`}
ref={gridRef} ref={gridRef}
style={{ backgroundImage }} style={{ backgroundImage: audioRegion ? undefined : backgroundImage }}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
onClick={onClick} onClick={onClick}
onMouseDown={(e) => onMouseDown(e)} onMouseDown={(e) => onMouseDown(e)}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
{/* Spectrogram layer — rendered at z-index 0, behind all highlights and notes */}
{audioRegion && trackId && projectName && (
<SpectrogramCanvas
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
bpm={bpm}
thresholdDb={spectrogramThresholdDb}
power={spectrogramPower}
zoom={pianoRollZoom}
onLoadingChange={onSpectrogramLoadingChange}
/>
)}
{/* Cursor Highlights */} {/* Cursor Highlights */}
{cursorPosition && ( {cursorPosition && (
<> <>
@@ -14,7 +14,7 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided
}) => { }) => {
// Get store access for playhead position updates // Get store access for playhead position updates
const { setPlayheadPosition } = useProjectStore(); const { setPlayheadPosition, requestMainContentScroll } = useProjectStore();
// Refs for drag functionality // Refs for drag functionality
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
@@ -124,6 +124,7 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
} }
setPlayheadPosition(newPosition); setPlayheadPosition(newPosition);
requestMainContentScroll(newPosition);
} }
}; };
+94 -3
View File
@@ -77,10 +77,17 @@
/* Ensure toolbar and its dropdowns appear above piano roll content */ /* Ensure toolbar and its dropdowns appear above piano roll content */
} }
.piano-roll-toolbar .tool-button {
width: 20px;
height: 20px;
}
/* Override pointer-events for piano roll toolbar sections */ /* Override pointer-events for piano roll toolbar sections */
.piano-roll-toolbar .toolbar-left, .piano-roll-toolbar .toolbar-left,
.piano-roll-toolbar .toolbar-right { .piano-roll-toolbar .toolbar-right {
pointer-events: auto; pointer-events: auto;
/* max-width: 70%; */
width: auto;
} }
.piano-roll-toolbar .quant-button { .piano-roll-toolbar .quant-button {
@@ -96,12 +103,44 @@
left: 0; left: 0;
} }
/* Spectrogram toolbar controls */
.spectrogram-toolbar-controls {
display: flex;
align-items: center;
gap: 6px;
margin-left: 5px;
}
.spectrogram-control-label {
font-size: 10px;
color: #aaa;
white-space: nowrap;
}
.spectrogram-threshold-slider {
/* Match the visual width of the Qua. Pos. / Qua. Len. buttons (~80px) */
width: 80px;
height: 4px;
accent-color: #7a9ccf;
cursor: pointer;
margin: 0;
}
.spectrogram-threshold-value {
font-size: 10px;
color: #e0e0e0;
min-width: 30px;
text-align: right;
white-space: nowrap;
}
.piano-roll-title { .piano-roll-title {
flex: 1; flex: 1;
text-align: center; text-align: center;
font-size: 14px; font-size: 12px;
font-weight: bold;
color: #e0e0e0; color: #e0e0e0;
text-transform: uppercase; /* text-transform: uppercase; */
cursor: pointer; cursor: pointer;
padding: 5px; padding: 5px;
border-radius: 3px; border-radius: 3px;
@@ -120,13 +159,37 @@
color: #ff4444; color: #ff4444;
} }
.piano-roll-content-outer {
position: relative;
flex: 1;
overflow: hidden;
}
.piano-roll-content { .piano-roll-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; height: 100%;
overflow: auto; overflow: auto;
} }
.spectrogram-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 100;
}
.spectrogram-loading-label {
padding: 6px 10px;
background: rgba(0, 0, 0, 0.6);
color: #aaa;
font-size: 11px;
border-radius: 3px;
}
.piano-grid-header { .piano-grid-header {
display: grid; display: grid;
grid-template-columns: repeat(var(--max-number-of-bars), var(--region-grid-bar-width)); grid-template-columns: repeat(var(--max-number-of-bars), var(--region-grid-bar-width));
@@ -276,3 +339,31 @@
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
} }
.piano-roll-zoom-popup {
position: absolute;
top: 100%;
right: 0;
background-color: #2d2d2d;
border: 1px solid #444;
border-radius: 3px;
padding: 8px 12px;
z-index: 1500;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
white-space: nowrap;
margin-top: 2px;
}
.piano-roll-zoom-popup input[type="range"] {
width: 120px;
}
.piano-roll-zoom-value {
font-size: 12px;
color: #e0e0e0;
min-width: 20px;
text-align: center;
}
+139 -15
View File
@@ -1,10 +1,11 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback, useLayoutEffect } from 'react';
import './PianoRoll.css'; import './PianoRoll.css';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { FaGripLines } from 'react-icons/fa'; import { FaGripLines } from 'react-icons/fa';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS } from '../../constants'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants';
import PianoRollHeader from './PianoRollHeader'; import PianoRollHeader from './PianoRollHeader';
import PianoRollToolbar from './PianoRollToolbar'; import PianoRollToolbar from './PianoRollToolbar';
import PianoRollContent from './PianoRollContent'; import PianoRollContent from './PianoRollContent';
@@ -22,19 +23,36 @@ interface PianoRollProps {
regionId: string | null; regionId: string | null;
initialPosition?: { x: number; y: number }; initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number }; initialSize?: { width: number; height: number };
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
} }
const PianoRoll: React.FC<PianoRollProps> = ({ const PianoRoll: React.FC<PianoRollProps> = ({
onClose, onClose,
regionId, regionId,
initialPosition, initialPosition,
initialSize initialSize,
mode = 'midi-edit',
audioRegion,
trackId,
projectName,
}) => { }) => {
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled } = useProjectStore(); const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest } = useProjectStore();
// Tool state for piano roll // Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
// Spectrogram controls (only used in spectrogram mode)
const [spectrogramThresholdDb, setSpectrogramThresholdDb] = useState<number>(-25);
const [spectrogramPower, setSpectrogramPower] = useState<number>(0.5);
// Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
// Quantization state // Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8'); const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8'); const [quantLength, setQuantLength] = useState<string>('1/8');
@@ -63,6 +81,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Refs for auto-scroll during playback // Refs for auto-scroll during playback
const pianoRollExpectedScrollLeftRef = useRef<number>(-1); const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false); const pianoRollIsPlayingRef = useRef(false);
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
// Ref for storing the setNoteUpdateCounter function // Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null); const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
@@ -140,22 +159,22 @@ const PianoRoll: React.FC<PianoRollProps> = ({
return; return;
} }
// Find the region in the tracks let found: KGMidiRegion | null = null;
for (const track of tracks) { for (const track of tracks) {
const regions = track.getRegions(); const region = track.getRegions().find(r => r.getId() === regionId);
const region = regions.find(r => r.getId() === regionId);
if (region && region instanceof KGMidiRegion) { if (region && region instanceof KGMidiRegion) {
setActiveRegion(region); found = region;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Active region set in PianoRoll: ${region.getId()}`);
console.log(`Region details: name=${region.getName()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
}
break; break;
} }
} }
// Always update — clears stale MIDI region when switching to an audio region
setActiveRegion(found);
if (found && DEBUG_MODE.PIANO_ROLL) {
console.log(`Active region set in PianoRoll: ${found.getId()}`);
console.log(`Region details: name=${found.getName()}, trackId=${found.getTrackId()}, trackIndex=${found.getTrackIndex()}`);
}
}, [regionId, tracks]); }, [regionId, tracks]);
// Sync local state with KGPianoRollState on mount // Sync local state with KGPianoRollState on mount
@@ -595,6 +614,30 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} }
}, [quantizeSelectedNotes, quantizeNoteLength]); }, [quantizeSelectedNotes, quantizeNoteLength]);
const handleZoomChange = useCallback((nextZoom: number) => {
if (nextZoom === pianoRollZoom) return;
const container = pianoRollContentRef.current;
pendingZoomAnchorBeatRef.current = null;
if (container) {
const keysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH;
if (visibleMusicWidth > 0 && beatWidth > 0) {
pendingZoomAnchorBeatRef.current = (container.scrollLeft + visibleMusicWidth / 2) / beatWidth;
} else {
pendingZoomAnchorBeatRef.current = null;
}
}
setPianoRollZoom(nextZoom);
}, [pianoRollZoom]);
// Calculate C4 position and scroll to it when piano roll opens // Calculate C4 position and scroll to it when piano roll opens
useEffect(() => { useEffect(() => {
if (pianoRollContentRef.current) { if (pianoRollContentRef.current) {
@@ -671,6 +714,66 @@ const PianoRoll: React.FC<PianoRollProps> = ({
container.scrollLeft = clampedScrollLeft; container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled]); }, [playheadPosition, isPlaying, autoScrollEnabled]);
// Handle scroll requests from main content bar numbers clicks
useEffect(() => {
if (pianoRollScrollRequest === null) return;
const container = pianoRollContentRef.current;
if (!container) return;
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
const playheadPixel = pianoRollScrollRequest * 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)
);
container.scrollLeft = clampedScrollLeft;
// Clear the request after handling
useProjectStore.setState({ pianoRollScrollRequest: null });
}, [pianoRollScrollRequest]);
// Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
useLayoutEffect(() => {
const beatWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * pianoRollZoom;
document.documentElement.style.setProperty('--region-grid-beat-width', `${beatWidth}px`);
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
}
const anchorBeat = pendingZoomAnchorBeatRef.current;
const container = pianoRollContentRef.current;
if (anchorBeat !== null && container) {
const keysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
const targetPixel = anchorBeat * beatWidth;
const targetScrollLeft = targetPixel - visibleMusicWidth / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft;
pendingZoomAnchorBeatRef.current = null;
}
return () => {
document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
};
}, [pianoRollZoom]);
// Scroll horizontally to the active region's starting bar // Scroll horizontally to the active region's starting bar
useEffect(() => { useEffect(() => {
if (pianoRollContentRef.current && activeRegion) { if (pianoRollContentRef.current && activeRegion) {
@@ -855,6 +958,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Get the title for the piano roll based on the active region // Get the title for the piano roll based on the active region
const getPianoRollTitle = () => { const getPianoRollTitle = () => {
if (isSpectrogram) return audioRegion ? `SPECTROGRAM — ${audioRegion.getName()}` : 'SPECTROGRAM';
if (isHybrid) {
const midiName = activeRegion?.getName() ?? 'MIDI';
const audioName = audioRegion?.getName() ?? 'Audio';
return `${midiName} + ${audioName}`;
}
if (!activeRegion) return "EDIT NOTE CLIP"; if (!activeRegion) return "EDIT NOTE CLIP";
// Calculate the bar and beat position of the region // Calculate the bar and beat position of the region
@@ -901,6 +1010,13 @@ const PianoRoll: React.FC<PianoRollProps> = ({
chordGuide={chordGuide} chordGuide={chordGuide}
onChordGuideChange={handleChordGuideSelect} onChordGuideChange={handleChordGuideSelect}
blinkButton={blinkButton} blinkButton={blinkButton}
mode={mode}
thresholdDb={spectrogramThresholdDb}
onThresholdChange={setSpectrogramThresholdDb}
power={spectrogramPower}
onPowerChange={setSpectrogramPower}
zoom={pianoRollZoom}
onZoomChange={handleZoomChange}
/> />
<PianoRollContent <PianoRollContent
@@ -916,6 +1032,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
selectedMode={selectedMode} selectedMode={selectedMode}
keySignature={keySignature} keySignature={keySignature}
chordGuide={chordGuide} chordGuide={chordGuide}
mode={mode}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower}
pianoRollZoom={pianoRollZoom}
/> />
<div <div
+67 -28
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useRef, useEffect } from 'react'; import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { DEBUG_MODE } from '../../constants'; import { DEBUG_MODE } from '../../constants';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGMidiNote } from '../../core/midi/KGMidiNote';
@@ -12,6 +12,7 @@ import PianoGrid from './PianoGrid';
import { useNoteOperations } from '../../hooks/useNoteOperations'; import { useNoteOperations } from '../../hooks/useNoteOperations';
import { useNoteSelection } from '../../hooks/useNoteSelection'; import { useNoteSelection } from '../../hooks/useNoteSelection';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
interface PianoRollContentProps { interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>; contentRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -26,6 +27,14 @@ interface PianoRollContentProps {
selectedMode: string; selectedMode: string;
keySignature: KeySignature; keySignature: KeySignature;
chordGuide: string; chordGuide: string;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
bpm?: number;
spectrogramThresholdDb?: number;
spectrogramPower?: number;
pianoRollZoom?: number;
} }
const PianoRollContent: React.FC<PianoRollContentProps> = ({ const PianoRollContent: React.FC<PianoRollContentProps> = ({
@@ -40,8 +49,22 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
onSetDeleteNotesTrigger, onSetDeleteNotesTrigger,
selectedMode, selectedMode,
keySignature, keySignature,
chordGuide chordGuide,
mode = 'midi-edit',
audioRegion,
trackId,
projectName,
bpm = 120,
spectrogramThresholdDb = -25,
spectrogramPower = 0.5,
pianoRollZoom = 1,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram';
const [spectrogramLoading, setSpectrogramLoading] = useState(false);
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
setSpectrogramLoading(loading);
}, []);
// Get KGCore instance // Get KGCore instance
const core = KGCore.instance(); const core = KGCore.instance();
@@ -105,9 +128,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
// Combined click handler for both pointer and pencil modes // Combined click handler for both pointer and pencil modes
const handleCombinedClick = (e: React.MouseEvent) => { const handleCombinedClick = (e: React.MouseEvent) => {
// Handle selection click (pointer mode) if (isSpectrogram) return;
handleBackgroundClick(e); handleBackgroundClick(e);
// Handle pencil mode note creation
handleGridClick(e); handleGridClick(e);
}; };
@@ -144,7 +166,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
// Memoize the notes rendering to prevent unnecessary recalculations // Memoize the notes rendering to prevent unnecessary recalculations
const memoizedNotes = useMemo(() => { const memoizedNotes = useMemo(() => {
if (!activeRegion) return null; if (isSpectrogram || !activeRegion) return null;
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Rendering notes for region: ${activeRegion.getId()}`); console.log(`Rendering notes for region: ${activeRegion.getId()}`);
@@ -219,7 +241,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
/> />
); );
}); });
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]); }, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
const recordingNoteOverlays = useMemo(() => { const recordingNoteOverlays = useMemo(() => {
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null; if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
@@ -240,31 +262,48 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
}, [isRecording, recordingNotes, activeRegion]); }, [isRecording, recordingNotes, activeRegion]);
return ( return (
<div <div className="piano-roll-content-outer">
className="piano-roll-content" <div
ref={contentRef} className="piano-roll-content"
> ref={contentRef}
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} /> >
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
<div className="piano-roll-body"> <div className="piano-roll-body">
<PianoKeys activeRegion={activeRegion} /> <PianoKeys activeRegion={activeRegion} />
<PianoGrid <PianoGrid
gridRef={pianoGridRef} gridRef={pianoGridRef}
onDoubleClick={handleGridDoubleClick} onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
onClick={handleCombinedClick} onClick={isSpectrogram ? () => {} : handleCombinedClick}
onMouseDown={handleBackgroundMouseDown} onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown}
isBoxSelecting={isBoxSelectingRef.current} isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current}
selectionBox={selectionBoxRef.current} selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
regionStartBeat={activeRegion?.getStartFromBeat() || 0} regionStartBeat={activeRegion?.getStartFromBeat() || 0}
selectedMode={selectedMode} selectedMode={selectedMode}
keySignature={keySignature} keySignature={keySignature}
chordGuide={chordGuide} chordGuide={chordGuide}
> audioRegion={audioRegion}
{memoizedNotes} trackId={trackId}
{recordingNoteOverlays} projectName={projectName}
</PianoGrid> bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower}
pianoRollZoom={pianoRollZoom}
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
>
{memoizedNotes}
{!isSpectrogram && recordingNoteOverlays}
</PianoGrid>
</div>
</div> </div>
{spectrogramLoading && (
<div className="spectrogram-loading-overlay">
<div className="spectrogram-loading-label">
Computing spectrogram
</div>
</div>
)}
</div> </div>
); );
}; };
@@ -19,12 +19,6 @@ const PianoRollHeader: React.FC<PianoRollHeaderProps> = ({
className="piano-roll-header" className="piano-roll-header"
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
> >
<button
className="close-button"
onClick={onClose}
>
<FaTimes />
</button>
<div <div
className="piano-roll-title" className="piano-roll-title"
onClick={onTitleClick} onClick={onTitleClick}
@@ -32,6 +26,12 @@ const PianoRollHeader: React.FC<PianoRollHeaderProps> = ({
> >
{title} {title}
</div> </div>
<button
className="close-button"
onClick={onClose}
>
<FaTimes />
</button>
</div> </div>
); );
}; };
+151 -66
View File
@@ -4,6 +4,13 @@ import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
const POWER_OPTIONS = [
{ label: 'Linear', value: '1.0' },
{ label: '√ (default)', value: '0.5' },
{ label: 'Mild', value: '0.4' },
{ label: 'Strong', value: '0.3' },
];
interface PianoRollToolbarProps { interface PianoRollToolbarProps {
activeTool: 'pointer' | 'pencil'; activeTool: 'pointer' | 'pencil';
onToolSelect: (tool: 'pointer' | 'pencil') => void; onToolSelect: (tool: 'pointer' | 'pencil') => void;
@@ -17,6 +24,13 @@ interface PianoRollToolbarProps {
chordGuide: string; chordGuide: string;
onChordGuideChange: (value: string) => void; onChordGuideChange: (value: string) => void;
blinkButton?: string | null; blinkButton?: string | null;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
thresholdDb?: number;
onThresholdChange?: (db: number) => void;
power?: number;
onPowerChange?: (power: number) => void;
zoom: number;
onZoomChange: (value: number) => void;
} }
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
@@ -31,79 +45,150 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onModeChange, onModeChange,
chordGuide, chordGuide,
onChordGuideChange, onChordGuideChange,
blinkButton = null blinkButton = null,
mode = 'midi-edit',
thresholdDb = -25,
onThresholdChange,
power = 0.5,
onPowerChange,
zoom,
onZoomChange,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram';
const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid
const showSpecControls = mode === 'spectrogram' || mode === 'hybrid';
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showZoomSlider) return;
const handleClickOutside = (e: MouseEvent) => {
if (zoomSliderRef.current && !zoomSliderRef.current.contains(e.target as Node)) {
setShowZoomSlider(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showZoomSlider]);
return ( return (
<div className="piano-roll-toolbar"> <div className="piano-roll-toolbar">
<div className="toolbar-left"> {showMidiControls && (
{/* Left section with mode and chord guide dropdowns */} <div className="toolbar-left">
<KGDropdown <button
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))} className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
value={selectedMode} onClick={() => onToolSelect('pointer')}
onChange={(value) => onModeChange(value)} title="Pointer Tool"
label="Mode" >
buttonClassName="mode-dropdown" <FaMousePointer />
showValueAsLabel={true} </button>
/> <button
<KGDropdown className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
options={[ onClick={() => onToolSelect('pencil')}
{ label: 'Guide: Disabled', value: 'N' }, title="Pencil Tool"
{ label: 'Chord Guide: T', value: 'T' }, >
{ label: 'Chord Guide: S', value: 'S' }, <FaPencilAlt />
{ label: 'Chord Guide: D', value: 'D' } </button>
]} <KGDropdown
value={chordGuide} options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))}
onChange={(value) => onChordGuideChange(value)} value={selectedMode}
label="Chord" onChange={(value) => onModeChange(value)}
buttonClassName="chord-guide-dropdown" label="Mode"
showValueAsLabel={true} buttonClassName="mode-dropdown"
/> showValueAsLabel={true}
</div> />
<KGDropdown
<div className="toolbar-center"> options={[
{/* Center section with pointer and pencil tools */} { label: 'Guide: Disabled', value: 'N' },
<button { label: 'Chord Guide: T', value: 'T' },
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`} { label: 'Chord Guide: S', value: 'S' },
onClick={() => onToolSelect('pointer')} { label: 'Chord Guide: D', value: 'D' }
title="Pointer Tool" ]}
> value={chordGuide}
<FaMousePointer /> onChange={(value) => onChordGuideChange(value)}
</button> label="Chord"
<button buttonClassName="chord-guide-dropdown"
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`} showValueAsLabel={true}
onClick={() => onToolSelect('pencil')} />
title="Pencil Tool" </div>
> )}
<FaPencilAlt />
</button>
</div>
<div className="toolbar-right"> <div className="toolbar-right">
{/* Right section with quantization options */} {showMidiControls && (
<KGDropdown <>
options={KGPianoRollState.SNAP_OPTIONS} <KGDropdown
value={snapping} options={KGPianoRollState.SNAP_OPTIONS}
onChange={(value) => onSnappingSelect(value)} value={snapping}
label="Snap" onChange={(value) => onSnappingSelect(value)}
buttonClassName="snapping" label="Snap"
showValueAsLabel={true} buttonClassName="snapping"
/> showValueAsLabel={true}
/>
<KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS}
value={quantPosition}
onChange={(value) => onQuantSelect('position', value)}
label="Qua. Pos."
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
/>
<KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS}
value={quantLength}
onChange={(value) => onQuantSelect('length', value)}
label="Qua. Len."
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
/>
</>
)}
<KGDropdown {showSpecControls && (
options={KGPianoRollState.QUANT_POS_OPTIONS} <div className="spectrogram-toolbar-controls">
value={quantPosition} <span className="spectrogram-control-label">Floor</span>
onChange={(value) => onQuantSelect('position', value)} <input
label="Qua. Pos." type="range"
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`} className="spectrogram-threshold-slider"
/> min={-50}
max={-5}
step={1}
value={thresholdDb}
onChange={e => onThresholdChange?.(parseInt(e.target.value))}
title={`Noise floor: ${thresholdDb} dB`}
/>
<span className="spectrogram-threshold-value">{thresholdDb} dB</span>
<KGDropdown
options={POWER_OPTIONS}
value={power.toString()}
onChange={v => onPowerChange?.(parseFloat(v))}
label="Curve"
buttonClassName="curve-dropdown"
showValueAsLabel={true}
/>
</div>
)}
<KGDropdown <div className="quant-dropdown-container" ref={zoomSliderRef}>
options={KGPianoRollState.QUANT_LEN_OPTIONS} <button
value={quantLength} className="quant-button"
onChange={(value) => onQuantSelect('length', value)} onClick={() => setShowZoomSlider(!showZoomSlider)}
label="Qua. Len." title="Zoom"
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`} >
/> {zoom}x
</button>
{showZoomSlider && (
<div className="piano-roll-zoom-popup">
<input
type="range"
min="1"
max="8"
step="1"
value={zoom}
onChange={(e) => onZoomChange(parseInt(e.target.value))}
/>
<span className="piano-roll-zoom-value">{zoom}x</span>
</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,260 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import * as Tone from 'tone';
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import type { SpectrogramRequest, SpectrogramResult } from '../../workers/spectrogramWorker';
interface SpectrogramCanvasProps {
audioRegion: KGAudioRegion;
trackId: string;
projectName: string;
bpm: number;
thresholdDb: number;
power: number;
zoom: number;
onLoadingChange?: (loading: boolean) => void;
}
const PITCH_BINS = 128;
const HOP_SIZE = 1024;
// Piecewise-linear RGB colormap: black → dark blue → blue → purple → red → orange → yellow
// Hue rotates 240°→300°→0°→60°, bypassing green entirely.
const COLORMAP_STOPS: Array<[number, [number, number, number]]> = [
[0.00, [ 0, 0, 0]],
[0.15, [ 0, 0, 180]],
[0.35, [ 0, 60, 255]],
[0.55, [180, 0, 120]],
[0.70, [255, 0, 0]],
[0.85, [255, 140, 0]],
[1.00, [255, 255, 0]],
];
function hotColormap(v: number): [number, number, number] {
v = Math.max(0, Math.min(1, v));
for (let i = 0; i < COLORMAP_STOPS.length - 1; i++) {
const [t0, c0] = COLORMAP_STOPS[i];
const [t1, c1] = COLORMAP_STOPS[i + 1];
if (v <= t1) {
const t = (v - t0) / (t1 - t0);
return [
Math.round(c0[0] + t * (c1[0] - c0[0])),
Math.round(c0[1] + t * (c1[1] - c0[1])),
Math.round(c0[2] + t * (c1[2] - c0[2])),
];
}
}
return COLORMAP_STOPS[COLORMAP_STOPS.length - 1][1];
}
const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
audioRegion,
trackId,
projectName,
bpm,
thresholdDb,
power,
zoom,
onLoadingChange,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [loading, setLoading] = useState(true);
const workerRef = useRef<Worker | null>(null);
// Cache the raw linear result so threshold/power changes re-render without re-running FFT
const rawResultRef = useRef<SpectrogramResult | null>(null);
const sampleRateRef = useRef<number>(44100);
const regionDurationRef = useRef<number>(0);
// Natural (1x) canvas pixel width — set after each draw, used to apply zoom as CSS stretch
const naturalWidthRef = useRef<number>(0);
// Always-current zoom without making it a renderSpectrogram dependency
const zoomRef = useRef(zoom);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
useEffect(() => { onLoadingChange?.(loading); }, [loading, onLoadingChange]);
const renderSpectrogram = useCallback((
result: SpectrogramResult,
sampleRate: number,
regionDurationSeconds: number,
thresholdDb: number,
power: number,
) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const noteHeight =
parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const totalBeats = (regionDurationSeconds * bpm) / 60;
// Always draw at 1x resolution; zoom is applied as CSS width stretch
const canvasWidth = Math.ceil(totalBeats * 40);
const canvasHeight = PITCH_BINS * noteHeight;
// Convert dB threshold to linear: values below this → black
const linearThreshold = Math.pow(10, thresholdDb / 20);
// 1. Paint at natural STFT resolution onto an offscreen canvas (timeSteps × 128).
// Row i = pitchIndex i = pitch (107 i). Apply threshold then power curve.
const offscreen = document.createElement('canvas');
offscreen.width = result.timeSteps;
offscreen.height = PITCH_BINS;
const offCtx = offscreen.getContext('2d');
if (!offCtx) return;
const imgData = offCtx.createImageData(result.timeSteps, PITCH_BINS);
const pixels = imgData.data;
for (let row = 0; row < PITCH_BINS; row++) {
const pitch = 107 - row;
for (let col = 0; col < result.timeSteps; col++) {
const idx = (row * result.timeSteps + col) * 4;
pixels[idx + 3] = 255; // always opaque
if (pitch < 12 || pitch > 107) continue; // outside range → black
const raw = result.data[col * PITCH_BINS + pitch];
// Hard threshold: values below noise floor → 0 (black)
// Re-scale surviving range to [0,1] then apply power curve
const gated = raw < linearThreshold
? 0
: Math.pow((raw - linearThreshold) / (1 - linearThreshold), power);
if (gated <= 0) continue; // stays black
const [r, g, b] = hotColormap(gated);
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
}
}
offCtx.putImageData(imgData, 0, 0);
// 2. Stretch onto the full canvas — browser bilinear filter smooths between bins.
canvas.width = canvasWidth;
canvas.height = canvasHeight;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(offscreen, 0, 0, canvasWidth, canvasHeight);
// Store natural width and apply current zoom as CSS stretch (no pixel recompute on zoom)
naturalWidthRef.current = canvasWidth;
canvas.style.width = `${canvasWidth * zoomRef.current}px`;
canvas.style.height = `${canvasHeight}px`;
}, [bpm]);
// Re-render without re-running the worker when threshold or power changes
useEffect(() => {
if (rawResultRef.current) {
renderSpectrogram(
rawResultRef.current,
sampleRateRef.current,
regionDurationRef.current,
thresholdDb,
power,
);
}
}, [thresholdDb, power, renderSpectrogram]);
// Zoom changes: stretch width only, pin height to canvas pixel height
useEffect(() => {
if (canvasRef.current && naturalWidthRef.current > 0) {
canvasRef.current.style.width = `${naturalWidthRef.current * zoom}px`;
canvasRef.current.style.height = `${canvasRef.current.height}px`;
}
}, [zoom]);
// Load audio + run worker when the audio region itself changes
useEffect(() => {
let cancelled = false;
const compute = async () => {
setLoading(true);
workerRef.current?.terminate();
workerRef.current = null;
try {
let audioBuffer: AudioBuffer | undefined = KGAudioInterface.instance().getAudioBuffer(
trackId,
audioRegion.getAudioFileId()
);
if (!audioBuffer) {
const arrayBuffer = await KGAudioFileStorage.loadAudioFile(
projectName,
audioRegion.getAudioFileId()
);
if (cancelled) return;
const actx = Tone.getContext().rawContext as AudioContext;
audioBuffer = await actx.decodeAudioData(arrayBuffer);
}
if (cancelled) return;
const pcm = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const regionDurationSeconds = (audioRegion.getLength() * 60) / bpm;
const worker = new Worker(
new URL('../../workers/spectrogramWorker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current = worker;
worker.onmessage = (e: MessageEvent<SpectrogramResult>) => {
if (cancelled) { worker.terminate(); return; }
rawResultRef.current = e.data;
sampleRateRef.current = sampleRate;
regionDurationRef.current = regionDurationSeconds;
renderSpectrogram(e.data, sampleRate, regionDurationSeconds, thresholdDb, power);
setLoading(false);
worker.terminate();
workerRef.current = null;
};
const request: SpectrogramRequest = {
pcm: pcm.slice(0) as Float32Array,
sampleRate,
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
regionDurationSeconds,
bpm,
};
worker.postMessage(request, [request.pcm.buffer]);
} catch (err) {
if (!cancelled) {
console.error('SpectrogramCanvas: failed to compute spectrogram', err);
setLoading(false);
}
}
};
compute();
return () => {
cancelled = true;
workerRef.current?.terminate();
workerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRegion, trackId, projectName, bpm]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
zIndex: 0,
pointerEvents: 'none',
}}
/>
);
};
export default SpectrogramCanvas;
+63 -4
View File
@@ -97,11 +97,23 @@
background-color: #5a9b6a; background-color: #5a9b6a;
} }
/* Region pencil trigger inside content */ /* Left-side button cluster inside region-content */
.region-pencil-btn { .region-left-buttons {
position: absolute; position: absolute;
top: 4px; top: 4px;
left: 4px; left: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
z-index: 2;
}
/* Shared style for all region content buttons */
.region-pencil-btn,
.region-waveform-btn,
.region-spectrogram-btn,
.region-hybrid-btn {
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
color: #fff; color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
@@ -109,16 +121,63 @@
padding: 2px; padding: 2px;
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;
z-index: 2;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.region-pencil-btn:hover { .region-pencil-btn:hover,
.region-spectrogram-btn:hover,
.region-hybrid-btn:hover {
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
} }
.region-waveform-btn {
display: none;
}
.region-waveform-btn:hover {
background: rgba(0, 0, 0, 0.35);
}
/* Fine-move widget: sits inline in the left button cluster */
.region-fine-move-widget {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.region-fine-move-btn {
background: rgba(0, 0, 0, 0.25);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
padding: 2px;
margin: 0;
cursor: ew-resize;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.region-fine-move-btn:hover {
background: rgba(0, 0, 0, 0.35);
}
.region-fine-move-label {
background: rgba(0, 0, 0, 0.25);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
padding: 2px 4px;
font-size: 10px;
white-space: nowrap;
line-height: 1;
user-select: none;
}
/* Instrument dropdown specific styles */ /* Instrument dropdown specific styles */
.instrument-dropdown .quant-dropdown { .instrument-dropdown .quant-dropdown {
min-width: 80px; min-width: 80px;
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import RegionItem from './RegionItem';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMainContentState } from '../../core/state/KGMainContentState';
vi.mock('../../stores/projectStore', () => ({
useProjectStore: () => ({
selectedRegionIds: [],
timeSignature: { numerator: 4, denominator: 4 },
bpm: 120,
}),
}));
describe('RegionItem', () => {
beforeAll(() => {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: vi.fn(() => ({
clearRect: vi.fn(),
fillRect: vi.fn(),
})),
});
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
beforeEach(() => {
KGMainContentState.instance().setActiveTool('pointer');
});
const renderRegion = (props: Partial<React.ComponentProps<typeof RegionItem>> = {}) => {
const midiRegion = new KGMidiRegion('midi-1', 'track-1', 0, 'Test Region', 0, 4);
return render(
<RegionItem
id="midi-1"
name="Test Region"
style={{ left: '0px', width: '120px', position: 'absolute' }}
onClick={vi.fn()}
onDragStart={vi.fn()}
onDrag={vi.fn()}
onDragEnd={vi.fn()}
midiRegion={midiRegion}
{...props}
/>
);
};
it('treats small pointer jitter as a click', () => {
const onClick = vi.fn();
const onDragStart = vi.fn();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const { container } = renderRegion({ onClick, onDragStart, onDrag, onDragEnd });
const region = container.querySelector('.track-region');
expect(region).toBeTruthy();
fireEvent.mouseDown(region!, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(document, { clientX: 102, clientY: 102 });
fireEvent.mouseUp(document, { clientX: 102, clientY: 102 });
expect(onClick).toHaveBeenCalledWith('midi-1');
expect(onDragStart).not.toHaveBeenCalled();
expect(onDrag).not.toHaveBeenCalled();
expect(onDragEnd).not.toHaveBeenCalled();
});
it('starts a drag after crossing the movement threshold', () => {
const onClick = vi.fn();
const onDragStart = vi.fn();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const { container } = renderRegion({ onClick, onDragStart, onDrag, onDragEnd });
const region = container.querySelector('.track-region');
expect(region).toBeTruthy();
fireEvent.mouseDown(region!, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(document, { clientX: 110, clientY: 100 });
fireEvent.mouseUp(document, { clientX: 110, clientY: 100 });
expect(onClick).not.toHaveBeenCalled();
expect(onDragStart).toHaveBeenCalledWith('midi-1', 100, 100);
expect(onDrag).toHaveBeenCalledWith('midi-1', 10, 0);
expect(onDragEnd).toHaveBeenCalledWith('midi-1');
});
});
+200 -58
View File
@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import './Region.css'; import './Region.css';
import { FaPencilAlt } from 'react-icons/fa'; import { FaPencilAlt, FaPlus } from 'react-icons/fa';
import { MdGraphicEq, MdSwapHoriz } from 'react-icons/md';
import type { ResizeAction } from '../interfaces'; import type { ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
@@ -8,6 +9,8 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState'; import { KGMainContentState } from '../../core/state/KGMainContentState';
const DRAG_START_THRESHOLD_PX = 4;
interface RegionItemProps { interface RegionItemProps {
id: string; id: string;
name: string; name: string;
@@ -27,6 +30,13 @@ interface RegionItemProps {
onClick?: (regionId: string) => void; onClick?: (regionId: string) => void;
// Explicit open piano roll action from header pencil icon // Explicit open piano roll action from header pencil icon
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
// Open spectrogram viewer for audio regions
onOpenSpectrogram?: (regionId: string) => void;
// Enter hybrid mode (show + when piano roll is open with the opposite region type selected)
showHybridButton?: boolean;
onOpenHybrid?: (regionId: string) => void;
// Fine-move end callback — passes raw (unscaled) mouse pixel delta
onFineMoveEnd?: (regionId: string, rawPixelDelta: number) => void;
// MIDI region data for rendering notes // MIDI region data for rendering notes
midiRegion?: KGMidiRegion; midiRegion?: KGMidiRegion;
// Audio region data for rendering waveform // Audio region data for rendering waveform
@@ -49,6 +59,10 @@ const RegionItem: React.FC<RegionItemProps> = ({
onDragEnd, onDragEnd,
onClick, onClick,
onOpenPianoRoll, onOpenPianoRoll,
onOpenSpectrogram,
showHybridButton,
onOpenHybrid,
onFineMoveEnd,
midiRegion, midiRegion,
audioRegion, audioRegion,
audioBuffer audioBuffer
@@ -60,11 +74,19 @@ const RegionItem: React.FC<RegionItemProps> = ({
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none'); const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const initialMousePosRef = useRef<{x: number, y: number}>({x: 0, y: 0}); const initialMousePosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 });
// Use refs to track states for immediate access // Use refs to track states for immediate access
const isResizingRef = useRef<boolean>(false); const isResizingRef = useRef<boolean>(false);
const isDraggingRef = useRef<boolean>(false); const isDraggingRef = useRef<boolean>(false);
const hasMovedRef = useRef<boolean>(false); const isPendingDragRef = useRef<boolean>(false);
// Fine-move state
const [isFineDragging, setIsFineDragging] = useState(false);
const [fineDeltaDisplay, setFineDeltaDisplay] = useState('+0.00');
const [fineTranslateX, setFineTranslateX] = useState(0);
const isFineDraggingRef = useRef(false);
const fineMouseStartXRef = useRef(0);
const fineRawDeltaRef = useRef(0);
// Canvas ref for note visualization // Canvas ref for note visualization
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -382,7 +404,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
const activeTool = KGMainContentState.instance().getActiveTool(); const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') { if (activeTool === 'pencil') {
// Still allow click events to pass through for region selection // Still allow click events to pass through for region selection
if (!hasMovedRef.current && onClick) { if (onClick) {
if (DEBUG_MODE.REGION_ITEM) { if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED (pencil mode): regionId=${id}`); console.log(`REGION CLICKED (pencil mode): regionId=${id}`);
} }
@@ -395,7 +417,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
e.preventDefault(); e.preventDefault();
// Reset movement tracking // Reset movement tracking
hasMovedRef.current = false; isPendingDragRef.current = false;
// Store initial mouse position // Store initial mouse position
initialMousePosRef.current = { x: e.clientX, y: e.clientY }; initialMousePosRef.current = { x: e.clientX, y: e.clientY };
@@ -414,21 +436,12 @@ const RegionItem: React.FC<RegionItemProps> = ({
onResizeStart(id, resizeEdge, e.clientX); onResizeStart(id, resizeEdge, e.clientX);
} }
} else { } else {
// Start dragging // Wait for actual pointer movement before promoting this gesture to a drag.
if (DEBUG_MODE.REGION_ITEM) { if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG START: regionId=${id}`); console.log(`PENDING REGION INTERACTION: regionId=${id}`);
} }
setIsDragging(true); isPendingDragRef.current = true;
isDraggingRef.current = true;
// Change cursor to grabbing during drag
setCursor('grabbing');
// Call the onDragStart callback if provided
if (onDragStart) {
onDragStart(id, e.clientX, e.clientY);
}
} }
// Add global event listeners for mouse move and up // Add global event listeners for mouse move and up
@@ -438,9 +451,6 @@ const RegionItem: React.FC<RegionItemProps> = ({
// Handle global mouse move for resize or drag // Handle global mouse move for resize or drag
const handleGlobalMouseMove = (e: MouseEvent) => { const handleGlobalMouseMove = (e: MouseEvent) => {
// Set the hasMovedRef to true as soon as there's movement
hasMovedRef.current = true;
if (isResizingRef.current) { if (isResizingRef.current) {
// Handle resize // Handle resize
if (DEBUG_MODE.REGION_ITEM) { if (DEBUG_MODE.REGION_ITEM) {
@@ -454,16 +464,35 @@ const RegionItem: React.FC<RegionItemProps> = ({
if (onResize) { if (onResize) {
onResize(id, resizeEdge, deltaX); onResize(id, resizeEdge, deltaX);
} }
} else if (isDraggingRef.current) { } else if (isDraggingRef.current || isPendingDragRef.current) {
const deltaX = e.clientX - initialMousePosRef.current.x;
const deltaY = e.clientY - initialMousePosRef.current.y;
const movedEnough = Math.hypot(deltaX, deltaY) >= DRAG_START_THRESHOLD_PX;
if (!isDraggingRef.current) {
if (!movedEnough) {
return;
}
if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG START: regionId=${id}`);
}
isPendingDragRef.current = false;
setIsDragging(true);
isDraggingRef.current = true;
setCursor('grabbing');
if (onDragStart) {
onDragStart(id, initialMousePosRef.current.x, initialMousePosRef.current.y);
}
}
// Handle drag // Handle drag
if (DEBUG_MODE.REGION_ITEM) { if (DEBUG_MODE.REGION_ITEM) {
console.log(`DRAG MOVE: regionId=${id}, trackIndex=${trackIndex}`); console.log(`DRAG MOVE: regionId=${id}, trackIndex=${trackIndex}`);
} }
// Calculate delta from initial position
const deltaX = e.clientX - initialMousePosRef.current.x;
const deltaY = e.clientY - initialMousePosRef.current.y;
// Call the onDrag callback if provided // Call the onDrag callback if provided
if (onDrag) { if (onDrag) {
onDrag(id, deltaX, deltaY); onDrag(id, deltaX, deltaY);
@@ -502,26 +531,62 @@ const RegionItem: React.FC<RegionItemProps> = ({
if (onDragEnd) { if (onDragEnd) {
onDragEnd(id); onDragEnd(id);
} }
} else if (isPendingDragRef.current && onClick) {
// If there was no movement, treat it as a click if (DEBUG_MODE.REGION_ITEM) {
if (!hasMovedRef.current && onClick) { console.log(`REGION CLICKED: regionId=${id}`);
if (DEBUG_MODE.REGION_ITEM) {
console.log(`REGION CLICKED: regionId=${id}`);
}
onClick(id);
} }
onClick(id);
} }
isPendingDragRef.current = false;
// Remove global event listeners // Remove global event listeners
document.removeEventListener('mousemove', handleGlobalMouseMove); document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp); document.removeEventListener('mouseup', handleGlobalMouseUp);
}; };
// Fine-move handlers
const handleFineMoveGlobalMouseMove = (e: MouseEvent) => {
if (!isFineDraggingRef.current) return;
const rawDelta = e.clientX - fineMouseStartXRef.current;
fineRawDeltaRef.current = rawDelta;
const scaledDelta = rawDelta * REGION_CONSTANTS.FINE_MOVE_SPEED_RATIO;
setFineTranslateX(scaledDelta);
setFineDeltaDisplay(scaledDelta >= 0 ? `+${scaledDelta.toFixed(2)}` : `${scaledDelta.toFixed(2)}`);
};
const handleFineMoveGlobalMouseUp = () => {
if (!isFineDraggingRef.current) return;
isFineDraggingRef.current = false;
setIsFineDragging(false);
setFineTranslateX(0);
document.removeEventListener('mousemove', handleFineMoveGlobalMouseMove);
document.removeEventListener('mouseup', handleFineMoveGlobalMouseUp);
if (fineRawDeltaRef.current !== 0 && onFineMoveEnd) {
onFineMoveEnd(id, fineRawDeltaRef.current);
}
};
const handleFineMoveMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (isFineDraggingRef.current) return;
fineMouseStartXRef.current = e.clientX;
fineRawDeltaRef.current = 0;
isFineDraggingRef.current = true;
setIsFineDragging(true);
setFineDeltaDisplay('+0.00');
document.addEventListener('mousemove', handleFineMoveGlobalMouseMove);
document.addEventListener('mouseup', handleFineMoveGlobalMouseUp);
};
// Clean up event listeners on unmount // Clean up event listeners on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove); document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp); document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('mousemove', handleFineMoveGlobalMouseMove);
document.removeEventListener('mouseup', handleFineMoveGlobalMouseUp);
}; };
}, []); }, []);
@@ -538,7 +603,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
<div <div
key={id} key={id}
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''} ${audioRegion ? 'audio-region' : ''}`} className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''} ${audioRegion ? 'audio-region' : ''}`}
style={{ ...style, cursor }} style={{ ...style, cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@@ -551,31 +616,108 @@ const RegionItem: React.FC<RegionItemProps> = ({
{name} {name}
</div> </div>
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}> <div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
{!audioRegion && ( <div className="region-left-buttons">
<button {!audioRegion && (
className="region-pencil-btn" <button
title="Edit notes" className="region-pencil-btn"
onMouseDown={(e) => { title="Edit notes"
e.preventDefault(); onMouseDown={(e) => {
e.stopPropagation(); e.preventDefault();
}} e.stopPropagation();
onClick={(e) => { }}
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.preventDefault();
if (DEBUG_MODE.REGION_ITEM) { e.stopPropagation();
console.log(`Pencil clicked: open piano roll for region ${id}`); if (DEBUG_MODE.REGION_ITEM) {
} console.log(`Pencil clicked: open piano roll for region ${id}`);
if (onOpenPianoRoll) { }
onOpenPianoRoll(id); if (onOpenPianoRoll) {
} else if (onClick) { onOpenPianoRoll(id);
onClick(id); } else if (onClick) {
} onClick(id);
}} }
aria-label="Open piano roll" }}
> aria-label="Open piano roll"
<FaPencilAlt size={10} /> >
</button> <FaPencilAlt size={10} />
)} </button>
)}
{audioRegion && (
<button
className="region-waveform-btn"
title="View waveform"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
aria-label="View waveform"
>
<MdGraphicEq size={10} />
</button>
)}
{audioRegion && (
<button
className="region-spectrogram-btn"
title="View melodic spectrogram"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onOpenSpectrogram) {
onOpenSpectrogram(id);
}
}}
aria-label="View spectrogram"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<rect x="3" y="0.5" width="6.5" height="2.5" rx="0.4"/>
<rect x="1.5" y="3.75" width="6.5" height="2.5" rx="0.4"/>
<rect x="0" y="7" width="6.5" height="2.5" rx="0.4"/>
</svg>
</button>
)}
{showHybridButton && (
<button
className="region-hybrid-btn"
title="Open in hybrid mode"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onOpenHybrid) {
onOpenHybrid(id);
}
}}
aria-label="Open hybrid mode"
>
<FaPlus size={10} />
</button>
)}
<div className="region-fine-move-widget">
<button
className="region-fine-move-btn"
title="Fine move"
onMouseDown={handleFineMoveMouseDown}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
aria-label="Fine move region"
>
<MdSwapHoriz size={10} />
</button>
{isFineDragging && (
<span className="region-fine-move-label">{fineDeltaDisplay}</span>
)}
</div>
</div>
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
</div> </div>
</div> </div>
+1 -1
View File
@@ -49,7 +49,7 @@
} }
.track-info { .track-info {
width: 200px; width: var(--track-info-panel-width);
height: 120px; height: 120px;
padding: 15px 5px 15px 15px; padding: 15px 5px 15px 15px;
background-color: #2d2d2d; background-color: #2d2d2d;
+31
View File
@@ -24,8 +24,13 @@ interface TrackGridItemProps {
onRegionResizeEnd?: (regionId: string, finalBarNumber: number, finalLength: number) => void; onRegionResizeEnd?: (regionId: string, finalBarNumber: number, finalLength: number) => void;
onRegionDrag?: (regionId: string, newBarNumber: number, newTrackIndex: number) => void; onRegionDrag?: (regionId: string, newBarNumber: number, newTrackIndex: number) => void;
onRegionDragEnd?: (regionId: string, finalBarNumber: number, finalTrackIndex: number) => void; onRegionDragEnd?: (regionId: string, finalBarNumber: number, finalTrackIndex: number) => void;
onRegionFineMoveEnd?: (regionId: string, deltaInBars: number) => void;
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
onOpenSpectrogram?: (regionId: string) => void;
showHybridButtonForAudio?: boolean;
showHybridButtonForMidi?: boolean;
onOpenHybrid?: (regionId: string) => void;
allTracks?: KGTrack[]; // Added to access all tracks for drag operations allTracks?: KGTrack[]; // Added to access all tracks for drag operations
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void; onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
} }
@@ -45,8 +50,13 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onRegionResizeEnd, onRegionResizeEnd,
onRegionDrag, onRegionDrag,
onRegionDragEnd, onRegionDragEnd,
onRegionFineMoveEnd,
onRegionClick, onRegionClick,
onOpenPianoRoll, onOpenPianoRoll,
onOpenSpectrogram,
showHybridButtonForAudio,
showHybridButtonForMidi,
onOpenHybrid,
allTracks, allTracks,
onKGOneClipDrop, onKGOneClipDrop,
}) => { }) => {
@@ -484,12 +494,27 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
currentDragTop.current = null; currentDragTop.current = null;
currentDragRegion.current = null; currentDragRegion.current = null;
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
if (DEBUG_MODE.TRACK_GRID_ITEM) {
console.log(`Skipping no-op drag update for region ${regionId}`);
}
return;
}
// Notify parent about drag end with final values // Notify parent about drag end with final values
if (onRegionDragEnd) { if (onRegionDragEnd) {
onRegionDragEnd(regionId, finalBarNumber, finalTrackIndex); onRegionDragEnd(regionId, finalBarNumber, finalTrackIndex);
} }
}; };
// Handle fine-move end — convert raw pixel delta to delta in bars and pass up
const handleRegionFineMoveEnd = (regionId: string, rawPixelDelta: number) => {
const barWidth = containerWidth / maxBars;
if (barWidth <= 0) return;
const deltaInBars = (rawPixelDelta * REGION_CONSTANTS.FINE_MOVE_SPEED_RATIO) / barWidth;
onRegionFineMoveEnd?.(regionId, deltaInBars);
};
// Handle region click // Handle region click
const handleRegionClick = (regionId: string) => { const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_ITEM) { if (DEBUG_MODE.TRACK_GRID_ITEM) {
@@ -555,6 +580,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onDragStart={handleRegionDragStart} onDragStart={handleRegionDragStart}
onDrag={handleRegionDrag} onDrag={handleRegionDrag}
onDragEnd={handleRegionDragEnd} onDragEnd={handleRegionDragEnd}
onFineMoveEnd={handleRegionFineMoveEnd}
// Keep onClick for selection-only logic if needed by parent // Keep onClick for selection-only logic if needed by parent
onClick={handleRegionClick} onClick={handleRegionClick}
// New explicit pencil action — disabled for audio regions // New explicit pencil action — disabled for audio regions
@@ -566,6 +592,11 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onRegionClick(regionId); onRegionClick(regionId);
} }
}} }}
onOpenSpectrogram={audioRegion ? (regionId) => {
onOpenSpectrogram?.(regionId);
} : undefined}
showHybridButton={audioRegion ? showHybridButtonForAudio : showHybridButtonForMidi}
onOpenHybrid={onOpenHybrid}
midiRegion={midiRegion} midiRegion={midiRegion}
audioRegion={audioRegion} audioRegion={audioRegion}
audioBuffer={audioBuffer} audioBuffer={audioBuffer}
+58
View File
@@ -30,6 +30,10 @@ interface TrackGridPanelProps {
onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void; onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void;
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
onOpenSpectrogram?: (regionId: string) => void;
showHybridButtonForAudio?: boolean;
showHybridButtonForMidi?: boolean;
onOpenHybrid?: (regionId: string) => void;
onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void; onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void;
} }
@@ -46,6 +50,10 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionUpdated, onRegionUpdated,
onRegionClick, onRegionClick,
onOpenPianoRoll, onOpenPianoRoll,
onOpenSpectrogram,
showHybridButtonForAudio,
showHybridButtonForMidi,
onOpenHybrid,
onExternalDropComplete, onExternalDropComplete,
}) => { }) => {
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
@@ -379,6 +387,13 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
const region = regions.find(r => r.id === regionId); const region = regions.find(r => r.id === regionId);
if (!region) return; if (!region) return;
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Skipping no-op move for region ${regionId}`);
}
return;
}
// Get the target track // Get the target track
const targetTrack = tracks[finalTrackIndex]; const targetTrack = tracks[finalTrackIndex];
if (!targetTrack) return; if (!targetTrack) return;
@@ -458,6 +473,44 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
} }
}; };
// Handle fine-move end — execute MoveRegionCommand with float-precision beat position
const handleRegionFineMoveEnd = (regionId: string, deltaInBars: number) => {
const region = regions.find(r => r.id === regionId);
if (!region) return;
const track = tracks.find(t => t.getId().toString() === region.trackId);
if (!track) return;
const coreRegion = track.getRegions().find(r => r.getId() === regionId);
if (!coreRegion) return;
const beatsPerBar = timeSignature.numerator;
const newStartFromBeat = Math.max(0, coreRegion.getStartFromBeat() + deltaInBars * beatsPerBar);
if (newStartFromBeat === coreRegion.getStartFromBeat()) return;
try {
// Use constructor directly (NOT fromBarCoordinates) to preserve float precision
const command = new MoveRegionCommand(
regionId,
newStartFromBeat,
track.getId().toString(),
region.trackIndex
);
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`Fine-moved region ${regionId}: startFromBeat=${newStartFromBeat}`);
}
const newBarNumber = newStartFromBeat / beatsPerBar + 1;
onRegionUpdated?.(
regionId,
{ barNumber: newBarNumber, trackId: region.trackId, trackIndex: region.trackIndex },
{ startBeat: newStartFromBeat, length: coreRegion.getLength() }
);
} catch (error) {
console.error('Error executing fine-move:', error);
}
};
// Handle region click // Handle region click
const handleRegionClick = (regionId: string) => { const handleRegionClick = (regionId: string) => {
if (DEBUG_MODE.TRACK_GRID_PANEL) { if (DEBUG_MODE.TRACK_GRID_PANEL) {
@@ -649,8 +702,13 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionResizeEnd={handleRegionResizeEnd} onRegionResizeEnd={handleRegionResizeEnd}
onRegionDrag={handleRegionDrag} onRegionDrag={handleRegionDrag}
onRegionDragEnd={handleRegionDragEnd} onRegionDragEnd={handleRegionDragEnd}
onRegionFineMoveEnd={handleRegionFineMoveEnd}
onRegionClick={handleRegionClick} onRegionClick={handleRegionClick}
onOpenPianoRoll={onOpenPianoRoll} onOpenPianoRoll={onOpenPianoRoll}
onOpenSpectrogram={onOpenSpectrogram}
showHybridButtonForAudio={showHybridButtonForAudio}
showHybridButtonForMidi={showHybridButtonForMidi}
onOpenHybrid={onOpenHybrid}
allTracks={tracks} allTracks={tracks}
onKGOneClipDrop={handleExternalDrop} onKGOneClipDrop={handleExternalDrop}
/> />
+2
View File
@@ -28,6 +28,8 @@ export const REGION_CONSTANTS = {
EDGE_THRESHOLD: 10, EDGE_THRESHOLD: 10,
// Minimum region length in bars // Minimum region length in bars
MIN_REGION_LENGTH: 1.0, MIN_REGION_LENGTH: 1.0,
// Fine-move speed ratio: mouse pixels to region movement pixels
FINE_MOVE_SPEED_RATIO: 0.25,
}; };
// Piano roll related constants // Piano roll related constants
@@ -41,10 +41,10 @@ describe('upgradeToV7', () => {
}); });
it('clamps values that exceed MAX_TRACK_VOLUME_DB', () => { it('clamps values that exceed MAX_TRACK_VOLUME_DB', () => {
// linear > 1.0 would give positive dB; cap at +6 // linear > 1.0 gives positive dB but 2.0 is still below the +12 dB ceiling
const project = makeProject([2.0]); const project = makeProject([2.0]);
upgradeToV7(project); upgradeToV7(project);
expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB); expect(project.getTracks()[0].getVolume()).toBeCloseTo(20 * Math.log10(2.0), 5);
}); });
it('bumps project structure version to 7', () => { it('bumps project structure version to 7', () => {
+1 -1
View File
@@ -348,7 +348,7 @@ describe('KGMidiTrack', () => {
// Volume outside valid dB range is clamped // Volume outside valid dB range is clamped
track.setVolume(10); track.setVolume(10);
expect(track.getVolume()).toBe(6); expect(track.getVolume()).toBe(10);
track.setVolume(-100); track.setVolume(-100);
expect(track.getVolume()).toBe(-60); expect(track.getVolume()).toBe(-60);
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
const mockProject = {
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
getMaxBars: () => 32,
getBarWidthMultiplier: () => 1,
getTracks: () => [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')],
getBpm: () => 120,
getKeySignature: () => 'C major',
getName: () => 'Test Project',
getSelectedMode: () => 'major',
getIsLooping: () => false,
getLoopingRange: () => null,
};
const mockCore = {
getCurrentProject: () => mockProject,
setPlayheadUpdateCallback: vi.fn(),
setPlaybackStateChangeCallback: vi.fn(),
getSelectedItems: () => [],
onSelectionChanged: vi.fn(),
canUndo: () => false,
canRedo: () => false,
getUndoDescription: () => '',
getRedoDescription: () => '',
setOnCommandHistoryChanged: vi.fn(),
executeCommand: vi.fn(),
clearSelectedItems: vi.fn(),
getStatus: () => 'Ready',
getPlayheadPosition: () => 0,
getIsPlaying: () => false,
};
vi.mock('../core/KGCore', () => ({
KGCore: {
instance: () => mockCore,
},
}));
vi.mock('../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => ({
getIsInitialized: () => true,
get: () => false,
}),
},
}));
describe('projectStore piano roll state', () => {
beforeEach(() => {
vi.resetModules();
});
it('clears hybrid state when opening a MIDI region', async () => {
const { useProjectStore } = await import('./projectStore');
act(() => {
useProjectStore.getState().openHybridMode('midi-a', 'audio-a');
});
let state = useProjectStore.getState();
expect(state.pianoRollMode).toBe('hybrid');
expect(state.activeRegionId).toBe('midi-a');
expect(state.hybridAudioRegionId).toBe('audio-a');
act(() => {
useProjectStore.getState().openMidiPianoRoll('midi-b');
});
state = useProjectStore.getState();
expect(state.showPianoRoll).toBe(true);
expect(state.pianoRollMode).toBe('midi-edit');
expect(state.activeRegionId).toBe('midi-b');
expect(state.hybridAudioRegionId).toBeNull();
});
});
+39 -2
View File
@@ -78,6 +78,8 @@ interface ProjectState {
// Piano roll state // Piano roll state
showPianoRoll: boolean; showPianoRoll: boolean;
activeRegionId: string | null; activeRegionId: string | null;
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
hybridAudioRegionId: string | null;
// ChatBox state // ChatBox state
showChatBox: boolean; showChatBox: boolean;
@@ -108,6 +110,12 @@ interface ProjectState {
undoDescription: string | null; undoDescription: string | null;
redoDescription: string | null; redoDescription: string | null;
// Cross-component scroll request state
requestMainContentScroll: (beatPosition: number) => void;
requestPianoRollScroll: (beatPosition: number) => void;
mainContentScrollRequest: number | null;
pianoRollScrollRequest: number | null;
// Actions // Actions
setProjectName: (name: string) => void; setProjectName: (name: string) => void;
setSavedProjectName: (name: string) => void; setSavedProjectName: (name: string) => void;
@@ -146,6 +154,9 @@ interface ProjectState {
// Piano roll actions // Piano roll actions
setShowPianoRoll: (show: boolean) => void; setShowPianoRoll: (show: boolean) => void;
setActiveRegionId: (regionId: string | null) => void; setActiveRegionId: (regionId: string | null) => void;
openMidiPianoRoll: (regionId: string) => void;
openSpectrogramViewer: (regionId: string) => void;
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
// Project state cleanup // Project state cleanup
cleanupProjectState: () => void; cleanupProjectState: () => void;
@@ -304,6 +315,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Initial piano roll state // Initial piano roll state
showPianoRoll: false, showPianoRoll: false,
activeRegionId: null, activeRegionId: null,
pianoRollMode: 'midi-edit' as const,
hybridAudioRegionId: null,
// Initial ChatBox state // Initial ChatBox state
showChatBox: initialChatBoxState, showChatBox: initialChatBoxState,
@@ -333,6 +346,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
recordingNotes: [], recordingNotes: [],
recordingOriginalPlayhead: 0, recordingOriginalPlayhead: 0,
// Initial cross-component scroll request state
mainContentScrollRequest: null,
pianoRollScrollRequest: null,
// Actions // Actions
setProjectName: (name: string) => { setProjectName: (name: string) => {
try { try {
@@ -786,6 +803,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ autoScrollEnabled: enabled }); set({ autoScrollEnabled: enabled });
}, },
requestMainContentScroll: (beatPosition: number) => {
set({ mainContentScrollRequest: beatPosition });
},
requestPianoRollScroll: (beatPosition: number) => {
set({ pianoRollScrollRequest: beatPosition });
},
startPlaying: async () => { startPlaying: async () => {
await KGCore.instance().startPlaying(); await KGCore.instance().startPlaying();
set({ isPlaying: true, autoScrollEnabled: true }); set({ isPlaying: true, autoScrollEnabled: true });
@@ -1033,13 +1058,25 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ activeRegionId: regionId }); set({ activeRegionId: regionId });
}, },
openMidiPianoRoll: (regionId: string) => {
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit', hybridAudioRegionId: null });
},
openSpectrogramViewer: (regionId: string) => {
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram', hybridAudioRegionId: null });
},
openHybridMode: (midiRegionId: string, audioRegionId: string) => {
set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' });
},
// Project state cleanup - used when starting new/loading projects // Project state cleanup - used when starting new/loading projects
cleanupProjectState: () => { cleanupProjectState: () => {
// Close piano roll if it's visible // Close piano roll if it's visible
set({ showPianoRoll: false }); set({ showPianoRoll: false });
// Clear active region // Clear active region and hybrid state
set({ activeRegionId: null }); set({ activeRegionId: null, hybridAudioRegionId: null, pianoRollMode: 'midi-edit' });
// Clear any selected items // Clear any selected items
KGCore.instance().clearSelectedItems(); KGCore.instance().clearSelectedItems();
+1
View File
@@ -2,6 +2,7 @@
--time-signature-numerator: 4; --time-signature-numerator: 4;
--max-number-of-bars: 32; --max-number-of-bars: 32;
--track-grid-bar-width: 40px; --track-grid-bar-width: 40px;
--track-info-panel-width: 200px;
--region-piano-key-width: 60px; --region-piano-key-width: 60px;
--region-piano-key-height: 20px; --region-piano-key-height: 20px;
--region-grid-beat-width: 40px; --region-grid-beat-width: 40px;
+103
View File
@@ -0,0 +1,103 @@
import FFT from 'fft.js';
type WorkerScopeLike = typeof globalThis & {
onmessage: ((event: MessageEvent<SpectrogramRequest>) => void) | null;
postMessage: (message: SpectrogramResult, transfer: Transferable[]) => void;
};
const workerScope = self as WorkerScopeLike;
export interface SpectrogramRequest {
pcm: Float32Array;
sampleRate: number;
clipStartOffsetSeconds: number;
regionDurationSeconds: number;
bpm: number;
}
export interface SpectrogramResult {
data: Float32Array; // [timeSteps × 128] row-major, row = time step, col = pitch 0-127
timeSteps: number;
}
const FFT_SIZE = 8192;
const HOP_SIZE = 1024;
const PITCH_BINS = 128;
function hannWindow(size: number): Float32Array {
const w = new Float32Array(size);
for (let i = 0; i < size; i++) {
w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
}
return w;
}
workerScope.onmessage = (e: MessageEvent<SpectrogramRequest>) => {
const { pcm, sampleRate, clipStartOffsetSeconds, regionDurationSeconds } = e.data;
const startSample = Math.floor(clipStartOffsetSeconds * sampleRate);
const endSample = Math.min(pcm.length, startSample + Math.ceil(regionDurationSeconds * sampleRate));
const regionSamples = pcm.subarray(startSample, endSample);
const fft = new FFT(FFT_SIZE);
const hann = hannWindow(FFT_SIZE);
const complexOut = fft.createComplexArray() as number[];
const inputPadded = new Float32Array(FFT_SIZE);
const totalHops = Math.max(1, Math.ceil((regionSamples.length - FFT_SIZE) / HOP_SIZE) + 1);
const result = new Float32Array(totalHops * PITCH_BINS);
let maxVal = 0;
for (let hop = 0; hop < totalHops; hop++) {
const offset = hop * HOP_SIZE;
// Fill windowed frame (zero-pad at end if needed)
inputPadded.fill(0);
const available = Math.min(FFT_SIZE, regionSamples.length - offset);
for (let i = 0; i < available; i++) {
inputPadded[i] = regionSamples[offset + i] * hann[i];
}
fft.realTransform(complexOut, inputPadded as unknown as number[]);
fft.completeSpectrum(complexOut);
// Max-pool magnitude into pitch bins: keep the loudest FFT bin per semitone,
// rather than summing. Summing inflates every bin by how many FFT bins land there.
const pitchRow = hop * PITCH_BINS;
const numBins = FFT_SIZE / 2;
for (let bin = 1; bin < numBins; bin++) {
const freq = (bin * sampleRate) / FFT_SIZE;
if (freq < 20 || freq > 20000) continue;
// Convert frequency to MIDI pitch; clamp to piano roll range C0B7 (MIDI 12107)
const pitch = Math.round(69 + 12 * Math.log2(freq / 440));
if (pitch < 12 || pitch > 107) continue;
const re = complexOut[2 * bin];
const im = complexOut[2 * bin + 1];
const magnitude = Math.sqrt(re * re + im * im);
if (magnitude > result[pitchRow + pitch]) {
result[pitchRow + pitch] = magnitude;
}
}
// Track global max for normalization
for (let p = 0; p < PITCH_BINS; p++) {
if (result[pitchRow + p] > maxVal) maxVal = result[pitchRow + p];
}
}
// Linear normalization only — threshold and power curve are applied in the canvas
// renderer so changing them is instant (no need to re-run the FFT).
if (maxVal > 0) {
for (let i = 0; i < result.length; i++) {
result[i] = result[i] / maxVal;
}
}
const response: SpectrogramResult = { data: result, timeSteps: totalHops };
workerScope.postMessage(response, [result.buffer]);
};