diff --git a/src/components/MainContent.css b/src/components/MainContent.css index 9dec725..31bc118 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -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 */ diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index f09f55f..c56e09c 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -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 = ({ activeRegionId, setShowPianoRoll, setActiveRegionId, - addTrack + addTrack, + addAudioTrack } = useProjectStore(); // State to store regions @@ -130,7 +132,7 @@ const MainContent: React.FC = ({ // 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 = ({ // 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 = ({
{/* Top-left spacer */}
- + +
{/* Bar numbers at the top */} diff --git a/src/components/track/Region.css b/src/components/track/Region.css index c1c17d5..d9629dc 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -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; diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index 084e021..1526026 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -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 = ({ @@ -45,7 +49,9 @@ const RegionItem: React.FC = ({ 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 = ({ } }; + // 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(''); const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0); @@ -226,15 +284,23 @@ const RegionItem: React.FC = ({ // Set up canvas when component mounts or updates useEffect(() => { - renderNotesOnCanvas(); - }, [midiRegion, timeSignature, id, noteUpdateTrigger]); + if (audioRegion && audioBuffer) { + renderWaveformOnCanvas(); + } else { + renderNotesOnCanvas(); + } + }, [midiRegion, audioRegion, audioBuffer, timeSignature, id, noteUpdateTrigger]); // Re-render canvas when region content size changes useEffect(() => { if (!regionContentRef.current) return; const resizeObserver = new ResizeObserver(() => { - renderNotesOnCanvas(); + if (audioRegion && audioBuffer) { + renderWaveformOnCanvas(); + } else { + renderNotesOnCanvas(); + } }); resizeObserver.observe(regionContentRef.current); @@ -244,7 +310,7 @@ const RegionItem: React.FC = ({ resizeObserver.unobserve(regionContentRef.current); } }; - }, [midiRegion, timeSignature]); + }, [midiRegion, audioRegion, audioBuffer, timeSignature]); // Handle mouse movement to detect edge proximity const handleMouseMove = (e: React.MouseEvent) => { @@ -259,6 +325,13 @@ const RegionItem: React.FC = ({ 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 = ({ return (
= ({ data-is-dragging={isDragging} >
- {name} + {audioRegion ? audioRegion.getAudioFileName() : name}
-
- +
+ {!audioRegion && ( + + )}
diff --git a/src/components/track/Track.css b/src/components/track/Track.css index 67143bc..fe1d7c6 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -26,7 +26,7 @@ font-size: 12px; padding: 0; height: 100%; - width: 100%; + flex: 1; display: flex; align-items: center; justify-content: center; diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index ab29a17..96b8004 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -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,11 +508,22 @@ const TrackGridItem: React.FC = ({ > {/* 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 ( - = ({ 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 = ({ } }} midiRegion={midiRegion} + audioRegion={audioRegion} + audioBuffer={audioBuffer} /> ); })} diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index 50b104e..7045516 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -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 { @@ -64,7 +66,12 @@ const TrackGridPanel: React.FC = ({ // Get the track and its ID 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}`); } @@ -238,7 +245,19 @@ const TrackGridPanel: React.FC = ({ // Get the target track 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,9 +270,22 @@ const TrackGridPanel: React.FC = ({ 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`); - + // Verify the command worked const movedRegion = command.getTargetRegion(); console.log(`Verified region: ${movedRegion ? 'found' : 'not found'}, startBeat=${movedRegion?.getStartFromBeat()}, trackId=${movedRegion?.getTrackId()}`); diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index c9feb2d..b97a603 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -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 = ({ 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 = ({ const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument()); const [showSettingsDropdown, setShowSettingsDropdown] = useState(false); + const [showAudioImportModal, setShowAudioImportModal] = useState(false); const settingsDropdownRef = useRef(null); const suppressDragRef = useRef(false); const [volume, setVolume] = useState(track.getVolume()); @@ -195,6 +199,8 @@ const TrackInfoItem: React.FC = ({ // 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 = ({ 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 = ({
- {String(FLUIDR3_INSTRUMENT_MAP[currentInstrument + {isAudioTrack ? ( +
+ +
+ ) : ( + {String(FLUIDR3_INSTRUMENT_MAP[currentInstrument + )}
= ({
- + {isAudioTrack ? ( + + ) : ( + + )}
+ {isAudioTrack && ( + setShowAudioImportModal(false)} + onFileImport={handleAudioFileImport} + acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']} + title="Import Audio" + description="Drag and drop your audio file here" + /> + )}
); }; -export default TrackInfoItem; \ No newline at end of file +export default TrackInfoItem; \ No newline at end of file diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 233fc2f..787f1c4 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -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' }, ], }, }) diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index b775e98..dfda13a 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -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 = new Map(); + // Audio player buses for audio/wav tracks + private trackAudioPlayerBuses: Map = new Map(); + // Playback state private isPlaying: boolean = false; private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME; @@ -125,6 +137,12 @@ export class KGAudioInterface { audioBus.dispose(); }); this.trackAudioBuses.clear(); + + // Dispose of all audio player buses + this.trackAudioPlayerBuses.forEach(playerBus => { + playerBus.dispose(); + }); + this.trackAudioPlayerBuses.clear(); // Dispose master gain if (this.masterGain) { @@ -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 { + // 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 { + 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,13 +437,14 @@ 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()}`); if (region.getCurrentType() === 'KGMidiRegion') { const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] }; - + // Get notes from region (assuming it has a getNotes method) if (midiRegion.getNotes) { midiRegion.getNotes().forEach((note: KGMidiNote) => { @@ -334,7 +463,7 @@ export class KGAudioInterface { if (noteStartBeat < startPosition) { return; // Skip notes that would have already finished before playback starts } - + // Convert beats to Tone.js time format for scheduling const noteStartTime = this.beatsToToneTime(noteStartBeat); const noteDuration = this.beatsToToneTime(noteDurationBeats); @@ -355,13 +484,115 @@ export class KGAudioInterface { audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity); } }, noteStartTime); - + this.scheduledEvents.add(eventId); }); } } }); } + + // 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`); @@ -404,7 +635,12 @@ export class KGAudioInterface { this.trackAudioBuses.forEach(audioBus => { 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); } @@ -784,4 +1037,4 @@ export class KGAudioInterface { public getLookaheadTime(): number { return Tone.getContext().lookAhead; } -} \ No newline at end of file +} diff --git a/src/core/audio-interface/KGAudioPlayerBus.ts b/src/core/audio-interface/KGAudioPlayerBus.ts new file mode 100644 index 0000000..f4f97ca --- /dev/null +++ b/src/core/audio-interface/KGAudioPlayerBus.ts @@ -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 = 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 { + 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, + }; + } +} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index bb1e8cd..06b73f0 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -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'; diff --git a/src/core/commands/region/ImportAudioCommand.ts b/src/core/commands/region/ImportAudioCommand.ts new file mode 100644 index 0000000..27eb620 --- /dev/null +++ b/src/core/commands/region/ImportAudioCommand.ts @@ -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; + } +} diff --git a/src/core/commands/track/AddAudioTrackCommand.ts b/src/core/commands/track/AddAudioTrackCommand.ts new file mode 100644 index 0000000..ac9ef10 --- /dev/null +++ b/src/core/commands/track/AddAudioTrackCommand.ts @@ -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; + } +} diff --git a/src/core/commands/track/RemoveTrackCommand.ts b/src/core/commands/track/RemoveTrackCommand.ts index c72db00..d4fc159 100644 --- a/src/core/commands/track/RemoveTrackCommand.ts +++ b/src/core/commands/track/RemoveTrackCommand.ts @@ -39,9 +39,10 @@ export class RemoveTrackCommand extends KGCommand { this.originalInstrument = (trackToRemove as KGMidiTrack).getInstrument(); } - // Remove audio synth for the track + // Remove audio bus for the track (handles both MIDI synth and audio player bus) const audioInterface = KGAudioInterface.instance(); audioInterface.removeTrackSynth(this.trackId.toString()); + audioInterface.removeTrackAudioPlayerBus(this.trackId.toString()); // Remove the track from the core model const updatedTracks = tracks.filter(track => track.getId() !== this.trackId); @@ -85,11 +86,15 @@ export class RemoveTrackCommand extends KGCommand { // Update the core model currentProject.setTracks(updatedTracks); - // Recreate audio synth for the track + // Recreate audio bus for the track const audioInterface = KGAudioInterface.instance(); - audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument); - - console.log(`Restored track ${this.trackId} with ${this.originalInstrument} instrument`); + if (this.removedTrack.getCurrentType() === 'KGAudioTrack') { + audioInterface.createTrackAudioPlayerBus(this.trackId.toString()); + } else { + audioInterface.createTrackSynth(this.trackId.toString(), this.originalInstrument); + } + + console.log(`Restored track ${this.trackId}`); } getDescription(): string { diff --git a/src/core/io/KGAudioFileStorage.ts b/src/core/io/KGAudioFileStorage.ts new file mode 100644 index 0000000..aca87f5 --- /dev/null +++ b/src/core/io/KGAudioFileStorage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }); + } +} diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index d0f3bfa..1bf5f6a 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -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}`); diff --git a/src/core/project-upgrader/upgradeToV4.ts b/src/core/project-upgrader/upgradeToV4.ts new file mode 100644 index 0000000..2e8ef31 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV4.ts @@ -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; +} diff --git a/src/core/region/KGAudioRegion.ts b/src/core/region/KGAudioRegion.ts new file mode 100644 index 0000000..d5b49a9 --- /dev/null +++ b/src/core/region/KGAudioRegion.ts @@ -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'; + } +} diff --git a/src/core/track/KGAudioTrack.ts b/src/core/track/KGAudioTrack.ts new file mode 100644 index 0000000..5053995 --- /dev/null +++ b/src/core/track/KGAudioTrack.ts @@ -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'; + } +} diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index 7b811a7..11bbf3d 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -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' }, ], }, }) diff --git a/src/hooks/useRegionOperations.ts b/src/hooks/useRegionOperations.ts index e2e1adc..5bd59e6 100644 --- a/src/hooks/useRegionOperations.ts +++ b/src/hooks/useRegionOperations.ts @@ -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'; @@ -46,9 +47,9 @@ export const useRegionOperations = ({ const deleteSelectedRegions = useCallback(() => { // Get all selected regions from KGCore const selectedItems = core.getSelectedItems(); - const selectedRegions = selectedItems.filter(item => - item instanceof KGMidiRegion - ) as KGMidiRegion[]; + const selectedRegions = selectedItems.filter(item => + item instanceof KGMidiRegion || item instanceof KGAudioRegion + ); if (selectedRegions.length === 0) { if (DEBUG_MODE.MAIN_CONTENT) { diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 78c7a2a..cbb9e27 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -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,9 +67,13 @@ interface ProjectState { showInstrumentSelection: boolean; // instrumentSelectionTrackId removed; panel now follows selectedTrackId + // Audio import modal state + showAudioImportModal: boolean; + audioImportTargetTrackId: string | null; + // Settings state showSettings: boolean; - + // Undo/redo state canUndo: boolean; canRedo: boolean; @@ -75,6 +83,10 @@ interface ProjectState { // Actions setProjectName: (name: string) => void; addTrack: () => Promise; + addAudioTrack: () => Promise; + importAudioToTrack: (trackId: string, file: File) => Promise; + openAudioImportModal: (trackId: string) => void; + closeAudioImportModal: () => void; removeTrack: (id: number) => Promise; updateTrack: (track: KGTrack) => Promise; updateTrackProperties: (trackId: number, properties: TrackUpdateProperties) => Promise; @@ -250,6 +262,10 @@ export const useProjectStore = create((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((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((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 = { + 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((set, get) => { // Setup audio synths for all tracks const audioInterface = KGAudioInterface.instance(); - // Clear any existing synths first - tracks.forEach(track => { - audioInterface.removeTrackSynth(track.getId().toString()); - }); - - // Create synths for all tracks (with their stored volumes) + // Clear any existing synths/buses first tracks.forEach(track => { const trackId = track.getId().toString(); - // Get instrument from track model if it's a MIDI track - let instrument: InstrumentType = 'acoustic_grand_piano'; // Default fallback - if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) { - instrument = (track as KGMidiTrack).getInstrument(); - } - audioInterface.createTrackSynth(trackId, instrument); - // Volume is applied during bus creation; ensure sync if bus already existed - audioInterface.setTrackVolume(trackId, track.getVolume()); + audioInterface.removeTrackSynth(trackId); + audioInterface.removeTrackAudioPlayerBus(trackId); }); + + // Create synths/buses for all tracks (with their stored volumes) + const projectName = projectToLoad.getName(); + for (const track of tracks) { + const trackId = track.getId().toString(); + + if (track.getCurrentType() === 'KGAudioTrack') { + // Audio track: create player bus and load audio buffers + await audioInterface.createTrackAudioPlayerBus(trackId, track.getVolume()); + + // Load audio buffers for all regions in this audio track + const audioTrack = track as KGAudioTrack; + for (const region of audioTrack.getRegions()) { + if (region.getCurrentType() === 'KGAudioRegion') { + const audioRegion = region as KGAudioRegion; + const audioFileId = audioRegion.getAudioFileId(); + if (audioFileId) { + try { + const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId); + const audioContext = Tone.getContext().rawContext as AudioContext; + const decoded = await audioContext.decodeAudioData(arrayBuffer); + const toneBuffer = new Tone.ToneAudioBuffer(); + toneBuffer.set(decoded); + audioInterface.loadAudioBufferForTrack(trackId, audioFileId, toneBuffer); + } catch (err) { + console.error(`Failed to load audio file ${audioFileId}:`, err); + } + } + } + } + } else { + // MIDI track: create sampler-based audio bus + let instrument: InstrumentType = 'acoustic_grand_piano'; + if (track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track) { + instrument = (track as KGMidiTrack).getInstrument(); + } + audioInterface.createTrackSynth(trackId, instrument); + audioInterface.setTrackVolume(trackId, track.getVolume()); + } + } // Update CSS variable for time signature numerator updateTimeSignatureCSS(timeSignature);