Files
coolsoup/src/spectral-synthesis/audio/export.ts
Raphaël Forment 623082ce3b Initial CoolSoup implementation
CoolSoup is a React + TypeScript + Vite application that generates visual patterns and converts them to audio through spectral synthesis. Features multiple image generators (Tixy expressions, geometric tiles, external APIs) and an advanced audio synthesis engine that treats images as spectrograms.
2025-09-29 14:44:48 +02:00

184 lines
4.7 KiB
TypeScript

/**
* Create WAV buffer from audio data
*/
export function createWAVBuffer(audioData: Float32Array, sampleRate: number): ArrayBuffer {
const length = audioData.length
const buffer = new ArrayBuffer(44 + length * 2)
const view = new DataView(buffer)
// WAV header
writeString(view, 0, 'RIFF')
view.setUint32(4, 36 + length * 2, true) // file length - 8
writeString(view, 8, 'WAVE')
writeString(view, 12, 'fmt ')
view.setUint32(16, 16, true) // format chunk length
view.setUint16(20, 1, true) // PCM format
view.setUint16(22, 1, true) // mono
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true) // byte rate
view.setUint16(32, 2, true) // block align
view.setUint16(34, 16, true) // bits per sample
writeString(view, 36, 'data')
view.setUint32(40, length * 2, true) // data chunk length
// Convert float samples to 16-bit PCM
let offset = 44
for (let i = 0; i < length; i++) {
const sample = Math.max(-1, Math.min(1, audioData[i]))
view.setInt16(offset, sample * 0x7FFF, true)
offset += 2
}
return buffer
}
function writeString(view: DataView, offset: number, string: string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
/**
* Download audio as WAV file
*/
export function downloadWAV(audioData: Float32Array, sampleRate: number, filename: string) {
const buffer = createWAVBuffer(audioData, sampleRate)
const blob = new Blob([buffer], { type: 'audio/wav' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export interface AudioPlayer {
play(): void
pause(): void
stop(): void
setVolume(volume: number): void
isPlaying(): boolean
onStateChange(callback: (isPlaying: boolean) => void): void
}
/**
* Create an audio player with playback controls
*/
export function createAudioPlayer(audioData: Float32Array, sampleRate: number): AudioPlayer {
let audioContext: AudioContext | null = null
let source: AudioBufferSourceNode | null = null
let gainNode: GainNode | null = null
let isCurrentlyPlaying = false
let isPaused = false
let pausedAt = 0
let startedAt = 0
let stateCallback: ((isPlaying: boolean) => void) | null = null
const initAudioContext = () => {
if (!audioContext) {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
gainNode = audioContext.createGain()
gainNode.connect(audioContext.destination)
if (audioContext.sampleRate !== sampleRate) {
console.warn(`Audio context sample rate (${audioContext.sampleRate}) differs from data sample rate (${sampleRate})`)
}
}
}
const updateState = (playing: boolean) => {
isCurrentlyPlaying = playing
if (stateCallback) {
stateCallback(playing)
}
}
return {
play() {
initAudioContext()
if (!audioContext || !gainNode) return
if (isPaused) {
// Resume from pause is not supported with AudioBufferSource
// We need to restart from the beginning
isPaused = false
pausedAt = 0
}
if (source) {
source.stop()
}
const buffer = audioContext.createBuffer(1, audioData.length, sampleRate)
buffer.copyToChannel(audioData, 0)
source = audioContext.createBufferSource()
source.buffer = buffer
source.connect(gainNode)
source.onended = () => {
updateState(false)
isPaused = false
pausedAt = 0
startedAt = 0
}
source.start()
startedAt = audioContext.currentTime
updateState(true)
},
pause() {
if (source && isCurrentlyPlaying) {
source.stop()
source = null
isPaused = true
pausedAt = audioContext ? audioContext.currentTime - startedAt : 0
updateState(false)
}
},
stop() {
if (source) {
source.stop()
source = null
}
isPaused = false
pausedAt = 0
startedAt = 0
updateState(false)
},
setVolume(volume: number) {
if (gainNode) {
gainNode.gain.value = Math.max(0, Math.min(1, volume))
}
},
isPlaying() {
return isCurrentlyPlaying
},
onStateChange(callback: (isPlaying: boolean) => void) {
stateCallback = callback
}
}
}
/**
* Play audio in browser (legacy function for backward compatibility)
*/
export async function playAudio(audioData: Float32Array, sampleRate: number): Promise<void> {
const player = createAudioPlayer(audioData, sampleRate)
return new Promise(resolve => {
player.onStateChange((isPlaying) => {
if (!isPlaying) {
resolve()
}
})
player.play()
})
}