feat: add audio track support with WAV/MP3 playback and looping
Introduce KGAudioTrack and KGAudioRegion as new track/region types alongside existing MIDI tracks. Users can import WAV, MP3, OGG, FLAC, and AAC files into audio tracks with full playback, looping, and solo/mute/volume support. New core models: - KGAudioTrack (extends KGTrack with TrackType.Wave) - KGAudioRegion (extends KGRegion with audio file reference) - KGAudioPlayerBus (Tone.Gain + ToneBufferSource-based playback) - KGAudioFileStorage (OPFS media/ directory for binary audio files) New commands: - AddAudioTrackCommand (create audio track with player bus) - ImportAudioCommand (import audio file, create region at playhead, auto-expand maxBars) Playback features: - Audio regions scheduled via Tone.Transport alongside MIDI events - Loop support with duration capping at loop boundaries - Resume safety offset to prevent ToneBufferSource timing misses - Cross-track buffer copy when moving audio regions between tracks - Proper buffer cleanup on track deletion and project switch UI changes: - Separate "+ MIDI" and "+ Audio" buttons in top-left spacer - Audio track shows upload button instead of instrument selector - FileImportModal reused for audio file drag-and-drop upload - Real waveform rendering on audio region canvas - Green color scheme for audio regions (vs blue for MIDI) - Pencil icon and resize handles hidden for audio regions - Cross-type region moves blocked (MIDI ↔ Audio) - Piano roll blocked for audio regions - Audio region deletion support Infrastructure: - Project structure version bumped to 4 (no-op migration) - class-transformer discriminators updated for new subtypes - RemoveTrackCommand updated to clean up audio player buses
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
z-index: 1002; /* Higher than other elements to ensure it's always visible */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Offset spacer when instrument selection panel is visible on the left */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||
import TrackGridPanel from './track/TrackGridPanel';
|
||||
import PianoRoll from './piano-roll/PianoRoll';
|
||||
@@ -35,7 +36,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
activeRegionId,
|
||||
setShowPianoRoll,
|
||||
setActiveRegionId,
|
||||
addTrack
|
||||
addTrack,
|
||||
addAudioTrack
|
||||
} = useProjectStore();
|
||||
|
||||
// State to store regions
|
||||
@@ -130,7 +132,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
|
||||
// Iterate through all regions in the track
|
||||
track.getRegions().forEach(region => {
|
||||
if (region instanceof KGMidiRegion) {
|
||||
if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) {
|
||||
// Calculate bar number and length from beats
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
|
||||
@@ -432,6 +434,15 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
|
||||
// Handle explicit pencil action: select region and open piano roll
|
||||
const handleOpenPianoRoll = (regionId: string) => {
|
||||
// Don't open piano roll for audio regions
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
for (const track of project.getTracks()) {
|
||||
const region = track.getRegions().find(r => r.getId() === regionId);
|
||||
if (region && region.getCurrentType() === 'KGAudioRegion') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Open piano roll via pencil for region: ${regionId}`);
|
||||
}
|
||||
@@ -691,7 +702,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
<div className="main-content-wrapper">
|
||||
{/* Top-left spacer */}
|
||||
<div className="top-left-spacer">
|
||||
<button className="add-track-btn" onClick={() => addTrack()}>+ Add track</button>
|
||||
<button className="add-track-btn" onClick={() => addTrack()}>+ MIDI</button>
|
||||
<button className="add-track-btn" onClick={() => addAudioTrack()}>+ Audio</button>
|
||||
</div>
|
||||
|
||||
{/* Bar numbers at the top */}
|
||||
|
||||
@@ -71,6 +71,28 @@
|
||||
position: relative; /* Allow overlayed controls */
|
||||
}
|
||||
|
||||
.region-content.audio-region-content {
|
||||
background-color: #90EE90; /* Light green for audio regions */
|
||||
}
|
||||
|
||||
/* Audio region overrides */
|
||||
.track-region.audio-region {
|
||||
background-color: #3a6b4a;
|
||||
border-color: #4a8b5a;
|
||||
}
|
||||
|
||||
.track-region.audio-region:hover {
|
||||
box-shadow: 0 0 0 1px #6aab7a;
|
||||
}
|
||||
|
||||
.track-region.audio-region .region-header {
|
||||
background-color: #4a8b5a;
|
||||
}
|
||||
|
||||
.track-region.audio-region:hover .region-header {
|
||||
background-color: #5a9b6a;
|
||||
}
|
||||
|
||||
/* Region pencil trigger inside content */
|
||||
.region-pencil-btn {
|
||||
position: absolute;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FaPencilAlt } from 'react-icons/fa';
|
||||
import type { ResizeAction } from '../interfaces';
|
||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
|
||||
@@ -28,6 +29,9 @@ interface RegionItemProps {
|
||||
onOpenPianoRoll?: (regionId: string) => void;
|
||||
// MIDI region data for rendering notes
|
||||
midiRegion?: KGMidiRegion;
|
||||
// Audio region data for rendering waveform
|
||||
audioRegion?: KGAudioRegion;
|
||||
audioBuffer?: AudioBuffer;
|
||||
}
|
||||
|
||||
const RegionItem: React.FC<RegionItemProps> = ({
|
||||
@@ -45,7 +49,9 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
onDragEnd,
|
||||
onClick,
|
||||
onOpenPianoRoll,
|
||||
midiRegion
|
||||
midiRegion,
|
||||
audioRegion,
|
||||
audioBuffer
|
||||
}) => {
|
||||
// Get selection state and time signature from store
|
||||
const { selectedRegionIds, timeSignature } = useProjectStore();
|
||||
@@ -204,6 +210,58 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Function to render audio waveform on canvas
|
||||
const renderWaveformOnCanvas = () => {
|
||||
if (!canvasRef.current || !regionContentRef.current || !audioBuffer) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const contentRect = regionContentRef.current.getBoundingClientRect();
|
||||
const width = contentRect.width;
|
||||
const height = contentRect.height;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Get channel data (use first channel)
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samples = channelData.length;
|
||||
|
||||
// Downsample to canvas width
|
||||
const samplesPerPixel = Math.max(1, Math.floor(samples / width));
|
||||
const centerY = height / 2;
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const startSample = Math.floor(x * samplesPerPixel);
|
||||
const endSample = Math.min(startSample + samplesPerPixel, samples);
|
||||
|
||||
let min = 0;
|
||||
let max = 0;
|
||||
for (let i = startSample; i < endSample; i++) {
|
||||
const val = channelData[i];
|
||||
if (val < min) min = val;
|
||||
if (val > max) max = val;
|
||||
}
|
||||
|
||||
// Draw vertical line from min to max amplitude
|
||||
const yMin = centerY - max * centerY;
|
||||
const yMax = centerY - min * centerY;
|
||||
|
||||
ctx.moveTo(x, yMin);
|
||||
ctx.lineTo(x, yMax);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// Create a stable reference to track note changes
|
||||
const notesRef = useRef<string>('');
|
||||
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
|
||||
@@ -226,15 +284,23 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
|
||||
// Set up canvas when component mounts or updates
|
||||
useEffect(() => {
|
||||
if (audioRegion && audioBuffer) {
|
||||
renderWaveformOnCanvas();
|
||||
} else {
|
||||
renderNotesOnCanvas();
|
||||
}, [midiRegion, timeSignature, id, noteUpdateTrigger]);
|
||||
}
|
||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature, id, noteUpdateTrigger]);
|
||||
|
||||
// Re-render canvas when region content size changes
|
||||
useEffect(() => {
|
||||
if (!regionContentRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (audioRegion && audioBuffer) {
|
||||
renderWaveformOnCanvas();
|
||||
} else {
|
||||
renderNotesOnCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(regionContentRef.current);
|
||||
@@ -244,7 +310,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
resizeObserver.unobserve(regionContentRef.current);
|
||||
}
|
||||
};
|
||||
}, [midiRegion, timeSignature]);
|
||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature]);
|
||||
|
||||
// Handle mouse movement to detect edge proximity
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -259,6 +325,13 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Audio regions: move only, no resize
|
||||
if (audioRegion) {
|
||||
setCursor('grab');
|
||||
setResizeEdge('none');
|
||||
return;
|
||||
}
|
||||
|
||||
const regionElement = e.currentTarget;
|
||||
const rect = regionElement.getBoundingClientRect();
|
||||
|
||||
@@ -453,7 +526,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''}`}
|
||||
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? 'selected' : ''} ${audioRegion ? 'audio-region' : ''}`}
|
||||
style={{ ...style, cursor }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -464,9 +537,10 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
data-is-dragging={isDragging}
|
||||
>
|
||||
<div className="region-header">
|
||||
{name}
|
||||
{audioRegion ? audioRegion.getAudioFileName() : name}
|
||||
</div>
|
||||
<div className="region-content" ref={regionContentRef}>
|
||||
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
||||
{!audioRegion && (
|
||||
<button
|
||||
className="region-pencil-btn"
|
||||
title="Edit notes"
|
||||
@@ -490,6 +564,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
>
|
||||
<FaPencilAlt size={10} />
|
||||
</button>
|
||||
)}
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import RegionItem from './RegionItem';
|
||||
import type { RegionUI, ResizeAction } from '../interfaces';
|
||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||
@@ -506,8 +508,19 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
>
|
||||
{/* Render regions for this track */}
|
||||
{trackRegions.map(region => {
|
||||
// Find the corresponding KGMidiRegion in the track
|
||||
const midiRegion = track.getRegions().find(r => r.getId() === region.id) as KGMidiRegion | undefined;
|
||||
// Find the corresponding region in the track
|
||||
const coreRegion = track.getRegions().find(r => r.getId() === region.id);
|
||||
const midiRegion = coreRegion?.getCurrentType() === 'KGMidiRegion' ? coreRegion as unknown as KGMidiRegion : undefined;
|
||||
const audioRegion = coreRegion?.getCurrentType() === 'KGAudioRegion' ? coreRegion as unknown as KGAudioRegion : undefined;
|
||||
|
||||
// Get audio buffer for waveform rendering
|
||||
let audioBuffer: AudioBuffer | undefined;
|
||||
if (audioRegion) {
|
||||
audioBuffer = KGAudioInterface.instance().getAudioBuffer(
|
||||
track.getId().toString(),
|
||||
audioRegion.getAudioFileId()
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RegionItem
|
||||
@@ -526,8 +539,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
onDragEnd={handleRegionDragEnd}
|
||||
// Keep onClick for selection-only logic if needed by parent
|
||||
onClick={handleRegionClick}
|
||||
// New explicit pencil action
|
||||
onOpenPianoRoll={(regionId) => {
|
||||
// New explicit pencil action — disabled for audio regions
|
||||
onOpenPianoRoll={audioRegion ? undefined : (regionId) => {
|
||||
if (onOpenPianoRoll) {
|
||||
onOpenPianoRoll(regionId);
|
||||
} else if (onRegionClick) {
|
||||
@@ -536,6 +549,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
}
|
||||
}}
|
||||
midiRegion={midiRegion}
|
||||
audioRegion={audioRegion}
|
||||
audioBuffer={audioBuffer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { KGTrack, TrackType } from '../../core/track/KGTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import TrackGridItem from './TrackGridItem';
|
||||
import { Playhead } from '../common';
|
||||
@@ -9,6 +9,8 @@ import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { generateNewRegionName } from '../../util/miscUtil';
|
||||
|
||||
interface TrackGridPanelProps {
|
||||
@@ -65,6 +67,11 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
const track = tracks[trackIndex];
|
||||
const trackId = track.getId().toString();
|
||||
|
||||
// Don't allow manual region creation on audio tracks
|
||||
if (track.getType() === TrackType.Wave) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`Creating region on track ${trackIndex + 1}, bar ${barNumber}`);
|
||||
}
|
||||
@@ -239,6 +246,18 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
const targetTrack = tracks[finalTrackIndex];
|
||||
if (!targetTrack) return;
|
||||
|
||||
// Block cross-type region moves (MIDI <-> Audio)
|
||||
const sourceTrack = tracks.find(t => {
|
||||
return t.getRegions().some(r => r.getId() === regionId);
|
||||
});
|
||||
if (sourceTrack && sourceTrack.getType() !== targetTrack.getType()) {
|
||||
// Snap back — don't execute the move
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`Blocked cross-type move: ${sourceTrack.getType()} region cannot move to ${targetTrack.getType()} track`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use command pattern to move the region
|
||||
try {
|
||||
const command = MoveRegionCommand.fromBarCoordinates(
|
||||
@@ -251,6 +270,19 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
|
||||
KGCore.instance().executeCommand(command);
|
||||
|
||||
// Copy audio buffer to target track if this is a cross-track audio region move
|
||||
if (sourceTrack && targetTrack && sourceTrack.getId() !== targetTrack.getId()) {
|
||||
const coreRegion = targetTrack.getRegions().find(r => r.getId() === regionId);
|
||||
if (coreRegion?.getCurrentType() === 'KGAudioRegion') {
|
||||
const audioRegion = coreRegion as unknown as KGAudioRegion;
|
||||
KGAudioInterface.instance().copyAudioBufferBetweenTracks(
|
||||
sourceTrack.getId().toString(),
|
||||
targetTrack.getId().toString(),
|
||||
audioRegion.getAudioFileId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`Executed MoveRegionCommand: region ${regionId} moved using command pattern`);
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { TbPiano } from 'react-icons/tb';
|
||||
import { TbSettings } from 'react-icons/tb';
|
||||
import { FaFileAudio } from 'react-icons/fa';
|
||||
import KGDropdown from '../common/KGDropdown';
|
||||
import FileImportModal from '../common/FileImportModal';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
@@ -34,7 +37,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
onDrop,
|
||||
onDragEnd
|
||||
}) => {
|
||||
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, tracks: allTracks } = useProjectStore();
|
||||
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
|
||||
const isSelected = selectedTrackId === track.getId().toString();
|
||||
// Inline instrument dropdown removed; use InstrumentSelection panel instead
|
||||
|
||||
@@ -48,6 +51,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
|
||||
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
|
||||
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
|
||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const suppressDragRef = useRef(false);
|
||||
const [volume, setVolume] = useState(track.getVolume());
|
||||
@@ -195,6 +199,8 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
|
||||
// Inline instrument change removed; handled by InstrumentSelection panel
|
||||
|
||||
const isAudioTrack = track instanceof KGAudioTrack;
|
||||
|
||||
// Handle piano button click
|
||||
const handlePianoButtonClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -204,6 +210,19 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
toggleInstrumentSelectionForTrack();
|
||||
};
|
||||
|
||||
// Handle audio import button click
|
||||
const handleAudioImportClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTrack(track.getId().toString());
|
||||
setShowAudioImportModal(true);
|
||||
};
|
||||
|
||||
// Handle audio file import
|
||||
const handleAudioFileImport = (file: File) => {
|
||||
importAudioToTrack(track.getId().toString(), file);
|
||||
setShowAudioImportModal(false);
|
||||
};
|
||||
|
||||
// Handle settings button click
|
||||
const handleSettingsButtonClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -253,12 +272,18 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<div className="track-controls">
|
||||
<div className="track-name-and-volume">
|
||||
<div className="instrument-image">
|
||||
{isAudioTrack ? (
|
||||
<div style={{ width: 64, height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '28px', color: 'var(--text-secondary, #999)' }}>
|
||||
<FaFileAudio />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
|
||||
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-name-and-controls">
|
||||
<div
|
||||
@@ -296,9 +321,15 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
|
||||
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
|
||||
<div>
|
||||
{isAudioTrack ? (
|
||||
<button className="instrument" onClick={handleAudioImportClick} title="Import Audio">
|
||||
<FaFileAudio />
|
||||
</button>
|
||||
) : (
|
||||
<button className="instrument" onClick={handlePianoButtonClick}>
|
||||
<TbPiano />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }} ref={settingsDropdownRef}>
|
||||
<button className="settings" onClick={handleSettingsButtonClick}>
|
||||
@@ -319,6 +350,16 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isAudioTrack && (
|
||||
<FileImportModal
|
||||
isVisible={showAudioImportModal}
|
||||
onClose={() => setShowAudioImportModal(false)}
|
||||
onFileImport={handleAudioFileImport}
|
||||
acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
|
||||
title="Import Audio"
|
||||
description="Drag and drop your audio file here"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGTrack } from './track/KGTrack';
|
||||
import { KGMidiTrack } from './track/KGMidiTrack';
|
||||
import { KGAudioTrack } from './track/KGAudioTrack';
|
||||
import { type TimeSignature, WithDefault } from '../types/projectTypes';
|
||||
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||
|
||||
@@ -47,7 +48,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 3;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 4;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
@@ -56,6 +57,7 @@ export class KGProject {
|
||||
subTypes: [
|
||||
{ value: KGTrack, name: 'KGTrack' },
|
||||
{ value: KGMidiTrack, name: 'KGMidiTrack' },
|
||||
{ value: KGAudioTrack, name: 'KGAudioTrack' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,9 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { pitchToNoteNameString } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { KGCore } from '../KGCore';
|
||||
import { ConfigManager } from '../config/ConfigManager';
|
||||
|
||||
@@ -15,6 +17,13 @@ import { ConfigManager } from '../config/ConfigManager';
|
||||
* Abstracts audio engine implementation (Tone.js) for potential future replacement
|
||||
*/
|
||||
export class KGAudioInterface {
|
||||
/**
|
||||
* Avoid scheduling audio-region resume callbacks exactly on the current
|
||||
* transport boundary. Tone.Transport can miss those edge-triggered events,
|
||||
* which leaves the playhead moving but the resumed clip silent.
|
||||
*/
|
||||
private static readonly AUDIO_RESUME_SAFETY_OFFSET_SECONDS = 0.005;
|
||||
|
||||
// Private static instance for singleton pattern
|
||||
private static _instance: KGAudioInterface | null = null;
|
||||
|
||||
@@ -25,6 +34,9 @@ export class KGAudioInterface {
|
||||
// Track management - now using KGAudioBus
|
||||
private trackAudioBuses: Map<string, KGAudioBus> = new Map();
|
||||
|
||||
// Audio player buses for audio/wav tracks
|
||||
private trackAudioPlayerBuses: Map<string, KGAudioPlayerBus> = new Map();
|
||||
|
||||
// Playback state
|
||||
private isPlaying: boolean = false;
|
||||
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
||||
@@ -126,6 +138,12 @@ export class KGAudioInterface {
|
||||
});
|
||||
this.trackAudioBuses.clear();
|
||||
|
||||
// Dispose of all audio player buses
|
||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||
playerBus.dispose();
|
||||
});
|
||||
this.trackAudioPlayerBuses.clear();
|
||||
|
||||
// Dispose master gain
|
||||
if (this.masterGain) {
|
||||
this.masterGain.dispose();
|
||||
@@ -217,6 +235,113 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO PLAYER BUS MANAGEMENT (for audio/wav tracks) =====
|
||||
|
||||
/**
|
||||
* Create an audio player bus for an audio track
|
||||
*/
|
||||
public async createTrackAudioPlayerBus(
|
||||
trackId: string,
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME
|
||||
): Promise<void> {
|
||||
// Remove existing player bus if it exists
|
||||
await this.removeTrackAudioPlayerBus(trackId);
|
||||
|
||||
try {
|
||||
console.log(`Creating audio player bus for track ${trackId}`);
|
||||
const playerBus = await KGAudioPlayerBus.create(volume);
|
||||
|
||||
if (this.masterGain) {
|
||||
playerBus.connect(this.masterGain);
|
||||
}
|
||||
|
||||
this.trackAudioPlayerBuses.set(trackId, playerBus);
|
||||
console.log(`Created audio player bus for track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to create audio player bus for track ${trackId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an audio player bus
|
||||
*/
|
||||
public async removeTrackAudioPlayerBus(trackId: string): Promise<void> {
|
||||
try {
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (playerBus) {
|
||||
playerBus.dispose();
|
||||
this.trackAudioPlayerBuses.delete(trackId);
|
||||
console.log(`Removed audio player bus for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error removing audio player bus for track ${trackId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an audio buffer into a track's player bus
|
||||
*/
|
||||
public loadAudioBufferForTrack(
|
||||
trackId: string,
|
||||
audioFileId: string,
|
||||
buffer: Tone.ToneAudioBuffer
|
||||
): void {
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (playerBus) {
|
||||
playerBus.loadBuffer(audioFileId, buffer);
|
||||
} else {
|
||||
console.warn(`No audio player bus found for track ${trackId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw AudioBuffer for waveform rendering
|
||||
*/
|
||||
public getAudioBuffer(trackId: string, audioFileId: string): AudioBuffer | undefined {
|
||||
// Try the specified track first
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
const buffer = playerBus?.getAudioBuffer(audioFileId);
|
||||
if (buffer) return buffer;
|
||||
|
||||
// Fallback: search all player buses (handles region moved to a different track)
|
||||
for (const bus of this.trackAudioPlayerBuses.values()) {
|
||||
const found = bus.getAudioBuffer(audioFileId);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an audio buffer from one track's player bus to another.
|
||||
* Used when an audio region is moved between tracks.
|
||||
*/
|
||||
public copyAudioBufferBetweenTracks(
|
||||
sourceTrackId: string,
|
||||
targetTrackId: string,
|
||||
audioFileId: string
|
||||
): void {
|
||||
// Use the raw AudioBuffer approach: get from any bus, wrap in ToneAudioBuffer, load into target
|
||||
const rawBuffer = this.getAudioBuffer(sourceTrackId, audioFileId);
|
||||
if (!rawBuffer) return;
|
||||
|
||||
const targetBus = this.trackAudioPlayerBuses.get(targetTrackId);
|
||||
if (!targetBus) return;
|
||||
|
||||
if (!targetBus.hasBuffer(audioFileId)) {
|
||||
const newToneBuffer = new Tone.ToneAudioBuffer(rawBuffer);
|
||||
targetBus.loadBuffer(audioFileId, newToneBuffer);
|
||||
|
||||
// Remove the buffer from the source bus to free memory
|
||||
const sBus = this.trackAudioPlayerBuses.get(sourceTrackId);
|
||||
if (sBus && sBus !== targetBus) {
|
||||
sBus.removeBuffer(audioFileId);
|
||||
}
|
||||
|
||||
console.log(`Moved audio buffer ${audioFileId} from track ${sourceTrackId} to track ${targetTrackId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change instrument type for a track (replaces setTrackInstrument)
|
||||
*/
|
||||
@@ -263,6 +388,9 @@ export class KGAudioInterface {
|
||||
// Set project BPM and time signature FIRST (this affects timing calculations)
|
||||
Tone.Transport.bpm.value = project.getBpm();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const secondsPerBeat = 60 / project.getBpm();
|
||||
const resumeSafetyOffsetBeats =
|
||||
KGAudioInterface.AUDIO_RESUME_SAFETY_OFFSET_SECONDS / secondsPerBeat;
|
||||
Tone.Transport.timeSignature = [timeSignature.numerator, timeSignature.denominator];
|
||||
|
||||
console.log(`Setting Tone.js BPM to ${project.getBpm()}, actual value: ${Tone.Transport.bpm.value}`);
|
||||
@@ -309,6 +437,7 @@ export class KGAudioInterface {
|
||||
|
||||
console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`);
|
||||
|
||||
// Schedule MIDI track events
|
||||
if (audioBus && track.getType() === 'MIDI') {
|
||||
track.getRegions().forEach(region => {
|
||||
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
|
||||
@@ -362,6 +491,108 @@ export class KGAudioInterface {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule audio/wav track events
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (playerBus && track.getType() === 'Wave') {
|
||||
track.getRegions().forEach(region => {
|
||||
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||
const audioRegion = region as unknown as KGAudioRegion;
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
const regionEndBeat = regionStartBeat + region.getLength();
|
||||
|
||||
// Skip regions outside loop range when looping
|
||||
if (regionStartBeat >= scheduleEndBeat || regionEndBeat <= scheduleStartBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip regions that start before playback start position
|
||||
if (regionStartBeat < startPosition) {
|
||||
// Region starts before playhead — calculate offset into the audio file
|
||||
const offsetBeats = startPosition - regionStartBeat;
|
||||
const offsetSeconds = offsetBeats * secondsPerBeat;
|
||||
const remainingBeats = regionEndBeat - startPosition;
|
||||
const remainingSeconds = remainingBeats * secondsPerBeat;
|
||||
const audioFileId = audioRegion.getAudioFileId();
|
||||
|
||||
// Cap duration at loop boundary to prevent overlap on loop re-trigger
|
||||
let effectiveRemainingSeconds = remainingSeconds;
|
||||
if (isLooping) {
|
||||
const maxDurationBeats = scheduleEndBeat - startPosition;
|
||||
const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
|
||||
effectiveRemainingSeconds = Math.min(remainingSeconds, maxDurationSeconds);
|
||||
}
|
||||
|
||||
if (effectiveRemainingSeconds > 0 && playerBus.hasBuffer(audioFileId)) {
|
||||
// Resume slightly after the current transport boundary and
|
||||
// compensate the source offset/duration. Scheduling exactly
|
||||
// at the playhead here can intermittently miss the callback,
|
||||
// which leaves the playhead moving but the clip silent.
|
||||
const safeResumeBeat = Math.min(
|
||||
startPosition + resumeSafetyOffsetBeats,
|
||||
regionEndBeat
|
||||
);
|
||||
const extraOffsetSeconds = (safeResumeBeat - startPosition) * secondsPerBeat;
|
||||
const adjustedOffsetSeconds = offsetSeconds + extraOffsetSeconds;
|
||||
const adjustedRemainingSeconds = Math.max(
|
||||
0,
|
||||
effectiveRemainingSeconds - extraOffsetSeconds
|
||||
);
|
||||
|
||||
if (adjustedRemainingSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const regionStartTime = this.beatsToToneTime(safeResumeBeat);
|
||||
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
playerBus.schedulePlayback(
|
||||
time + playbackDelay,
|
||||
audioFileId,
|
||||
adjustedOffsetSeconds,
|
||||
adjustedRemainingSeconds
|
||||
);
|
||||
}
|
||||
}, regionStartTime);
|
||||
this.scheduledEvents.add(eventId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const audioFileId = audioRegion.getAudioFileId();
|
||||
let effectiveDurationSeconds = audioRegion.getAudioDurationSeconds();
|
||||
|
||||
if (!playerBus.hasBuffer(audioFileId)) {
|
||||
console.warn(`No audio buffer loaded for ${audioFileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap duration at loop boundary to prevent overlap on loop re-trigger
|
||||
if (isLooping) {
|
||||
const maxDurationBeats = scheduleEndBeat - regionStartBeat;
|
||||
const maxDurationSeconds = maxDurationBeats * secondsPerBeat;
|
||||
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
|
||||
}
|
||||
|
||||
const regionStartTime = this.beatsToToneTime(regionStartBeat);
|
||||
|
||||
console.log(
|
||||
`Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, duration: ${effectiveDurationSeconds}s`
|
||||
);
|
||||
|
||||
const eventId = Tone.Transport.schedule((time) => {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
if (playerBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||
playerBus.schedulePlayback(time + playbackDelay, audioFileId, 0, effectiveDurationSeconds);
|
||||
}
|
||||
}, regionStartTime);
|
||||
|
||||
this.scheduledEvents.add(eventId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Prepared playback from position ${startPosition} with ${this.scheduledEvents.size} events`);
|
||||
@@ -405,6 +636,11 @@ export class KGAudioInterface {
|
||||
audioBus.releaseAll();
|
||||
});
|
||||
|
||||
// Stop all audio player buses
|
||||
this.trackAudioPlayerBuses.forEach(playerBus => {
|
||||
playerBus.stopAll();
|
||||
});
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
console.log('Audio playback stopped');
|
||||
@@ -560,10 +796,14 @@ export class KGAudioInterface {
|
||||
public setTrackVolume(trackId: string, volume: number): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setVolume(volume);
|
||||
console.log(`Set track ${trackId} volume to ${volume}`);
|
||||
} else {
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setVolume(volume);
|
||||
}
|
||||
if (!audioBus && !playerBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -577,14 +817,18 @@ export class KGAudioInterface {
|
||||
public setTrackMute(trackId: string, muted: boolean): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setMuted(muted);
|
||||
console.log(`Set track ${trackId} mute to ${muted}`);
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} else {
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setMuted(muted);
|
||||
}
|
||||
if (!audioBus && !playerBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} mute:`, error);
|
||||
}
|
||||
@@ -596,14 +840,18 @@ export class KGAudioInterface {
|
||||
public setTrackSolo(trackId: string, solo: boolean): void {
|
||||
try {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
if (audioBus) {
|
||||
audioBus.setSolo(solo);
|
||||
console.log(`Set track ${trackId} solo to ${solo}`);
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} else {
|
||||
}
|
||||
if (playerBus) {
|
||||
playerBus.setSolo(solo);
|
||||
}
|
||||
if (!audioBus && !playerBus) {
|
||||
console.warn(`No audio bus found for track ${trackId}`);
|
||||
}
|
||||
// Recompute effective volumes across all buses (solo logic)
|
||||
this.updateAllEffectiveVolumes();
|
||||
} catch (error) {
|
||||
console.error(`Error setting track ${trackId} solo:`, error);
|
||||
}
|
||||
@@ -646,17 +894,20 @@ export class KGAudioInterface {
|
||||
|
||||
public getTrackVolume(trackId: string): number {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
return audioBus?.getVolume() ?? playerBus?.getVolume() ?? AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
||||
}
|
||||
|
||||
public getTrackMuted(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getMuted() ?? false;
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
return audioBus?.getMuted() ?? playerBus?.getMuted() ?? false;
|
||||
}
|
||||
|
||||
public getTrackSolo(trackId: string): boolean {
|
||||
const audioBus = this.trackAudioBuses.get(trackId);
|
||||
return audioBus?.getSolo() ?? false;
|
||||
const playerBus = this.trackAudioPlayerBuses.get(trackId);
|
||||
return audioBus?.getSolo() ?? playerBus?.getSolo() ?? false;
|
||||
}
|
||||
|
||||
public getMasterVolume(): number {
|
||||
@@ -692,7 +943,8 @@ export class KGAudioInterface {
|
||||
* Check if any tracks are currently soloed
|
||||
*/
|
||||
private hasSoloedTracks(): boolean {
|
||||
return Array.from(this.trackAudioBuses.values()).some(audioBus => audioBus.getSolo());
|
||||
return Array.from(this.trackAudioBuses.values()).some(bus => bus.getSolo()) ||
|
||||
Array.from(this.trackAudioPlayerBuses.values()).some(bus => bus.getSolo());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -702,6 +954,7 @@ export class KGAudioInterface {
|
||||
try {
|
||||
const hasSoloedTracks = this.hasSoloedTracks();
|
||||
this.trackAudioBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
||||
this.trackAudioPlayerBuses.forEach(bus => bus.applyEffectiveVolume(hasSoloedTracks));
|
||||
} catch (error) {
|
||||
console.error('Error updating effective volumes:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import * as Tone from 'tone';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
/**
|
||||
* KGAudioPlayerBus - Represents an audio playback bus for a track.
|
||||
* Parallel to KGAudioBus but wraps a Tone.Gain node + a buffer cache
|
||||
* instead of a Tone.Sampler. Supports multiple audio files per track
|
||||
* via ToneBufferSource instances created on-demand during playback.
|
||||
*/
|
||||
export class KGAudioPlayerBus {
|
||||
// Gain node for volume/mute routing
|
||||
private gainNode: Tone.Gain;
|
||||
|
||||
// Cached audio buffers keyed by audioFileId
|
||||
private audioBuffers: Map<string, Tone.ToneAudioBuffer> = new Map();
|
||||
|
||||
// Active buffer sources for cleanup on stop
|
||||
private activeSources: Tone.ToneBufferSource[] = [];
|
||||
|
||||
// Audio properties
|
||||
private volume: number;
|
||||
private muted: boolean;
|
||||
private solo: boolean;
|
||||
|
||||
/**
|
||||
* Private constructor - use KGAudioPlayerBus.create() instead
|
||||
*/
|
||||
private constructor(
|
||||
gainNode: Tone.Gain,
|
||||
volume: number,
|
||||
muted: boolean,
|
||||
solo: boolean
|
||||
) {
|
||||
this.gainNode = gainNode;
|
||||
this.volume = volume;
|
||||
this.muted = muted;
|
||||
this.solo = solo;
|
||||
|
||||
this.updateGainVolume();
|
||||
|
||||
console.log(`KGAudioPlayerBus created - volume: ${volume}, muted: ${muted}, solo: ${solo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new KGAudioPlayerBus instance (async factory method)
|
||||
*/
|
||||
public static async create(
|
||||
volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME,
|
||||
muted: boolean = false,
|
||||
solo: boolean = false
|
||||
): Promise<KGAudioPlayerBus> {
|
||||
try {
|
||||
const gainNode = new Tone.Gain(1);
|
||||
const bus = new KGAudioPlayerBus(gainNode, volume, muted, solo);
|
||||
console.log('KGAudioPlayerBus created successfully');
|
||||
return bus;
|
||||
} catch (error) {
|
||||
console.error('Failed to create KGAudioPlayerBus:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== BUFFER MANAGEMENT =====
|
||||
|
||||
/**
|
||||
* Load/cache an audio buffer for a given audioFileId
|
||||
*/
|
||||
public loadBuffer(audioFileId: string, buffer: Tone.ToneAudioBuffer): void {
|
||||
this.audioBuffers.set(audioFileId, buffer);
|
||||
console.log(`Loaded audio buffer for ${audioFileId}, duration: ${buffer.duration}s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a buffer is cached for the given audioFileId
|
||||
*/
|
||||
public hasBuffer(audioFileId: string): boolean {
|
||||
return this.audioBuffers.has(audioFileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and dispose a cached buffer
|
||||
*/
|
||||
public removeBuffer(audioFileId: string): void {
|
||||
const buffer = this.audioBuffers.get(audioFileId);
|
||||
if (buffer) {
|
||||
buffer.dispose();
|
||||
this.audioBuffers.delete(audioFileId);
|
||||
console.log(`Removed audio buffer for ${audioFileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw AudioBuffer for waveform rendering
|
||||
*/
|
||||
public getAudioBuffer(audioFileId: string): AudioBuffer | undefined {
|
||||
const toneBuffer = this.audioBuffers.get(audioFileId);
|
||||
return toneBuffer?.get() as AudioBuffer | undefined;
|
||||
}
|
||||
|
||||
// ===== PLAYBACK =====
|
||||
|
||||
/**
|
||||
* Schedule playback of an audio buffer at a specific time.
|
||||
* Creates a new ToneBufferSource each call (stateless, safe for loop re-triggering).
|
||||
*/
|
||||
public schedulePlayback(
|
||||
time: number,
|
||||
audioFileId: string,
|
||||
offset: number = 0,
|
||||
duration?: number
|
||||
): void {
|
||||
const buffer = this.audioBuffers.get(audioFileId);
|
||||
if (!buffer) {
|
||||
console.error(`No audio buffer found for ${audioFileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = new Tone.ToneBufferSource(buffer);
|
||||
source.connect(this.gainNode);
|
||||
|
||||
// Ensure start time is not in the past — ToneBufferSource silently fails
|
||||
// if the time has already passed, unlike Sampler which handles it gracefully.
|
||||
const safeTime = Math.max(time, Tone.now());
|
||||
|
||||
if (duration !== undefined) {
|
||||
source.start(safeTime, offset, duration);
|
||||
} else {
|
||||
source.start(safeTime, offset);
|
||||
}
|
||||
|
||||
this.activeSources.push(source);
|
||||
|
||||
// Clean up source reference after it finishes
|
||||
source.onended = () => {
|
||||
const idx = this.activeSources.indexOf(source);
|
||||
if (idx !== -1) {
|
||||
this.activeSources.splice(idx, 1);
|
||||
}
|
||||
source.dispose();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error scheduling playback for ${audioFileId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active audio sources
|
||||
*/
|
||||
public stopAll(): void {
|
||||
try {
|
||||
for (const source of this.activeSources) {
|
||||
try {
|
||||
source.stop();
|
||||
} catch {
|
||||
// Source may have already stopped
|
||||
}
|
||||
// Don't dispose here — the onended callback handles disposal.
|
||||
// Double-dispose corrupts Tone.js internal state.
|
||||
}
|
||||
this.activeSources = [];
|
||||
} catch (error) {
|
||||
console.error('Error stopping all audio sources:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AUDIO PROPERTIES =====
|
||||
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = volume;
|
||||
this.updateGainVolume();
|
||||
console.log(`Set audio player bus volume to ${volume}`);
|
||||
}
|
||||
|
||||
public getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
public setMuted(muted: boolean): void {
|
||||
this.muted = muted;
|
||||
this.updateGainVolume();
|
||||
console.log(`Set audio player bus muted to ${muted}`);
|
||||
}
|
||||
|
||||
public getMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
public setSolo(solo: boolean): void {
|
||||
this.solo = solo;
|
||||
console.log(`Set audio player bus solo to ${solo}`);
|
||||
}
|
||||
|
||||
public getSolo(): boolean {
|
||||
return this.solo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply effective volume considering both mute and solo context
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
let effectiveVolume = this.volume;
|
||||
if (this.muted) {
|
||||
effectiveVolume = 0;
|
||||
} else if (hasSoloedTracks && !this.solo) {
|
||||
effectiveVolume = 0;
|
||||
}
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.gainNode.gain.value = Math.pow(10, volumeDb / 20);
|
||||
} catch (error) {
|
||||
console.error('Error applying effective volume for audio player bus:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this audio bus should play considering solo logic
|
||||
*/
|
||||
public shouldPlayWithSolo(hasSoloedTracks: boolean): boolean {
|
||||
if (this.muted) {
|
||||
return false;
|
||||
}
|
||||
if (hasSoloedTracks) {
|
||||
return this.solo;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== AUDIO ROUTING =====
|
||||
|
||||
public connect(destination: Tone.InputNode): void {
|
||||
try {
|
||||
this.gainNode.connect(destination);
|
||||
console.log('Connected audio player bus to destination');
|
||||
} catch (error) {
|
||||
console.error('Error connecting audio player bus:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
try {
|
||||
this.gainNode.disconnect();
|
||||
console.log('Disconnected audio player bus');
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting audio player bus:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RESOURCE MANAGEMENT =====
|
||||
|
||||
public dispose(): void {
|
||||
try {
|
||||
this.stopAll();
|
||||
for (const buffer of this.audioBuffers.values()) {
|
||||
buffer.dispose();
|
||||
}
|
||||
this.audioBuffers.clear();
|
||||
this.gainNode.dispose();
|
||||
console.log('Disposed KGAudioPlayerBus');
|
||||
} catch (error) {
|
||||
console.error('Error disposing KGAudioPlayerBus:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PRIVATE UTILITY =====
|
||||
|
||||
private updateGainVolume(): void {
|
||||
try {
|
||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
||||
// Convert linear volume to gain value
|
||||
this.gainNode.gain.value = effectiveVolume;
|
||||
} catch (error) {
|
||||
console.error('Error updating gain volume:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DEBUGGING =====
|
||||
|
||||
public getState(): {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
solo: boolean;
|
||||
bufferCount: number;
|
||||
activeSourceCount: number;
|
||||
} {
|
||||
return {
|
||||
volume: this.volume,
|
||||
muted: this.muted,
|
||||
solo: this.solo,
|
||||
bufferCount: this.audioBuffers.size,
|
||||
activeSourceCount: this.activeSources.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { KGCommandHistory } from './KGCommandHistory';
|
||||
|
||||
// Track commands
|
||||
export { AddTrackCommand } from './track/AddTrackCommand';
|
||||
export { AddAudioTrackCommand } from './track/AddAudioTrackCommand';
|
||||
export { RemoveTrackCommand } from './track/RemoveTrackCommand';
|
||||
export { ReorderTracksCommand } from './track/ReorderTracksCommand';
|
||||
export { UpdateTrackCommand, type TrackUpdateProperties } from './track/UpdateTrackCommand';
|
||||
@@ -19,6 +20,7 @@ export { ResizeRegionCommand } from './region/ResizeRegionCommand';
|
||||
export { MoveRegionCommand } from './region/MoveRegionCommand';
|
||||
export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
||||
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
||||
export { ImportAudioCommand } from './region/ImportAudioCommand';
|
||||
|
||||
// Note commands
|
||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||
|
||||
/**
|
||||
* Command to import an audio file into an audio track as a region.
|
||||
* Async work (file decode, OPFS storage, buffer loading) must be done
|
||||
* before execute() is called — this command is synchronous.
|
||||
*/
|
||||
export class ImportAudioCommand extends KGCommand {
|
||||
private trackId: number;
|
||||
private trackIndex: number;
|
||||
private audioFileId: string;
|
||||
private audioFileName: string;
|
||||
private audioDurationSeconds: number;
|
||||
private insertBeat: number;
|
||||
private durationInBeats: number;
|
||||
private previousMaxBars: number;
|
||||
private newMaxBars: number;
|
||||
private regionId: string;
|
||||
private createdRegion: KGAudioRegion | null = null;
|
||||
|
||||
constructor(
|
||||
trackId: number,
|
||||
trackIndex: number,
|
||||
audioFileId: string,
|
||||
audioFileName: string,
|
||||
audioDurationSeconds: number,
|
||||
insertBeat: number,
|
||||
durationInBeats: number,
|
||||
previousMaxBars: number,
|
||||
newMaxBars: number
|
||||
) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.trackIndex = trackIndex;
|
||||
this.audioFileId = audioFileId;
|
||||
this.audioFileName = audioFileName;
|
||||
this.audioDurationSeconds = audioDurationSeconds;
|
||||
this.insertBeat = insertBeat;
|
||||
this.durationInBeats = durationInBeats;
|
||||
this.previousMaxBars = previousMaxBars;
|
||||
this.newMaxBars = newMaxBars;
|
||||
this.regionId = `audio_region_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
|
||||
// Create the audio region
|
||||
this.createdRegion = new KGAudioRegion(
|
||||
this.regionId,
|
||||
this.trackId.toString(),
|
||||
this.trackIndex,
|
||||
this.audioFileName,
|
||||
this.insertBeat,
|
||||
this.durationInBeats,
|
||||
this.audioFileId,
|
||||
this.audioFileName,
|
||||
this.audioDurationSeconds
|
||||
);
|
||||
|
||||
// Add region to the track
|
||||
const track = currentProject.getTracks().find(t => t.getId() === this.trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track ${this.trackId} not found`);
|
||||
}
|
||||
track.addRegion(this.createdRegion);
|
||||
|
||||
// Expand maxBars if needed
|
||||
if (this.newMaxBars > this.previousMaxBars) {
|
||||
currentProject.setMaxBars(this.newMaxBars);
|
||||
}
|
||||
|
||||
console.log(`Imported audio "${this.audioFileName}" at beat ${this.insertBeat}, duration: ${this.durationInBeats} beats`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
|
||||
// Remove the region from the track
|
||||
const track = currentProject.getTracks().find(t => t.getId() === this.trackId);
|
||||
if (track) {
|
||||
track.removeRegion(this.regionId);
|
||||
}
|
||||
|
||||
// Revert maxBars if we expanded it
|
||||
if (this.newMaxBars > this.previousMaxBars) {
|
||||
currentProject.setMaxBars(this.previousMaxBars);
|
||||
}
|
||||
|
||||
console.log(`Undid audio import "${this.audioFileName}"`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Import audio "${this.audioFileName}"`;
|
||||
}
|
||||
|
||||
public getCreatedRegion(): KGAudioRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGAudioTrack } from '../../track/KGAudioTrack';
|
||||
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
|
||||
import { generateNewTrackName } from '../../../util/miscUtil';
|
||||
|
||||
/**
|
||||
* Command to add a new audio track to the project.
|
||||
* Handles both the core model update and audio player bus setup.
|
||||
*/
|
||||
export class AddAudioTrackCommand extends KGCommand {
|
||||
private trackId: number;
|
||||
private trackName: string;
|
||||
private trackIndex: number;
|
||||
private createdTrack: KGAudioTrack | null = null;
|
||||
|
||||
constructor(trackId?: number, trackName?: string) {
|
||||
super();
|
||||
|
||||
if (trackId === undefined) {
|
||||
const currentProject = KGCore.instance().getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
this.trackId = tracks.length > 0
|
||||
? Math.max(...tracks.map(track => track.getId())) + 1
|
||||
: 1;
|
||||
} else {
|
||||
this.trackId = trackId;
|
||||
}
|
||||
|
||||
this.trackName = trackName || generateNewTrackName();
|
||||
this.trackIndex = 0;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
this.trackIndex = tracks.length;
|
||||
|
||||
this.createdTrack = new KGAudioTrack(this.trackName, this.trackId);
|
||||
this.createdTrack.setTrackIndex(this.trackIndex);
|
||||
|
||||
const updatedTracks = [...tracks, this.createdTrack];
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
// Create audio player bus for the new track
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
|
||||
|
||||
console.log(`Added audio track ${this.trackId}`);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdTrack) {
|
||||
throw new Error('Cannot undo: no track was created');
|
||||
}
|
||||
|
||||
const core = KGCore.instance();
|
||||
const currentProject = core.getCurrentProject();
|
||||
const tracks = currentProject.getTracks();
|
||||
|
||||
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
|
||||
|
||||
console.log(`Removed audio track ${this.trackId}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Add audio track "${this.trackName}"`;
|
||||
}
|
||||
|
||||
public getTrackId(): number {
|
||||
return this.trackId;
|
||||
}
|
||||
|
||||
public getCreatedTrack(): KGAudioTrack | null {
|
||||
return this.createdTrack;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,10 @@ export class RemoveTrackCommand extends KGCommand {
|
||||
this.originalInstrument = (trackToRemove as KGMidiTrack).getInstrument();
|
||||
}
|
||||
|
||||
// Remove audio synth for the track
|
||||
// Remove audio bus for the track (handles both MIDI synth and audio player bus)
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.removeTrackSynth(this.trackId.toString());
|
||||
audioInterface.removeTrackAudioPlayerBus(this.trackId.toString());
|
||||
|
||||
// Remove the track from the core model
|
||||
const updatedTracks = tracks.filter(track => track.getId() !== this.trackId);
|
||||
@@ -85,11 +86,15 @@ export class RemoveTrackCommand extends KGCommand {
|
||||
// Update the core model
|
||||
currentProject.setTracks(updatedTracks);
|
||||
|
||||
// Recreate audio synth for the track
|
||||
// Recreate audio bus for the track
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (this.removedTrack.getCurrentType() === 'KGAudioTrack') {
|
||||
audioInterface.createTrackAudioPlayerBus(this.trackId.toString());
|
||||
} else {
|
||||
audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument);
|
||||
}
|
||||
|
||||
console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`);
|
||||
console.log(`Restored track ${this.trackId}`);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
/**
|
||||
* KGAudioFileStorage — Utility for storing and loading audio files
|
||||
* in the OPFS media/ directory alongside project data.
|
||||
*/
|
||||
export class KGAudioFileStorage {
|
||||
/**
|
||||
* Store an audio file in the project's media/ directory.
|
||||
*/
|
||||
public static async storeAudioFile(
|
||||
projectName: string,
|
||||
fileId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
const mediaDir = await KGAudioFileStorage.getMediaDir(projectName);
|
||||
const fileHandle = await mediaDir.getFileHandle(fileId, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(await file.arrayBuffer());
|
||||
await writable.close();
|
||||
console.log(`Stored audio file ${fileId} (${file.size} bytes) for project "${projectName}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an audio file as ArrayBuffer from the project's media/ directory.
|
||||
*/
|
||||
public static async loadAudioFile(
|
||||
projectName: string,
|
||||
fileId: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const mediaDir = await KGAudioFileStorage.getMediaDir(projectName);
|
||||
const fileHandle = await mediaDir.getFileHandle(fileId);
|
||||
const file = await fileHandle.getFile();
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an audio file from the project's media/ directory.
|
||||
*/
|
||||
public static async deleteAudioFile(
|
||||
projectName: string,
|
||||
fileId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mediaDir = await KGAudioFileStorage.getMediaDir(projectName);
|
||||
await mediaDir.removeEntry(fileId);
|
||||
console.log(`Deleted audio file ${fileId} from project "${projectName}"`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete audio file ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique audio file ID preserving the original file extension.
|
||||
*/
|
||||
public static generateAudioFileId(originalFileName: string): string {
|
||||
const ext = originalFileName.split('.').pop()?.toLowerCase() || 'wav';
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 10);
|
||||
return `audio_${timestamp}_${random}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the media directory handle for a project.
|
||||
*/
|
||||
private static async getMediaDir(projectName: string): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const projectsDir = await root.getDirectoryHandle(OPFS_CONSTANTS.ROOT_DIR);
|
||||
const projectDir = await projectsDir.getDirectoryHandle(projectName);
|
||||
return projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { KGProject } from '../KGProject';
|
||||
import { upgradeToV1 } from './upgradeToV1';
|
||||
import { upgradeToV2 } from './upgradeToV2';
|
||||
import { upgradeToV3 } from './upgradeToV3';
|
||||
import { upgradeToV4 } from './upgradeToV4';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
@@ -33,6 +34,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
workingProject = upgradeToV3(workingProject);
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
workingProject = upgradeToV4(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
|
||||
/**
|
||||
* Upgrade a project from structure version 3 to 4.
|
||||
* Adds audio track support. No data migration needed — existing projects have no audio tracks.
|
||||
*/
|
||||
export function upgradeToV4(project: KGProject): KGProject {
|
||||
try {
|
||||
// No data migration needed for audio track support.
|
||||
// The new KGAudioTrack and KGAudioRegion subtypes are registered in
|
||||
// the class-transformer discriminators and will be deserialized automatically.
|
||||
} finally {
|
||||
project.setProjectStructureVersion(4);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { WithDefault } from '../../types/projectTypes';
|
||||
|
||||
/**
|
||||
* KGAudioRegion - Class representing an audio region in the DAW
|
||||
* Contains a reference to an audio file stored in OPFS and inherits position/length from KGRegion
|
||||
*/
|
||||
export class KGAudioRegion extends KGRegion {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGAudioRegion';
|
||||
|
||||
@Expose()
|
||||
@WithDefault('')
|
||||
protected audioFileId: string = '';
|
||||
|
||||
@Expose()
|
||||
@WithDefault('')
|
||||
protected audioFileName: string = '';
|
||||
|
||||
@Expose()
|
||||
@WithDefault(0)
|
||||
protected audioDurationSeconds: number = 0;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
name: string,
|
||||
startFromBeat: number = 0,
|
||||
length: number = 0,
|
||||
audioFileId: string = '',
|
||||
audioFileName: string = '',
|
||||
audioDurationSeconds: number = 0
|
||||
) {
|
||||
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||
this.__type = 'KGAudioRegion';
|
||||
this.audioFileId = audioFileId;
|
||||
this.audioFileName = audioFileName;
|
||||
this.audioDurationSeconds = audioDurationSeconds;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public getAudioFileId(): string {
|
||||
return this.audioFileId;
|
||||
}
|
||||
|
||||
public getAudioFileName(): string {
|
||||
return this.audioFileName;
|
||||
}
|
||||
|
||||
public getAudioDurationSeconds(): number {
|
||||
return this.audioDurationSeconds;
|
||||
}
|
||||
|
||||
// Setters
|
||||
public setAudioFileId(audioFileId: string): void {
|
||||
this.audioFileId = audioFileId;
|
||||
}
|
||||
|
||||
public setAudioFileName(audioFileName: string): void {
|
||||
this.audioFileName = audioFileName;
|
||||
}
|
||||
|
||||
public setAudioDurationSeconds(audioDurationSeconds: number): void {
|
||||
this.audioDurationSeconds = audioDurationSeconds;
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGAudioRegion';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGTrack, TrackType } from './KGTrack';
|
||||
import { KGRegion } from '../region/KGRegion';
|
||||
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
export class KGAudioTrack extends KGTrack {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGAudioTrack';
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGRegion, {
|
||||
discriminator: {
|
||||
property: '__type',
|
||||
subTypes: [
|
||||
{ value: KGRegion, name: 'KGRegion' },
|
||||
{ value: KGAudioRegion, name: 'KGAudioRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
protected override regions: KGAudioRegion[] = [];
|
||||
|
||||
constructor(name: string = 'Untitled Audio Track', id: number = 0, volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) {
|
||||
super(name, id, TrackType.Wave);
|
||||
this.__type = 'KGAudioTrack';
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
// Override parent setRegions to enforce KGAudioRegion type
|
||||
public override setRegions(regions: KGAudioRegion[]): void {
|
||||
this.regions = regions;
|
||||
}
|
||||
|
||||
// Override getCurrentType to return specific subclass type
|
||||
public override getCurrentType(): string {
|
||||
return 'KGAudioTrack';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGRegion } from '../region/KGRegion';
|
||||
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { WithDefault } from '../../types/projectTypes';
|
||||
|
||||
@@ -42,6 +43,7 @@ export class KGTrack {
|
||||
subTypes: [
|
||||
{ value: KGRegion, name: 'KGRegion' },
|
||||
{ value: KGMidiRegion, name: 'KGMidiRegion' },
|
||||
{ value: KGAudioRegion, name: 'KGAudioRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { DEBUG_MODE } from '../constants';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { RegionUI } from '../components/interfaces';
|
||||
@@ -47,8 +48,8 @@ export const useRegionOperations = ({
|
||||
// Get all selected regions from KGCore
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegions = selectedItems.filter(item =>
|
||||
item instanceof KGMidiRegion
|
||||
) as KGMidiRegion[];
|
||||
item instanceof KGMidiRegion || item instanceof KGAudioRegion
|
||||
);
|
||||
|
||||
if (selectedRegions.length === 0) {
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
|
||||
+167
-11
@@ -9,10 +9,14 @@ import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import { KGRegion } from '../core/region/KGRegion';
|
||||
import { AddTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand } from '../core/commands';
|
||||
import { AddTrackCommand, AddAudioTrackCommand, RemoveTrackCommand, ReorderTracksCommand, UpdateTrackCommand, type TrackUpdateProperties, PasteRegionsCommand, PasteNotesCommand, ChangeProjectPropertyCommand, ImportAudioCommand } from '../core/commands';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrader';
|
||||
import { toggleLoop } from '../util/loopUtil';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
/**
|
||||
* Update CSS custom property for time signature numerator
|
||||
@@ -63,6 +67,10 @@ interface ProjectState {
|
||||
showInstrumentSelection: boolean;
|
||||
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
|
||||
|
||||
// Audio import modal state
|
||||
showAudioImportModal: boolean;
|
||||
audioImportTargetTrackId: string | null;
|
||||
|
||||
// Settings state
|
||||
showSettings: boolean;
|
||||
|
||||
@@ -75,6 +83,10 @@ interface ProjectState {
|
||||
// Actions
|
||||
setProjectName: (name: string) => void;
|
||||
addTrack: () => Promise<void>;
|
||||
addAudioTrack: () => Promise<void>;
|
||||
importAudioToTrack: (trackId: string, file: File) => Promise<void>;
|
||||
openAudioImportModal: (trackId: string) => void;
|
||||
closeAudioImportModal: () => void;
|
||||
removeTrack: (id: number) => Promise<void>;
|
||||
updateTrack: (track: KGTrack) => Promise<void>;
|
||||
updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise<void>;
|
||||
@@ -250,6 +262,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
// Initial Instrument Selection panel state
|
||||
showInstrumentSelection: initialShowInstrumentSelection,
|
||||
|
||||
// Initial audio import modal state
|
||||
showAudioImportModal: false,
|
||||
audioImportTargetTrackId: null,
|
||||
|
||||
// Initial Settings state
|
||||
showSettings: false,
|
||||
|
||||
@@ -300,6 +316,117 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
addAudioTrack: async () => {
|
||||
try {
|
||||
const command = new AddAudioTrackCommand();
|
||||
KGCore.instance().executeCommand(command);
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
set({ tracks: [...project.getTracks()] as KGTrack[] });
|
||||
|
||||
const newTrackId = command.getTrackId().toString();
|
||||
set({
|
||||
selectedTrackId: newTrackId,
|
||||
showAudioImportModal: true,
|
||||
audioImportTargetTrackId: newTrackId,
|
||||
});
|
||||
|
||||
console.log(`Added audio track ${command.getTrackId()}`);
|
||||
} catch (error) {
|
||||
console.error('Error adding audio track:', error);
|
||||
get().setStatus('Failed to add audio track');
|
||||
}
|
||||
},
|
||||
|
||||
importAudioToTrack: async (trackId: string, file: File) => {
|
||||
try {
|
||||
get().setStatus(`Importing "${file.name}"...`);
|
||||
|
||||
// Decode the audio file to get duration
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
toneBuffer.onload = () => resolve();
|
||||
// Set buffer from array buffer
|
||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||
audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0), // slice to avoid detached buffer
|
||||
(decoded) => {
|
||||
toneBuffer.set(decoded);
|
||||
resolve();
|
||||
},
|
||||
(err) => reject(err)
|
||||
);
|
||||
});
|
||||
|
||||
const audioDurationSeconds = toneBuffer.duration;
|
||||
const { bpm, timeSignature, playheadPosition, maxBars } = get();
|
||||
|
||||
// Calculate duration in beats
|
||||
const durationInBeats = audioDurationSeconds * (bpm / 60);
|
||||
|
||||
// Calculate if we need to expand maxBars
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const endBeat = playheadPosition + durationInBeats;
|
||||
const requiredBars = Math.ceil(endBeat / beatsPerBar);
|
||||
const newMaxBars = Math.max(maxBars, requiredBars);
|
||||
|
||||
// Store audio file in OPFS
|
||||
const projectName = get().projectName;
|
||||
const audioFileId = KGAudioFileStorage.generateAudioFileId(file.name);
|
||||
await KGAudioFileStorage.storeAudioFile(projectName, audioFileId, file);
|
||||
|
||||
// Load buffer into the audio player bus
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
|
||||
|
||||
// Find the track to get trackIndex
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||
if (!track) {
|
||||
throw new Error(`Track ${trackId} not found`);
|
||||
}
|
||||
|
||||
// Execute the import command
|
||||
const command = new ImportAudioCommand(
|
||||
track.getId(),
|
||||
track.getTrackIndex(),
|
||||
audioFileId,
|
||||
file.name,
|
||||
audioDurationSeconds,
|
||||
playheadPosition,
|
||||
durationInBeats,
|
||||
maxBars,
|
||||
newMaxBars
|
||||
);
|
||||
KGCore.instance().executeCommand(command);
|
||||
|
||||
// Update store state
|
||||
const updatedState: Partial<ProjectState> = {
|
||||
tracks: [...project.getTracks()] as KGTrack[],
|
||||
};
|
||||
if (newMaxBars > maxBars) {
|
||||
updatedState.maxBars = newMaxBars;
|
||||
updateMaxBarsCSS(newMaxBars);
|
||||
}
|
||||
set(updatedState);
|
||||
|
||||
get().setStatus(`Imported "${file.name}" successfully`);
|
||||
console.log(`Imported audio "${file.name}" to track ${trackId}`);
|
||||
} catch (error) {
|
||||
console.error('Error importing audio:', error);
|
||||
get().setStatus(`Failed to import audio: ${error}`);
|
||||
}
|
||||
},
|
||||
|
||||
openAudioImportModal: (trackId: string) => {
|
||||
set({ showAudioImportModal: true, audioImportTargetTrackId: trackId });
|
||||
},
|
||||
|
||||
closeAudioImportModal: () => {
|
||||
set({ showAudioImportModal: false, audioImportTargetTrackId: null });
|
||||
},
|
||||
|
||||
removeTrack: async (id: number) => {
|
||||
try {
|
||||
// Get the current tracks and find the index of the track being deleted
|
||||
@@ -488,23 +615,52 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
// Setup audio synths for all tracks
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
// Clear any existing synths first
|
||||
tracks.forEach(track => {
|
||||
audioInterface.removeTrackSynth(track.getId().toString());
|
||||
});
|
||||
|
||||
// Create synths for all tracks (with their stored volumes)
|
||||
// Clear any existing synths/buses first
|
||||
tracks.forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
// Get instrument from track model if it's a MIDI track
|
||||
let instrument: InstrumentType = 'acoustic_grand_piano'; // Default fallback
|
||||
audioInterface.removeTrackSynth(trackId);
|
||||
audioInterface.removeTrackAudioPlayerBus(trackId);
|
||||
});
|
||||
|
||||
// Create synths/buses for all tracks (with their stored volumes)
|
||||
const projectName = projectToLoad.getName();
|
||||
for (const track of tracks) {
|
||||
const trackId = track.getId().toString();
|
||||
|
||||
if (track.getCurrentType() === 'KGAudioTrack') {
|
||||
// Audio track: create player bus and load audio buffers
|
||||
await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume());
|
||||
|
||||
// Load audio buffers for all regions in this audio track
|
||||
const audioTrack = track as KGAudioTrack;
|
||||
for (const region of audioTrack.getRegions()) {
|
||||
if (region.getCurrentType() === 'KGAudioRegion') {
|
||||
const audioRegion = region as KGAudioRegion;
|
||||
const audioFileId = audioRegion.getAudioFileId();
|
||||
if (audioFileId) {
|
||||
try {
|
||||
const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
|
||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||
const decoded = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||
toneBuffer.set(decoded);
|
||||
audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load audio file ${audioFileId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// MIDI track: create sampler-based audio bus
|
||||
let instrument: InstrumentType = 'acoustic_grand_piano';
|
||||
if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) {
|
||||
instrument = (track as KGMidiTrack).getInstrument();
|
||||
}
|
||||
audioInterface.createTrackSynth(trackId, instrument);
|
||||
// Volume is applied during bus creation; ensure sync if bus already existed
|
||||
audioInterface.setTrackVolume(trackId, track.getVolume());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update CSS variable for time signature numerator
|
||||
updateTimeSignatureCSS(timeSignature);
|
||||
|
||||
Reference in New Issue
Block a user