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.
184 lines
4.7 KiB
TypeScript
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()
|
|
})
|
|
} |