diff --git a/src/App.tsx b/src/App.tsx index 4f7fae2..9fea06f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,8 @@ import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; +import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer'; +import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer'; import { KGCore } from './core/KGCore'; import { ConfigManager } from './core/config/ConfigManager'; import { validateFunctionalChordsJSON } from './util/scaleUtil'; @@ -144,6 +146,9 @@ function App() { {/* Migration Loading Overlay */} + + {/* Bounce/Render Overlay */} + ); } @@ -232,3 +237,26 @@ const MigrationOverlayContainer: React.FC = () => { /> ); }; + +// Bounce/render overlay — shown during offline WAV rendering +const BounceOverlayContainer: React.FC = () => { + const [isRendering, setIsRendering] = useState(false); + + useEffectReact(() => { + const renderer = KGOfflineRenderer.instance(); + const listener = (evt: RenderingEvent) => { + setIsRendering(evt.type === 'start'); + }; + renderer.addRenderingListener(listener); + return () => { + renderer.removeRenderingListener(listener); + }; + }, []); + + return ( + + ); +}; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index a754aa2..30e3831 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -22,6 +22,7 @@ import { regionDeleteManager } from '../util/regionDeleteUtil'; import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil'; import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; +import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer'; import KGDropdown from './common/KGDropdown'; import FileImportModal from './common/FileImportModal'; import OpenProjectModal from './common/OpenProjectModal'; @@ -82,7 +83,7 @@ const Toolbar: React.FC = () => { const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; // Export options - const exportOptions = ["Export to KGStudio file", "Export to MIDI file"]; + const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV"]; const handleProjectNameClick = () => { const newName = prompt("Enter project name:", projectName); @@ -202,6 +203,8 @@ const Toolbar: React.FC = () => { handleExportKGStudio(); } else if (exportType === "Export to MIDI file") { handleExportMIDI(); + } else if (exportType === "Export to WAV") { + handleBounceToWav(); } setShowExportDropdown(false); @@ -285,6 +288,22 @@ const Toolbar: React.FC = () => { } }; + const handleBounceToWav = async () => { + if (DEBUG_MODE.TOOLBAR) { + console.log("bouncing to WAV"); + } + + try { + const currentProject = KGCore.instance().getCurrentProject(); + await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName); + setStatus(`Project "${projectName}" exported as WAV file`); + } catch (error) { + console.error("Error bouncing to WAV:", error); + setStatus(`Error exporting WAV: ${error}`); + window.alert(`Failed to export project as WAV: ${error}`); + } + }; + const handleImportProject = () => { if (DEBUG_MODE.TOOLBAR) { console.log("user clicked import button"); diff --git a/src/components/common/LoadingOverlay.css b/src/components/common/LoadingOverlay.css index 56de905..fbc209b 100644 --- a/src/components/common/LoadingOverlay.css +++ b/src/components/common/LoadingOverlay.css @@ -3,6 +3,7 @@ position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); z-index: 20000; display: flex; align-items: center; diff --git a/src/core/audio-interface/KGOfflineRenderer.test.ts b/src/core/audio-interface/KGOfflineRenderer.test.ts new file mode 100644 index 0000000..fa8b39d --- /dev/null +++ b/src/core/audio-interface/KGOfflineRenderer.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { encodeWav } from './KGOfflineRenderer'; + +/** + * Create a minimal AudioBuffer-like object for testing. + * In the jsdom test environment, AudioBuffer is not available, + * so we create a plain object that matches the interface used by encodeWav. + */ +function createMockAudioBuffer( + options: { numberOfChannels: number; sampleRate: number; length: number }, + channelData?: Float32Array[] +): AudioBuffer { + const channels = channelData ?? Array.from({ length: options.numberOfChannels }, () => + new Float32Array(options.length) + ); + return { + numberOfChannels: options.numberOfChannels, + sampleRate: options.sampleRate, + length: options.length, + duration: options.length / options.sampleRate, + getChannelData: (ch: number) => channels[ch], + } as unknown as AudioBuffer; +} + +describe('encodeWav', () => { + it('should produce a valid RIFF/WAV header for stereo 44100Hz', () => { + const audioBuffer = createMockAudioBuffer({ + numberOfChannels: 2, + sampleRate: 44100, + length: 100, + }); + + const result = encodeWav(audioBuffer); + const view = new DataView(result); + + // RIFF header + expect(String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3))).toBe('RIFF'); + expect(String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11))).toBe('WAVE'); + + // File size field: total - 8 + const dataSize = 100 * 2 * 2; // 100 frames * 2 channels * 2 bytes + expect(view.getUint32(4, true)).toBe(44 + dataSize - 8); + + // fmt sub-chunk + expect(String.fromCharCode(view.getUint8(12), view.getUint8(13), view.getUint8(14), view.getUint8(15))).toBe('fmt '); + expect(view.getUint32(16, true)).toBe(16); // PCM sub-chunk size + expect(view.getUint16(20, true)).toBe(1); // audio format = PCM + expect(view.getUint16(22, true)).toBe(2); // channels + expect(view.getUint32(24, true)).toBe(44100); // sample rate + expect(view.getUint32(28, true)).toBe(44100 * 4); // byte rate (sampleRate * blockAlign) + expect(view.getUint16(32, true)).toBe(4); // block align (channels * bytesPerSample) + expect(view.getUint16(34, true)).toBe(16); // bits per sample + + // data sub-chunk + expect(String.fromCharCode(view.getUint8(36), view.getUint8(37), view.getUint8(38), view.getUint8(39))).toBe('data'); + expect(view.getUint32(40, true)).toBe(dataSize); + }); + + it('should produce correct header for mono 48000Hz', () => { + const audioBuffer = createMockAudioBuffer({ + numberOfChannels: 1, + sampleRate: 48000, + length: 50, + }); + + const result = encodeWav(audioBuffer); + const view = new DataView(result); + + expect(view.getUint16(22, true)).toBe(1); // 1 channel + expect(view.getUint32(24, true)).toBe(48000); // sample rate + expect(view.getUint16(32, true)).toBe(2); // block align (1 * 2) + expect(view.getUint32(28, true)).toBe(48000 * 2); // byte rate + expect(view.getUint32(40, true)).toBe(50 * 2); // data size + }); + + it('should have correct total buffer size', () => { + const audioBuffer = createMockAudioBuffer({ + numberOfChannels: 2, + sampleRate: 44100, + length: 200, + }); + + const result = encodeWav(audioBuffer); + // 44 header + 200 frames * 2 channels * 2 bytes + expect(result.byteLength).toBe(44 + 200 * 2 * 2); + }); + + it('should correctly convert float32 samples to int16', () => { + const left = new Float32Array([0, 1, -1, 0.5, -0.5]); + const right = new Float32Array([0, -1, 1, -0.5, 0.5]); + + const audioBuffer = createMockAudioBuffer( + { numberOfChannels: 2, sampleRate: 44100, length: 5 }, + [left, right] + ); + + const result = encodeWav(audioBuffer); + const view = new DataView(result); + + // Sample data starts at offset 44, interleaved L/R as int16 LE + // Sample 0: L=0 → 0, R=0 → 0 + expect(view.getInt16(44, true)).toBe(0); + expect(view.getInt16(46, true)).toBe(0); + + // Sample 1: L=1.0 → 32767, R=-1.0 → -32768 + expect(view.getInt16(48, true)).toBe(32767); + expect(view.getInt16(50, true)).toBe(-32768); + + // Sample 2: L=-1.0 → -32768, R=1.0 → 32767 + expect(view.getInt16(52, true)).toBe(-32768); + expect(view.getInt16(54, true)).toBe(32767); + + // Sample 3: L=0.5 → ~16383, R=-0.5 → ~-16384 + expect(view.getInt16(56, true)).toBeCloseTo(16383, -1); + expect(view.getInt16(58, true)).toBeCloseTo(-16384, -1); + }); + + it('should clamp values outside [-1, 1]', () => { + const data = new Float32Array([1.5, -1.5]); + + const audioBuffer = createMockAudioBuffer( + { numberOfChannels: 1, sampleRate: 44100, length: 2 }, + [data] + ); + + const result = encodeWav(audioBuffer); + const view = new DataView(result); + + // 1.5 clamped to 1.0 → 32767 + expect(view.getInt16(44, true)).toBe(32767); + // -1.5 clamped to -1.0 → -32768 + expect(view.getInt16(46, true)).toBe(-32768); + }); + + it('should handle zero-length audio', () => { + const audioBuffer = createMockAudioBuffer({ + numberOfChannels: 2, + sampleRate: 44100, + length: 0, + }); + + const result = encodeWav(audioBuffer); + expect(result.byteLength).toBe(44); // header only + const view = new DataView(result); + expect(view.getUint32(40, true)).toBe(0); // data size = 0 + }); +}); diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts new file mode 100644 index 0000000..5269b6e --- /dev/null +++ b/src/core/audio-interface/KGOfflineRenderer.ts @@ -0,0 +1,446 @@ +import * as Tone from 'tone'; +import type { KGProject } from '../KGProject'; +import type { KGMidiNote } from '../midi/KGMidiNote'; +import type { KGAudioRegion } from '../region/KGAudioRegion'; +import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; +import { pitchToNoteNameString } from '../../util/midiUtil'; +import { KGToneBuffersPool } from './KGToneBuffersPool'; +import { KGToneSamplerFactory } from './KGToneSamplerFactory'; +import { KGAudioInterface } from './KGAudioInterface'; + +export interface RenderOptions { + sampleRate?: number; // default 44100 + channels?: number; // default 2 (stereo) + tailSeconds?: number; // extra seconds after last note for release/reverb (default 2) +} + +export interface RenderingEvent { + type: 'start' | 'end' | 'error'; + message?: string; +} + +/** + * KGOfflineRenderer - Singleton for bouncing/rendering a project to audio. + * Uses Tone.Offline() to render faster-than-realtime via OfflineAudioContext. + */ +export class KGOfflineRenderer { + private static _instance: KGOfflineRenderer | null = null; + + private _isRendering = false; + private renderingListeners: Array<(_evt: RenderingEvent) => void> = []; + + private constructor() { + console.log('KGOfflineRenderer initialized'); + } + + public static instance(): KGOfflineRenderer { + if (!KGOfflineRenderer._instance) { + KGOfflineRenderer._instance = new KGOfflineRenderer(); + } + return KGOfflineRenderer._instance; + } + + // ===== EVENT LISTENERS ===== + + public addRenderingListener(listener: (_evt: RenderingEvent) => void): void { + this.renderingListeners.push(listener); + } + + public removeRenderingListener(listener: (_evt: RenderingEvent) => void): void { + this.renderingListeners = this.renderingListeners.filter(l => l !== listener); + } + + private emitRenderingEvent(evt: RenderingEvent): void { + for (const listener of this.renderingListeners) { + try { listener(evt); } catch { /* swallow */ } + } + } + + public get isRendering(): boolean { + return this._isRendering; + } + + // ===== PUBLIC API ===== + + /** + * Render the project to a ToneAudioBuffer using Tone.Offline. + */ + public async renderToBuffer(project: KGProject, options?: RenderOptions): Promise { + const sampleRate = options?.sampleRate ?? 44100; + const channels = options?.channels ?? 2; + const tailSeconds = options?.tailSeconds ?? 2; + + // Calculate render duration in seconds + const bpm = project.getBpm(); + const secondsPerBeat = 60 / bpm; + const timeSignature = project.getTimeSignature(); + const beatsPerBar = timeSignature.numerator; + + let renderStartBeat = 0; + let renderEndBeat: number; + + const isLooping = project.getIsLooping(); + // Looping range is determined up-front; non-looping range is computed + // after collecting track data (see below). + if (isLooping) { + const [startBar, endBarOriginal] = project.getLoopingRange(); + const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal; + renderStartBeat = startBar * beatsPerBar; + renderEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive + } else { + // Placeholder — will be refined after track data collection + renderEndBeat = project.getMaxBars() * beatsPerBar; + } + + // Determine solo state from the live audio buses + const audioInterface = KGAudioInterface.instance(); + + // Collect track info we'll need inside the offline callback + const tracks = project.getTracks(); + + // Pre-collect all the data we need before entering the offline context + const midiTrackData: Array<{ + trackId: string; + instrumentName: string; + volume: number; + muted: boolean; + solo: boolean; + regions: Array<{ + startBeat: number; + notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>; + }>; + }> = []; + + const audioTrackData: Array<{ + trackId: string; + volume: number; + muted: boolean; + solo: boolean; + regions: Array<{ + startBeat: number; + lengthBeats: number; + audioFileId: string; + clipStartOffsetSeconds: number; + audioDurationSeconds: number; + rawBuffer: AudioBuffer; + }>; + }> = []; + + let hasSoloedTracks = false; + + for (const track of tracks) { + const trackId = track.getId().toString(); + + if (track.getType() === 'MIDI') { + const midiTrack = track as unknown as { getInstrument: () => string }; + const instrumentName = String(midiTrack.getInstrument()); + + // Get live bus state for volume/mute/solo via public getters + const volume = audioInterface.getTrackVolume(trackId); + const muted = audioInterface.getTrackMuted(trackId); + const solo = audioInterface.getTrackSolo(trackId); + if (solo) hasSoloedTracks = true; + + const regions: typeof midiTrackData[0]['regions'] = []; + for (const region of track.getRegions()) { + if (region.getCurrentType() === 'KGMidiRegion') { + const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] }; + if (midiRegion.getNotes) { + const notes = midiRegion.getNotes().map(note => ({ + startBeat: note.getStartBeat() + region.getStartFromBeat(), + endBeat: note.getEndBeat() + region.getStartFromBeat(), + durationBeats: note.getEndBeat() - note.getStartBeat(), + pitch: note.getPitch(), + velocity: note.getVelocity(), + })); + regions.push({ startBeat: region.getStartFromBeat(), notes }); + } + } + } + + midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions }); + } else if (track.getType() === 'Wave') { + const volume = audioInterface.getTrackVolume(trackId); + const muted = audioInterface.getTrackMuted(trackId); + const solo = audioInterface.getTrackSolo(trackId); + if (solo) hasSoloedTracks = true; + + const regions: typeof audioTrackData[0]['regions'] = []; + for (const region of track.getRegions()) { + if (region.getCurrentType() === 'KGAudioRegion') { + const audioRegion = region as unknown as KGAudioRegion; + const audioFileId = audioRegion.getAudioFileId(); + const rawBuffer = audioInterface.getAudioBuffer(trackId, audioFileId); + if (rawBuffer) { + regions.push({ + startBeat: region.getStartFromBeat(), + lengthBeats: region.getLength(), + audioFileId, + clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(), + audioDurationSeconds: audioRegion.getAudioDurationSeconds(), + rawBuffer, + }); + } + } + } + + audioTrackData.push({ trackId, volume, muted, solo, regions }); + } + } + + // For non-looping mode, tighten the render range to the actual content + if (!isLooping) { + let contentStart = Infinity; + let contentEnd = 0; + + for (const t of midiTrackData) { + for (const r of t.regions) { + for (const n of r.notes) { + if (n.startBeat < contentStart) contentStart = n.startBeat; + if (n.endBeat > contentEnd) contentEnd = n.endBeat; + } + } + } + for (const t of audioTrackData) { + for (const r of t.regions) { + if (r.startBeat < contentStart) contentStart = r.startBeat; + const regionEnd = r.startBeat + r.lengthBeats; + if (regionEnd > contentEnd) contentEnd = regionEnd; + } + } + + if (contentEnd > 0) { + renderStartBeat = contentStart; + renderEndBeat = contentEnd; + } + // else: no content found, keep the full project range as fallback + } + + const durationSeconds = (renderEndBeat - renderStartBeat) * secondsPerBeat + tailSeconds; + + console.log(`Offline render: ${durationSeconds}s (beats ${renderStartBeat}-${renderEndBeat}), ${sampleRate}Hz, ${channels}ch`); + + // Run offline render + const buffer = await Tone.Offline(async (context) => { + // Master gain routed to offline destination + const masterGain = new Tone.Gain(1).toDestination(); + + // Set BPM and time signature on offline transport + context.transport.bpm.value = bpm; + context.transport.timeSignature = [timeSignature.numerator, timeSignature.denominator]; + + // ---- Create MIDI track samplers ---- + const samplerPromises: Promise[] = []; + + for (const trackInfo of midiTrackData) { + if (!shouldPlay(trackInfo, hasSoloedTracks)) continue; + + const promise = (async () => { + try { + // Get cached buffers from pool + const audioBuffers = await KGToneBuffersPool.instance().getToneAudioBuffers(trackInfo.instrumentName); + const pitchRange = FLUIDR3_INSTRUMENT_MAP[trackInfo.instrumentName]?.pitchRange || [21, 108]; + const urlMap = KGToneSamplerFactory.instance().convertBuffersToUrls(audioBuffers, pitchRange); + + // Create sampler inside offline context + const sampler = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Offline sampler timeout: ${trackInfo.instrumentName}`)), 30000); + const s = new Tone.Sampler({ + urls: urlMap, + onload: () => { clearTimeout(timeout); resolve(s); }, + onerror: (err) => { clearTimeout(timeout); reject(err); }, + }); + }); + + // Apply volume + const volumeDb = trackInfo.volume > 0 ? 20 * Math.log10(trackInfo.volume) : -Infinity; + sampler.volume.value = volumeDb; + sampler.connect(masterGain); + + // Schedule all notes for this track + for (const regionInfo of trackInfo.regions) { + for (const note of regionInfo.notes) { + // Skip notes outside render range + if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue; + + const offsetBeat = note.startBeat - renderStartBeat; + const noteStartTime = offsetBeat * secondsPerBeat; + const noteDuration = note.durationBeats * secondsPerBeat; + const noteName = pitchToNoteNameString(note.pitch); + const velocity = note.velocity / 127; + + context.transport.schedule((time) => { + sampler.triggerAttackRelease(noteName, noteDuration, time, velocity); + }, noteStartTime); + } + } + } catch (error) { + console.error(`Offline render: failed to create sampler for ${trackInfo.instrumentName}:`, error); + } + })(); + + samplerPromises.push(promise); + } + + // ---- Create audio track gain nodes and schedule regions ---- + for (const trackInfo of audioTrackData) { + if (!shouldPlay(trackInfo, hasSoloedTracks)) continue; + + const trackGain = new Tone.Gain(trackInfo.volume); + trackGain.connect(masterGain); + + for (const regionInfo of trackInfo.regions) { + const regionStartBeat = regionInfo.startBeat; + const regionEndBeat = regionStartBeat + regionInfo.lengthBeats; + + // Skip regions outside render range + if (regionStartBeat >= renderEndBeat || regionEndBeat <= renderStartBeat) continue; + + const clipStartOffsetSeconds = regionInfo.clipStartOffsetSeconds; + const audioDurationSeconds = regionInfo.audioDurationSeconds; + const regionLengthSeconds = regionInfo.lengthBeats * secondsPerBeat; + const effectiveDurationSeconds = Math.min(regionLengthSeconds, audioDurationSeconds - clipStartOffsetSeconds); + + if (effectiveDurationSeconds <= 0) continue; + + const offsetBeat = regionStartBeat - renderStartBeat; + const regionStartTime = Math.max(0, offsetBeat * secondsPerBeat); + + // Create buffer source NOW while the offline context is still active. + // Schedule callbacks fire during rendering after Tone.js restores the + // main context, so creating nodes there would bind them to the wrong context. + const toneBuffer = new Tone.ToneAudioBuffer(regionInfo.rawBuffer); + const source = new Tone.ToneBufferSource(toneBuffer); + source.connect(trackGain); + + context.transport.schedule((time) => { + source.start(time, clipStartOffsetSeconds, effectiveDurationSeconds); + }, regionStartTime); + } + } + + // Wait for all samplers to load + await Promise.all(samplerPromises); + + // Start offline transport + context.transport.start(0); + }, durationSeconds, channels, sampleRate); + + console.log(`Offline render complete: ${buffer.duration}s, ${buffer.numberOfChannels}ch`); + return buffer; + } + + /** + * Render the project and download as a WAV file. + */ + public async bounceToWav(project: KGProject, fileName?: string, options?: RenderOptions): Promise { + if (this._isRendering) { + console.warn('Already rendering, ignoring bounce request'); + return; + } + + this._isRendering = true; + this.emitRenderingEvent({ type: 'start', message: 'Bouncing to WAV...' }); + + try { + const toneBuffer = await this.renderToBuffer(project, options); + const audioBuffer = toneBuffer.get() as AudioBuffer; + const wavData = encodeWav(audioBuffer); + + // Trigger download + const blob = new Blob([wavData], { type: 'audio/wav' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${fileName ?? 'bounce'}.wav`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + this.emitRenderingEvent({ type: 'end', message: 'Bounce complete' }); + console.log('WAV bounce complete'); + } catch (error) { + console.error('Bounce to WAV failed:', error); + this.emitRenderingEvent({ type: 'error', message: String(error) }); + throw error; + } finally { + this._isRendering = false; + } + } +} + +// ===== HELPERS ===== + +function shouldPlay(trackInfo: { muted: boolean; solo: boolean }, hasSoloedTracks: boolean): boolean { + if (trackInfo.muted) return false; + if (hasSoloedTracks) return trackInfo.solo; + return true; +} + +// ===== WAV ENCODER ===== + +/** + * Encode an AudioBuffer as a 16-bit PCM WAV file. + * Returns the complete WAV file as an ArrayBuffer. + */ +export function encodeWav(audioBuffer: AudioBuffer): ArrayBuffer { + const numChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const numFrames = audioBuffer.length; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const blockAlign = numChannels * bytesPerSample; + const dataSize = numFrames * blockAlign; + const headerSize = 44; + const totalSize = headerSize + dataSize; + + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + + // Collect channel data + const channels: Float32Array[] = []; + for (let ch = 0; ch < numChannels; ch++) { + channels.push(audioBuffer.getChannelData(ch)); + } + + // RIFF header + writeString(view, 0, 'RIFF'); + view.setUint32(4, totalSize - 8, true); // file size - 8 + writeString(view, 8, 'WAVE'); + + // fmt sub-chunk + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // sub-chunk size (16 for PCM) + view.setUint16(20, 1, true); // audio format (1 = PCM) + view.setUint16(22, numChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); // byte rate + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitsPerSample, true); + + // data sub-chunk + writeString(view, 36, 'data'); + view.setUint32(40, dataSize, true); + + // Interleave and convert float32 [-1, 1] to int16 + let offset = headerSize; + for (let i = 0; i < numFrames; i++) { + for (let ch = 0; ch < numChannels; ch++) { + const sample = channels[ch][i]; + // Clamp to [-1, 1] then scale to int16 range + const clamped = Math.max(-1, Math.min(1, sample)); + const int16 = clamped < 0 ? clamped * 0x8000 : clamped * 0x7FFF; + view.setInt16(offset, int16, true); + offset += bytesPerSample; + } + } + + return buffer; +} + +function writeString(view: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } +} diff --git a/src/core/audio-interface/KGToneSamplerFactory.ts b/src/core/audio-interface/KGToneSamplerFactory.ts index 944f9c9..676434d 100644 --- a/src/core/audio-interface/KGToneSamplerFactory.ts +++ b/src/core/audio-interface/KGToneSamplerFactory.ts @@ -78,7 +78,7 @@ export class KGToneSamplerFactory { * Convert ToneAudioBuffers to the URL format expected by Tone.Sampler * This creates a mapping from note names to the actual audio buffers */ - private convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } { + public convertBuffersToUrls(audioBuffers: Tone.ToneAudioBuffers, range: number[] = [21, 118]): { [key: string]: Tone.ToneAudioBuffer } { const urls: { [key: string]: Tone.ToneAudioBuffer } = {}; // Note names in order (using flats instead of sharps where applicable)