modularity
This commit is contained in:
@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>bytesample</title>
|
<meta name="description" content="Bytebeat playground" />
|
||||||
|
<meta name="author" content="Raphaël Forment" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='22' text-anchor='middle' font-size='20' font-family='monospace' font-weight='bold'>&</text></svg>" />
|
||||||
|
<title>Bruitiste</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="m-0 p-0">
|
<body class="m-0 p-0">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
83
src/App.tsx
83
src/App.tsx
@ -1,14 +1,13 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import JSZip from 'jszip'
|
import { PlaybackManager } from './services/PlaybackManager'
|
||||||
import { BytebeatGenerator } from './lib/bytebeat'
|
import { DownloadService } from './services/DownloadService'
|
||||||
import { generateFormulaGrid } from './utils/bytebeatFormulas'
|
import { generateFormulaGrid } from './utils/bytebeatFormulas'
|
||||||
import { BytebeatTile } from './components/BytebeatTile'
|
import { BytebeatTile } from './components/BytebeatTile'
|
||||||
import { EffectsBar } from './components/EffectsBar'
|
import { EffectsBar } from './components/EffectsBar'
|
||||||
import { EngineControls } from './components/EngineControls'
|
import { EngineControls } from './components/EngineControls'
|
||||||
import { getSampleRateFromIndex } from './config/effects'
|
import { getSampleRateFromIndex } from './config/effects'
|
||||||
import { engineSettings, effectSettings } from './stores/settings'
|
import { engineSettings, effectSettings } from './stores/settings'
|
||||||
import type { EffectValues } from './types/effects'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const engineValues = useStore(engineSettings)
|
const engineValues = useStore(engineSettings)
|
||||||
@ -21,7 +20,8 @@ function App() {
|
|||||||
const [queued, setQueued] = useState<string | null>(null)
|
const [queued, setQueued] = useState<string | null>(null)
|
||||||
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||||
const [downloading, setDownloading] = useState(false)
|
const [downloading, setDownloading] = useState(false)
|
||||||
const generatorRef = useRef<BytebeatGenerator | null>(null)
|
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||||
|
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
||||||
const animationFrameRef = useRef<number | null>(null)
|
const animationFrameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -37,22 +37,23 @@ function App() {
|
|||||||
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
const sampleRate = getSampleRateFromIndex(engineValues.sampleRate)
|
||||||
const duration = engineValues.loopDuration
|
const duration = engineValues.loopDuration
|
||||||
|
|
||||||
if (!generatorRef.current) {
|
if (!playbackManagerRef.current) {
|
||||||
generatorRef.current = new BytebeatGenerator({ sampleRate, duration })
|
playbackManagerRef.current = new PlaybackManager({ sampleRate, duration })
|
||||||
} else {
|
} else {
|
||||||
generatorRef.current.updateOptions({ sampleRate, duration })
|
playbackManagerRef.current.updateOptions({ sampleRate, duration })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
playbackManagerRef.current.stop()
|
||||||
generatorRef.current.stop()
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
generatorRef.current.setFormula(formula)
|
|
||||||
generatorRef.current.setEffects(effectValues)
|
const success = playbackManagerRef.current.play(formula, sampleRate, duration)
|
||||||
generatorRef.current.play()
|
|
||||||
|
if (success) {
|
||||||
setPlaying(id)
|
setPlaying(id)
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
startPlaybackTracking()
|
startPlaybackTracking()
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('Failed to play formula:', error)
|
console.error('Failed to play formula')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,8 +63,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
if (generatorRef.current) {
|
if (playbackManagerRef.current) {
|
||||||
const position = generatorRef.current.getPlaybackPosition()
|
const position = playbackManagerRef.current.getPlaybackPosition()
|
||||||
setPlaybackPosition(position)
|
setPlaybackPosition(position)
|
||||||
animationFrameRef.current = requestAnimationFrame(updatePosition)
|
animationFrameRef.current = requestAnimationFrame(updatePosition)
|
||||||
}
|
}
|
||||||
@ -75,7 +76,7 @@ function App() {
|
|||||||
const id = `${row}-${col}`
|
const id = `${row}-${col}`
|
||||||
|
|
||||||
if (playing === id) {
|
if (playing === id) {
|
||||||
generatorRef.current?.stop()
|
playbackManagerRef.current?.stop()
|
||||||
setPlaying(null)
|
setPlaying(null)
|
||||||
setQueued(null)
|
setQueued(null)
|
||||||
if (animationFrameRef.current) {
|
if (animationFrameRef.current) {
|
||||||
@ -89,8 +90,8 @@ function App() {
|
|||||||
playFormula(formula, id)
|
playFormula(formula, id)
|
||||||
} else {
|
} else {
|
||||||
setQueued(id)
|
setQueued(id)
|
||||||
if (generatorRef.current) {
|
if (playbackManagerRef.current) {
|
||||||
generatorRef.current.scheduleNextTrack(() => {
|
playbackManagerRef.current.scheduleNextTrack(() => {
|
||||||
const queuedFormula = formulas.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])]
|
const queuedFormula = formulas.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])]
|
||||||
if (queuedFormula) {
|
if (queuedFormula) {
|
||||||
playFormula(queuedFormula, id)
|
playFormula(queuedFormula, id)
|
||||||
@ -106,54 +107,35 @@ function App() {
|
|||||||
|
|
||||||
|
|
||||||
const handleEngineChange = (parameterId: string, value: number) => {
|
const handleEngineChange = (parameterId: string, value: number) => {
|
||||||
engineSettings.setKey(parameterId, value)
|
engineSettings.setKey(parameterId as keyof typeof engineValues, value)
|
||||||
|
|
||||||
if (parameterId === 'masterVolume' && generatorRef.current) {
|
if (parameterId === 'masterVolume' && playbackManagerRef.current) {
|
||||||
generatorRef.current.setEffects(effectValues)
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEffectChange = (parameterId: string, value: number) => {
|
const handleEffectChange = (parameterId: string, value: number) => {
|
||||||
effectSettings.setKey(parameterId, value)
|
effectSettings.setKey(parameterId as keyof typeof effectValues, value)
|
||||||
if (generatorRef.current) {
|
if (playbackManagerRef.current) {
|
||||||
generatorRef.current.setEffects(effectValues)
|
playbackManagerRef.current.setEffects(effectValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadAll = async () => {
|
const handleDownloadAll = async () => {
|
||||||
setDownloading(true)
|
setDownloading(true)
|
||||||
const zip = new JSZip()
|
await downloadServiceRef.current.downloadAll(formulas, { duration: 10, bitDepth: 8 })
|
||||||
const gen = new BytebeatGenerator({ duration: 10 })
|
|
||||||
|
|
||||||
formulas.forEach((row, i) => {
|
|
||||||
row.forEach((formula, j) => {
|
|
||||||
try {
|
|
||||||
gen.setFormula(formula)
|
|
||||||
const blob = gen.exportWAV(8)
|
|
||||||
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to generate ${i}_${j}:`, error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: 'blob' })
|
|
||||||
const url = URL.createObjectURL(content)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = 'bytebeats.zip'
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
gen.dispose()
|
|
||||||
setDownloading(false)
|
setDownloading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadFormula = (formula: string, filename: string) => {
|
||||||
|
downloadServiceRef.current.downloadFormula(formula, filename, { duration: 10, bitDepth: 8 })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
||||||
<header className="bg-black border-b-2 border-white px-6 py-3">
|
<header className="bg-black border-b-2 border-white px-6 py-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h1 className="font-mono text-sm tracking-[0.3em] text-white">BYTEBEAT</h1>
|
<h1 className="font-mono text-sm tracking-[0.3em] text-white">BRUITISTE</h1>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleRandom}
|
onClick={handleRandom}
|
||||||
@ -188,6 +170,7 @@ function App() {
|
|||||||
playbackPosition={playing === id ? playbackPosition : 0}
|
playbackPosition={playing === id ? playbackPosition : 0}
|
||||||
onPlay={handleTileClick}
|
onPlay={handleTileClick}
|
||||||
onDoubleClick={handleTileDoubleClick}
|
onDoubleClick={handleTileDoubleClick}
|
||||||
|
onDownload={handleDownloadFormula}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BytebeatGenerator } from '../lib/bytebeat'
|
import { useRef, useEffect } from 'react'
|
||||||
import { Download } from 'lucide-react'
|
import { Download } from 'lucide-react'
|
||||||
|
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
||||||
|
|
||||||
interface BytebeatTileProps {
|
interface BytebeatTileProps {
|
||||||
formula: string
|
formula: string
|
||||||
@ -10,19 +11,28 @@ interface BytebeatTileProps {
|
|||||||
playbackPosition: number
|
playbackPosition: number
|
||||||
onPlay: (formula: string, row: number, col: number) => void
|
onPlay: (formula: string, row: number, col: number) => void
|
||||||
onDoubleClick: (formula: string, row: number, col: number) => void
|
onDoubleClick: (formula: string, row: number, col: number) => void
|
||||||
|
onDownload: (formula: string, filename: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, playbackPosition, onPlay, onDoubleClick }: BytebeatTileProps) {
|
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, playbackPosition, onPlay, onDoubleClick, onDownload }: BytebeatTileProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
canvas.width = rect.width * window.devicePixelRatio
|
||||||
|
canvas.height = rect.height * window.devicePixelRatio
|
||||||
|
|
||||||
|
const waveformData = generateWaveformData(formula, canvas.width)
|
||||||
|
const color = isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.35)'
|
||||||
|
drawWaveform(canvas, waveformData, color)
|
||||||
|
}, [formula, isPlaying, isQueued])
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const gen = new BytebeatGenerator({ duration: 10 })
|
onDownload(formula, `bytebeat_${row}_${col}.wav`)
|
||||||
try {
|
|
||||||
gen.setFormula(formula)
|
|
||||||
gen.downloadWAV(`bytebeat_${row}_${col}.wav`, 8)
|
|
||||||
gen.dispose()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to download ${row}_${col}:`, error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -33,6 +43,10 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, playbackP
|
|||||||
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : 'bg-black text-white'
|
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : 'bg-black text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
/>
|
||||||
{isPlaying && (
|
{isPlaying && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 bottom-0 bg-black opacity-10 transition-all duration-75 ease-linear"
|
className="absolute left-0 top-0 bottom-0 bg-black opacity-10 transition-all duration-75 ease-linear"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Slider } from './Slider'
|
import { Slider } from './Slider'
|
||||||
import { EFFECTS } from '../config/effects'
|
import { EFFECTS } from '../config/effects'
|
||||||
|
import { getClipModeLabel } from '../utils/formatters'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
|
||||||
interface EffectsBarProps {
|
interface EffectsBarProps {
|
||||||
@ -8,6 +9,13 @@ interface EffectsBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
||||||
|
const formatValue = (id: string, value: number): string => {
|
||||||
|
if (id === 'clipMode') {
|
||||||
|
return getClipModeLabel(value)
|
||||||
|
}
|
||||||
|
return value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black border-t-2 border-white px-6 py-4">
|
<div className="bg-black border-t-2 border-white px-6 py-4">
|
||||||
<div className="grid grid-cols-4 gap-6">
|
<div className="grid grid-cols-4 gap-6">
|
||||||
@ -22,6 +30,8 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
|||||||
step={param.step}
|
step={param.step}
|
||||||
unit={param.unit}
|
unit={param.unit}
|
||||||
onChange={(value) => onChange(param.id, value)}
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
formatValue={param.id === 'clipMode' ? formatValue : undefined}
|
||||||
|
valueId={param.id}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ENGINE_CONTROLS, SAMPLE_RATES, getComplexityLabel } from '../config/effects'
|
import { ENGINE_CONTROLS } from '../config/effects'
|
||||||
|
import { getComplexityLabel, getBitDepthLabel, getSampleRateLabel } from '../utils/formatters'
|
||||||
import type { EffectValues } from '../types/effects'
|
import type { EffectValues } from '../types/effects'
|
||||||
|
|
||||||
interface EngineControlsProps {
|
interface EngineControlsProps {
|
||||||
@ -10,9 +11,11 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
|
|||||||
const formatValue = (id: string, value: number): string => {
|
const formatValue = (id: string, value: number): string => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'sampleRate':
|
case 'sampleRate':
|
||||||
return `${SAMPLE_RATES[value]}Hz`
|
return getSampleRateLabel(value)
|
||||||
case 'complexity':
|
case 'complexity':
|
||||||
return getComplexityLabel(value)
|
return getComplexityLabel(value)
|
||||||
|
case 'bitDepth':
|
||||||
|
return getBitDepthLabel(value)
|
||||||
default:
|
default:
|
||||||
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
|
||||||
return `${value}${param?.unit || ''}`
|
return `${value}${param?.unit || ''}`
|
||||||
|
|||||||
@ -6,9 +6,13 @@ interface SliderProps {
|
|||||||
step: number
|
step: number
|
||||||
unit?: string
|
unit?: string
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
|
formatValue?: (id: string, value: number) => string
|
||||||
|
valueId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Slider({ label, value, min, max, step, unit, onChange }: SliderProps) {
|
export function Slider({ label, value, min, max, step, unit, onChange, formatValue, valueId }: SliderProps) {
|
||||||
|
const displayValue = formatValue && valueId ? formatValue(valueId, value) : `${value}${unit || ''}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
@ -16,7 +20,7 @@ export function Slider({ label, value, min, max, step, unit, onChange }: SliderP
|
|||||||
{label.toUpperCase()}
|
{label.toUpperCase()}
|
||||||
</label>
|
</label>
|
||||||
<span className="font-mono text-[10px] text-white">
|
<span className="font-mono text-[10px] text-white">
|
||||||
{value}{unit}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -32,6 +32,15 @@ export const ENGINE_CONTROLS: EffectConfig[] = [
|
|||||||
step: 1,
|
step: 1,
|
||||||
unit: ''
|
unit: ''
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'bitDepth',
|
||||||
|
label: 'Bit Depth',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'masterVolume',
|
id: 'masterVolume',
|
||||||
label: 'Volume',
|
label: 'Volume',
|
||||||
@ -86,16 +95,41 @@ export const EFFECTS: EffectConfig[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tbd',
|
id: 'bitcrush',
|
||||||
name: 'TBD',
|
name: 'Bitcrush',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
id: 'tbdParam',
|
id: 'bitcrushDepth',
|
||||||
label: 'TBD',
|
label: 'Depth',
|
||||||
|
min: 1,
|
||||||
|
max: 16,
|
||||||
|
default: 16,
|
||||||
|
step: 1,
|
||||||
|
unit: 'bit'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bitcrushRate',
|
||||||
|
label: 'Rate',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
default: 0,
|
default: 0,
|
||||||
step: 1
|
step: 1,
|
||||||
|
unit: '%'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clipmode',
|
||||||
|
name: 'Clip Mode',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
id: 'clipMode',
|
||||||
|
label: 'Mode',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
unit: ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -125,9 +159,4 @@ export const SAMPLE_RATES = [4000, 8000, 16000, 22050]
|
|||||||
|
|
||||||
export function getSampleRateFromIndex(index: number): number {
|
export function getSampleRateFromIndex(index: number): number {
|
||||||
return SAMPLE_RATES[index] || 8000
|
return SAMPLE_RATES[index] || 8000
|
||||||
}
|
|
||||||
|
|
||||||
export function getComplexityLabel(index: number): string {
|
|
||||||
const labels = ['Simple', 'Medium', 'Complex']
|
|
||||||
return labels[index] || 'Medium'
|
|
||||||
}
|
}
|
||||||
129
src/domain/audio/AudioPlayer.ts
Normal file
129
src/domain/audio/AudioPlayer.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { EffectsChain } from './effects/EffectsChain'
|
||||||
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
|
||||||
|
export interface AudioPlayerOptions {
|
||||||
|
sampleRate: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AudioPlayer {
|
||||||
|
private audioContext: AudioContext | null = null
|
||||||
|
private sourceNode: AudioBufferSourceNode | null = null
|
||||||
|
private effectsChain: EffectsChain | null = null
|
||||||
|
private effectValues: EffectValues = {}
|
||||||
|
private startTime: number = 0
|
||||||
|
private pauseTime: number = 0
|
||||||
|
private isLooping: boolean = true
|
||||||
|
private sampleRate: number
|
||||||
|
private duration: number
|
||||||
|
|
||||||
|
constructor(options: AudioPlayerOptions) {
|
||||||
|
this.sampleRate = options.sampleRate
|
||||||
|
this.duration = options.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOptions(options: Partial<AudioPlayerOptions>): void {
|
||||||
|
if (options.sampleRate !== undefined) {
|
||||||
|
this.sampleRate = options.sampleRate
|
||||||
|
}
|
||||||
|
if (options.duration !== undefined) {
|
||||||
|
this.duration = options.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffects(values: EffectValues): void {
|
||||||
|
this.effectValues = values
|
||||||
|
if (this.effectsChain) {
|
||||||
|
this.effectsChain.updateEffects(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
play(buffer: Float32Array, onEnded?: () => void): void {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.effectsChain) {
|
||||||
|
this.effectsChain = new EffectsChain(this.audioContext)
|
||||||
|
this.effectsChain.updateEffects(this.effectValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = this.audioContext.createBuffer(1, buffer.length, this.sampleRate)
|
||||||
|
audioBuffer.getChannelData(0).set(buffer)
|
||||||
|
|
||||||
|
this.sourceNode = this.audioContext.createBufferSource()
|
||||||
|
this.sourceNode.buffer = audioBuffer
|
||||||
|
this.sourceNode.loop = this.isLooping
|
||||||
|
|
||||||
|
if (onEnded) {
|
||||||
|
this.sourceNode.onended = onEnded
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sourceNode.connect(this.effectsChain.getInputNode())
|
||||||
|
this.effectsChain.getOutputNode().connect(this.audioContext.destination)
|
||||||
|
|
||||||
|
if (this.pauseTime > 0) {
|
||||||
|
this.sourceNode.start(0, this.pauseTime)
|
||||||
|
this.startTime = this.audioContext.currentTime - this.pauseTime
|
||||||
|
this.pauseTime = 0
|
||||||
|
} else {
|
||||||
|
this.sourceNode.start(0)
|
||||||
|
this.startTime = this.audioContext.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLooping(loop: boolean): void {
|
||||||
|
this.isLooping = loop
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.loop = loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextTrack(callback: () => void): void {
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.loop = false
|
||||||
|
this.sourceNode.onended = callback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackPosition(): number {
|
||||||
|
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const elapsed = this.audioContext.currentTime - this.startTime
|
||||||
|
return (elapsed % this.duration) / this.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
if (this.sourceNode && this.audioContext) {
|
||||||
|
this.pauseTime = this.audioContext.currentTime - this.startTime
|
||||||
|
this.sourceNode.stop()
|
||||||
|
this.sourceNode = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.stop()
|
||||||
|
this.sourceNode = null
|
||||||
|
}
|
||||||
|
this.startTime = 0
|
||||||
|
this.pauseTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop()
|
||||||
|
if (this.effectsChain) {
|
||||||
|
this.effectsChain.dispose()
|
||||||
|
this.effectsChain = null
|
||||||
|
}
|
||||||
|
if (this.audioContext) {
|
||||||
|
this.audioContext.close()
|
||||||
|
this.audioContext = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/domain/audio/BytebeatCompiler.ts
Normal file
36
src/domain/audio/BytebeatCompiler.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export type CompiledFormula = (t: number) => number
|
||||||
|
|
||||||
|
export interface CompilationResult {
|
||||||
|
success: boolean
|
||||||
|
compiledFormula?: CompiledFormula
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileFormula(formula: string): CompilationResult {
|
||||||
|
try {
|
||||||
|
const compiledFormula = new Function('t', `return ${formula}`) as CompiledFormula
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
compiledFormula
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown compilation error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testFormula(formula: string): boolean {
|
||||||
|
const result = compileFormula(formula)
|
||||||
|
if (!result.success || !result.compiledFormula) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
result.compiledFormula(0)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/domain/audio/SampleGenerator.ts
Normal file
52
src/domain/audio/SampleGenerator.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { CompiledFormula } from './BytebeatCompiler'
|
||||||
|
|
||||||
|
export interface GeneratorOptions {
|
||||||
|
sampleRate: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSamples(
|
||||||
|
compiledFormula: CompiledFormula,
|
||||||
|
options: GeneratorOptions
|
||||||
|
): Float32Array {
|
||||||
|
const { sampleRate, duration } = options
|
||||||
|
const numSamples = Math.floor(sampleRate * duration)
|
||||||
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
|
for (let t = 0; t < numSamples; t++) {
|
||||||
|
try {
|
||||||
|
const value = compiledFormula(t)
|
||||||
|
const byteValue = value & 0xFF
|
||||||
|
buffer[t] = (byteValue - 128) / 128
|
||||||
|
} catch (error) {
|
||||||
|
buffer[t] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSamplesWithBitDepth(
|
||||||
|
compiledFormula: CompiledFormula,
|
||||||
|
options: GeneratorOptions,
|
||||||
|
bitDepth: 8 | 16 | 24
|
||||||
|
): Float32Array {
|
||||||
|
const { sampleRate, duration } = options
|
||||||
|
const numSamples = Math.floor(sampleRate * duration)
|
||||||
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
|
const maxValue = Math.pow(2, bitDepth) - 1
|
||||||
|
const midPoint = maxValue / 2
|
||||||
|
|
||||||
|
for (let t = 0; t < numSamples; t++) {
|
||||||
|
try {
|
||||||
|
const value = compiledFormula(t)
|
||||||
|
const clampedValue = value & maxValue
|
||||||
|
buffer[t] = (clampedValue - midPoint) / midPoint
|
||||||
|
} catch (error) {
|
||||||
|
buffer[t] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
25
src/domain/audio/WavExporter.ts
Normal file
25
src/domain/audio/WavExporter.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { encodeWAV } from '../../lib/bytebeat/wavEncoder'
|
||||||
|
import type { BitDepth } from '../../lib/bytebeat/types'
|
||||||
|
|
||||||
|
export type { BitDepth }
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
sampleRate: number
|
||||||
|
bitDepth?: BitDepth
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToWav(
|
||||||
|
samples: Float32Array,
|
||||||
|
options: ExportOptions
|
||||||
|
): Blob {
|
||||||
|
const bitDepth = options.bitDepth || 8
|
||||||
|
return encodeWAV(samples, options.sampleRate, bitDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDownloadUrl(blob: Blob): string {
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeDownloadUrl(url: string): void {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
63
src/domain/audio/effects/DelayEffect.ts
Normal file
63
src/domain/audio/effects/DelayEffect.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { Effect } from './Effect.interface'
|
||||||
|
|
||||||
|
export class DelayEffect implements Effect {
|
||||||
|
readonly id = 'delay'
|
||||||
|
|
||||||
|
private inputNode: GainNode
|
||||||
|
private outputNode: GainNode
|
||||||
|
private delayNode: DelayNode
|
||||||
|
private feedbackNode: GainNode
|
||||||
|
private wetNode: GainNode
|
||||||
|
private dryNode: GainNode
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext) {
|
||||||
|
this.inputNode = audioContext.createGain()
|
||||||
|
this.outputNode = audioContext.createGain()
|
||||||
|
this.delayNode = audioContext.createDelay(2.0)
|
||||||
|
this.feedbackNode = audioContext.createGain()
|
||||||
|
this.wetNode = audioContext.createGain()
|
||||||
|
this.dryNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.dryNode.gain.value = 1
|
||||||
|
this.wetNode.gain.value = 0
|
||||||
|
this.feedbackNode.gain.value = 0.5
|
||||||
|
|
||||||
|
this.inputNode.connect(this.dryNode)
|
||||||
|
this.inputNode.connect(this.delayNode)
|
||||||
|
this.delayNode.connect(this.feedbackNode)
|
||||||
|
this.feedbackNode.connect(this.delayNode)
|
||||||
|
this.delayNode.connect(this.wetNode)
|
||||||
|
this.dryNode.connect(this.outputNode)
|
||||||
|
this.wetNode.connect(this.outputNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(values: Record<string, number>): void {
|
||||||
|
if (values.delayTime !== undefined) {
|
||||||
|
this.delayNode.delayTime.value = values.delayTime / 1000
|
||||||
|
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
||||||
|
this.wetNode.gain.value = delayAmount
|
||||||
|
this.dryNode.gain.value = 1 - delayAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.delayFeedback !== undefined) {
|
||||||
|
this.feedbackNode.gain.value = values.delayFeedback / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
this.delayNode.disconnect()
|
||||||
|
this.feedbackNode.disconnect()
|
||||||
|
this.wetNode.disconnect()
|
||||||
|
this.dryNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/domain/audio/effects/Effect.interface.ts
Normal file
11
src/domain/audio/effects/Effect.interface.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface Effect {
|
||||||
|
readonly id: string
|
||||||
|
getInputNode(): AudioNode
|
||||||
|
getOutputNode(): AudioNode
|
||||||
|
updateParams(values: Record<string, number>): void
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectFactory {
|
||||||
|
create(audioContext: AudioContext): Effect
|
||||||
|
}
|
||||||
65
src/domain/audio/effects/EffectsChain.ts
Normal file
65
src/domain/audio/effects/EffectsChain.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { Effect } from './Effect.interface'
|
||||||
|
import { DelayEffect } from './DelayEffect'
|
||||||
|
import { ReverbEffect } from './ReverbEffect'
|
||||||
|
import { PassThroughEffect } from './PassThroughEffect'
|
||||||
|
|
||||||
|
export class EffectsChain {
|
||||||
|
private inputNode: GainNode
|
||||||
|
private outputNode: GainNode
|
||||||
|
private masterGainNode: GainNode
|
||||||
|
private effects: Effect[]
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext) {
|
||||||
|
this.inputNode = audioContext.createGain()
|
||||||
|
this.outputNode = audioContext.createGain()
|
||||||
|
this.masterGainNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.effects = [
|
||||||
|
new DelayEffect(audioContext),
|
||||||
|
new ReverbEffect(audioContext),
|
||||||
|
new PassThroughEffect(audioContext, 'bitcrush'),
|
||||||
|
new PassThroughEffect(audioContext, 'clipmode')
|
||||||
|
]
|
||||||
|
|
||||||
|
this.setupChain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupChain(): void {
|
||||||
|
let currentInput: AudioNode = this.inputNode
|
||||||
|
|
||||||
|
for (const effect of this.effects) {
|
||||||
|
currentInput.connect(effect.getInputNode())
|
||||||
|
currentInput = effect.getOutputNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInput.connect(this.masterGainNode)
|
||||||
|
this.masterGainNode.connect(this.outputNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEffects(values: Record<string, number>): void {
|
||||||
|
for (const effect of this.effects) {
|
||||||
|
effect.updateParams(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.masterVolume !== undefined) {
|
||||||
|
this.masterGainNode.gain.value = values.masterVolume / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const effect of this.effects) {
|
||||||
|
effect.dispose()
|
||||||
|
}
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
this.masterGainNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/domain/audio/effects/PassThroughEffect.ts
Normal file
27
src/domain/audio/effects/PassThroughEffect.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { Effect } from './Effect.interface'
|
||||||
|
|
||||||
|
export class PassThroughEffect implements Effect {
|
||||||
|
readonly id: string
|
||||||
|
private node: GainNode
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext, id: string) {
|
||||||
|
this.id = id
|
||||||
|
this.node = audioContext.createGain()
|
||||||
|
this.node.gain.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.node
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.node
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(_values: Record<string, number>): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.node.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/domain/audio/effects/ReverbEffect.ts
Normal file
70
src/domain/audio/effects/ReverbEffect.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { Effect } from './Effect.interface'
|
||||||
|
|
||||||
|
export class ReverbEffect implements Effect {
|
||||||
|
readonly id = 'reverb'
|
||||||
|
|
||||||
|
private audioContext: AudioContext
|
||||||
|
private inputNode: GainNode
|
||||||
|
private outputNode: GainNode
|
||||||
|
private convolverNode: ConvolverNode
|
||||||
|
private wetNode: GainNode
|
||||||
|
private dryNode: GainNode
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext) {
|
||||||
|
this.audioContext = audioContext
|
||||||
|
this.inputNode = audioContext.createGain()
|
||||||
|
this.outputNode = audioContext.createGain()
|
||||||
|
this.convolverNode = audioContext.createConvolver()
|
||||||
|
this.wetNode = audioContext.createGain()
|
||||||
|
this.dryNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.wetNode.gain.value = 0
|
||||||
|
this.dryNode.gain.value = 1
|
||||||
|
|
||||||
|
this.inputNode.connect(this.dryNode)
|
||||||
|
this.inputNode.connect(this.convolverNode)
|
||||||
|
this.convolverNode.connect(this.wetNode)
|
||||||
|
this.dryNode.connect(this.outputNode)
|
||||||
|
this.wetNode.connect(this.outputNode)
|
||||||
|
|
||||||
|
this.generateImpulseResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateImpulseResponse(): void {
|
||||||
|
const length = this.audioContext.sampleRate * 2
|
||||||
|
const impulse = this.audioContext.createBuffer(2, length, this.audioContext.sampleRate)
|
||||||
|
const left = impulse.getChannelData(0)
|
||||||
|
const right = impulse.getChannelData(1)
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
||||||
|
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.convolverNode.buffer = impulse
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
updateParams(values: Record<string, number>): void {
|
||||||
|
if (values.reverbWetDry !== undefined) {
|
||||||
|
const wet = values.reverbWetDry / 100
|
||||||
|
this.wetNode.gain.value = wet
|
||||||
|
this.dryNode.gain.value = 1 - wet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
this.convolverNode.disconnect()
|
||||||
|
this.wetNode.disconnect()
|
||||||
|
this.dryNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/domain/audio/index.ts
Normal file
6
src/domain/audio/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './BytebeatCompiler'
|
||||||
|
export * from './SampleGenerator'
|
||||||
|
export * from './WavExporter'
|
||||||
|
export * from './AudioPlayer'
|
||||||
|
export * from './effects/Effect.interface'
|
||||||
|
export * from './effects/EffectsChain'
|
||||||
@ -1,25 +1,20 @@
|
|||||||
import type { BytebeatOptions, BitDepth } from './types'
|
import type { BytebeatOptions, BitDepth } from './types'
|
||||||
import { encodeWAV } from './wavEncoder'
|
|
||||||
import { EffectsChain } from './EffectsChain'
|
|
||||||
import type { EffectValues } from '../../types/effects'
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
import { compileFormula } from '../../domain/audio/BytebeatCompiler'
|
||||||
|
import { generateSamples } from '../../domain/audio/SampleGenerator'
|
||||||
|
import { exportToWav } from '../../domain/audio/WavExporter'
|
||||||
|
import { AudioPlayer } from '../../domain/audio/AudioPlayer'
|
||||||
|
|
||||||
export class BytebeatGenerator {
|
export class BytebeatGenerator {
|
||||||
private sampleRate: number
|
private sampleRate: number
|
||||||
private duration: number
|
private duration: number
|
||||||
private formula: string | null = null
|
|
||||||
private compiledFormula: ((t: number) => number) | null = null
|
|
||||||
private audioBuffer: Float32Array | null = null
|
private audioBuffer: Float32Array | null = null
|
||||||
private audioContext: AudioContext | null = null
|
private audioPlayer: AudioPlayer
|
||||||
private sourceNode: AudioBufferSourceNode | null = null
|
|
||||||
private effectsChain: EffectsChain | null = null
|
|
||||||
private effectValues: EffectValues = {}
|
|
||||||
private startTime: number = 0
|
|
||||||
private pauseTime: number = 0
|
|
||||||
private isLooping: boolean = true
|
|
||||||
|
|
||||||
constructor(options: BytebeatOptions = {}) {
|
constructor(options: BytebeatOptions = {}) {
|
||||||
this.sampleRate = options.sampleRate ?? 8000
|
this.sampleRate = options.sampleRate ?? 8000
|
||||||
this.duration = options.duration ?? 10
|
this.duration = options.duration ?? 10
|
||||||
|
this.audioPlayer = new AudioPlayer({ sampleRate: this.sampleRate, duration: this.duration })
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOptions(options: Partial<BytebeatOptions>): void {
|
updateOptions(options: Partial<BytebeatOptions>): void {
|
||||||
@ -31,133 +26,71 @@ export class BytebeatGenerator {
|
|||||||
this.duration = options.duration
|
this.duration = options.duration
|
||||||
this.audioBuffer = null
|
this.audioBuffer = null
|
||||||
}
|
}
|
||||||
|
this.audioPlayer.updateOptions({ sampleRate: this.sampleRate, duration: this.duration })
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormula(formula: string): void {
|
setFormula(formula: string): void {
|
||||||
this.formula = formula
|
const result = compileFormula(formula)
|
||||||
try {
|
|
||||||
this.compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
|
if (!result.success || !result.compiledFormula) {
|
||||||
this.audioBuffer = null
|
throw new Error(`Invalid formula: ${result.error}`)
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Invalid formula: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.audioBuffer = generateSamples(result.compiledFormula, {
|
||||||
|
sampleRate: this.sampleRate,
|
||||||
|
duration: this.duration
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
generate(): Float32Array {
|
generate(): Float32Array {
|
||||||
if (!this.compiledFormula) {
|
if (!this.audioBuffer) {
|
||||||
throw new Error('No formula set. Call setFormula() first.')
|
throw new Error('No formula set. Call setFormula() first.')
|
||||||
}
|
}
|
||||||
|
return this.audioBuffer
|
||||||
const numSamples = Math.floor(this.sampleRate * this.duration)
|
|
||||||
const buffer = new Float32Array(numSamples)
|
|
||||||
|
|
||||||
for (let t = 0; t < numSamples; t++) {
|
|
||||||
try {
|
|
||||||
const value = this.compiledFormula(t)
|
|
||||||
const byteValue = value & 0xFF
|
|
||||||
buffer[t] = (byteValue - 128) / 128
|
|
||||||
} catch (error) {
|
|
||||||
buffer[t] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.audioBuffer = buffer
|
|
||||||
return buffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEffects(values: EffectValues): void {
|
setEffects(values: EffectValues): void {
|
||||||
this.effectValues = values
|
this.audioPlayer.setEffects(values)
|
||||||
if (this.effectsChain) {
|
|
||||||
this.effectsChain.updateEffects(values)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackPosition(): number {
|
getPlaybackPosition(): number {
|
||||||
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
|
return this.audioPlayer.getPlaybackPosition()
|
||||||
return 0
|
|
||||||
}
|
|
||||||
const elapsed = this.audioContext.currentTime - this.startTime
|
|
||||||
return (elapsed % this.duration) / this.duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): void {
|
play(): void {
|
||||||
if (!this.audioBuffer) {
|
if (!this.audioBuffer) {
|
||||||
this.generate()
|
throw new Error('No audio buffer. Call setFormula() first.')
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.effectsChain) {
|
|
||||||
this.effectsChain = new EffectsChain(this.audioContext)
|
|
||||||
this.effectsChain.updateEffects(this.effectValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.sourceNode) {
|
|
||||||
this.sourceNode.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBuffer = this.audioContext.createBuffer(1, this.audioBuffer!.length, this.sampleRate)
|
|
||||||
audioBuffer.getChannelData(0).set(this.audioBuffer!)
|
|
||||||
|
|
||||||
this.sourceNode = this.audioContext.createBufferSource()
|
|
||||||
this.sourceNode.buffer = audioBuffer
|
|
||||||
this.sourceNode.loop = this.isLooping
|
|
||||||
this.sourceNode.connect(this.effectsChain.getInputNode())
|
|
||||||
this.effectsChain.getOutputNode().connect(this.audioContext.destination)
|
|
||||||
|
|
||||||
if (this.pauseTime > 0) {
|
|
||||||
this.sourceNode.start(0, this.pauseTime)
|
|
||||||
this.startTime = this.audioContext.currentTime - this.pauseTime
|
|
||||||
this.pauseTime = 0
|
|
||||||
} else {
|
|
||||||
this.sourceNode.start(0)
|
|
||||||
this.startTime = this.audioContext.currentTime
|
|
||||||
}
|
}
|
||||||
|
this.audioPlayer.play(this.audioBuffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoopEnd(callback: () => void): void {
|
onLoopEnd(callback: () => void): void {
|
||||||
if (this.sourceNode && !this.sourceNode.loop) {
|
if (!this.audioBuffer) return
|
||||||
this.sourceNode.onended = callback
|
this.audioPlayer.setLooping(false)
|
||||||
}
|
this.audioPlayer.play(this.audioBuffer, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLooping(loop: boolean): void {
|
setLooping(loop: boolean): void {
|
||||||
this.isLooping = loop
|
this.audioPlayer.setLooping(loop)
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleNextTrack(callback: () => void): void {
|
scheduleNextTrack(callback: () => void): void {
|
||||||
if (this.audioContext && this.sourceNode) {
|
this.audioPlayer.scheduleNextTrack(callback)
|
||||||
this.sourceNode.loop = false
|
|
||||||
this.sourceNode.onended = () => {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pause(): void {
|
pause(): void {
|
||||||
if (this.sourceNode && this.audioContext) {
|
this.audioPlayer.pause()
|
||||||
this.pauseTime = this.audioContext.currentTime - this.startTime
|
|
||||||
this.sourceNode.stop()
|
|
||||||
this.sourceNode = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.sourceNode) {
|
this.audioPlayer.stop()
|
||||||
this.sourceNode.stop()
|
|
||||||
this.sourceNode = null
|
|
||||||
}
|
|
||||||
this.startTime = 0
|
|
||||||
this.pauseTime = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exportWAV(bitDepth: BitDepth = 8): Blob {
|
exportWAV(bitDepth: BitDepth = 8): Blob {
|
||||||
if (!this.audioBuffer) {
|
if (!this.audioBuffer) {
|
||||||
this.generate()
|
throw new Error('No audio buffer. Call setFormula() first.')
|
||||||
}
|
}
|
||||||
return encodeWAV(this.audioBuffer!, this.sampleRate, bitDepth)
|
return exportToWav(this.audioBuffer, { sampleRate: this.sampleRate, bitDepth })
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadWAV(filename: string = 'bytebeat.wav', bitDepth: BitDepth = 8): void {
|
downloadWAV(filename: string = 'bytebeat.wav', bitDepth: BitDepth = 8): void {
|
||||||
@ -171,14 +104,6 @@ export class BytebeatGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop()
|
this.audioPlayer.dispose()
|
||||||
if (this.effectsChain) {
|
|
||||||
this.effectsChain.dispose()
|
|
||||||
this.effectsChain = null
|
|
||||||
}
|
|
||||||
if (this.audioContext) {
|
|
||||||
this.audioContext.close()
|
|
||||||
this.audioContext = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,7 +70,6 @@ export class EffectsChain {
|
|||||||
const right = impulse.getChannelData(1)
|
const right = impulse.getChannelData(1)
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const n = length - i
|
|
||||||
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
||||||
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/services/DownloadService.ts
Normal file
86
src/services/DownloadService.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
import { compileFormula } from '../domain/audio/BytebeatCompiler'
|
||||||
|
import { generateSamples } from '../domain/audio/SampleGenerator'
|
||||||
|
import { exportToWav } from '../domain/audio/WavExporter'
|
||||||
|
import type { BitDepth } from '../domain/audio/WavExporter'
|
||||||
|
|
||||||
|
export interface DownloadOptions {
|
||||||
|
sampleRate?: number
|
||||||
|
duration?: number
|
||||||
|
bitDepth?: BitDepth
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadService {
|
||||||
|
private downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFormula(
|
||||||
|
formula: string,
|
||||||
|
filename: string,
|
||||||
|
options: DownloadOptions = {}
|
||||||
|
): boolean {
|
||||||
|
const {
|
||||||
|
sampleRate = 8000,
|
||||||
|
duration = 10,
|
||||||
|
bitDepth = 8
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const result = compileFormula(formula)
|
||||||
|
|
||||||
|
if (!result.success || !result.compiledFormula) {
|
||||||
|
console.error('Failed to compile formula:', result.error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||||
|
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||||
|
this.downloadBlob(blob, filename)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download formula:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAll(
|
||||||
|
formulas: string[][],
|
||||||
|
options: DownloadOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const {
|
||||||
|
sampleRate = 8000,
|
||||||
|
duration = 10,
|
||||||
|
bitDepth = 8
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
formulas.forEach((row, i) => {
|
||||||
|
row.forEach((formula, j) => {
|
||||||
|
const result = compileFormula(formula)
|
||||||
|
|
||||||
|
if (!result.success || !result.compiledFormula) {
|
||||||
|
console.error(`Failed to compile ${i}_${j}:`, result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||||
|
const blob = exportToWav(buffer, { sampleRate, bitDepth })
|
||||||
|
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate ${i}_${j}:`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = await zip.generateAsync({ type: 'blob' })
|
||||||
|
this.downloadBlob(content, 'bytebeats.zip')
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/services/PlaybackManager.ts
Normal file
81
src/services/PlaybackManager.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { compileFormula } from '../domain/audio/BytebeatCompiler'
|
||||||
|
import { generateSamples } from '../domain/audio/SampleGenerator'
|
||||||
|
import { AudioPlayer } from '../domain/audio/AudioPlayer'
|
||||||
|
import type { EffectValues } from '../types/effects'
|
||||||
|
|
||||||
|
export interface PlaybackOptions {
|
||||||
|
sampleRate: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaybackManager {
|
||||||
|
private player: AudioPlayer
|
||||||
|
private currentFormula: string | null = null
|
||||||
|
private currentBuffer: Float32Array | null = null
|
||||||
|
private queuedCallback: (() => void) | null = null
|
||||||
|
|
||||||
|
constructor(options: PlaybackOptions) {
|
||||||
|
this.player = new AudioPlayer(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOptions(options: Partial<PlaybackOptions>): void {
|
||||||
|
this.player.updateOptions(options)
|
||||||
|
this.currentBuffer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffects(values: EffectValues): void {
|
||||||
|
this.player.setEffects(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
play(formula: string, sampleRate: number, duration: number): boolean {
|
||||||
|
const result = compileFormula(formula)
|
||||||
|
|
||||||
|
if (!result.success || !result.compiledFormula) {
|
||||||
|
console.error('Failed to compile formula:', result.error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.currentBuffer = generateSamples(result.compiledFormula, { sampleRate, duration })
|
||||||
|
this.currentFormula = formula
|
||||||
|
this.player.setLooping(true)
|
||||||
|
this.player.play(this.currentBuffer)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate samples:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.player.stop()
|
||||||
|
this.currentFormula = null
|
||||||
|
this.queuedCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextTrack(callback: () => void): void {
|
||||||
|
this.queuedCallback = callback
|
||||||
|
this.player.scheduleNextTrack(() => {
|
||||||
|
if (this.queuedCallback) {
|
||||||
|
this.queuedCallback()
|
||||||
|
this.queuedCallback = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackPosition(): number {
|
||||||
|
return this.player.getPlaybackPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this.currentFormula !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentFormula(): string | null {
|
||||||
|
return this.currentFormula
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.player.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/services/index.ts
Normal file
2
src/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './PlaybackManager'
|
||||||
|
export * from './DownloadService'
|
||||||
@ -1,9 +1,15 @@
|
|||||||
import { persistentMap } from '@nanostores/persistent'
|
import { persistentMap } from '@nanostores/persistent'
|
||||||
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
|
||||||
|
|
||||||
export const engineSettings = persistentMap<Record<string, number>>('engine:', getDefaultEngineValues())
|
export const engineSettings = persistentMap('engine:', getDefaultEngineValues(), {
|
||||||
|
encode: JSON.stringify,
|
||||||
|
decode: JSON.parse
|
||||||
|
})
|
||||||
|
|
||||||
export const effectSettings = persistentMap<Record<string, number>>('effects:', {
|
export const effectSettings = persistentMap('effects:', {
|
||||||
...getDefaultEffectValues(),
|
...getDefaultEffectValues(),
|
||||||
masterVolume: 75
|
masterVolume: 75
|
||||||
|
}, {
|
||||||
|
encode: JSON.stringify,
|
||||||
|
decode: JSON.parse
|
||||||
})
|
})
|
||||||
@ -40,23 +40,10 @@ const TEMPLATES: Template[] = [
|
|||||||
{ pattern: "((t>>S1)|(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 }
|
{ pattern: "((t>>S1)|(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 }
|
||||||
]
|
]
|
||||||
|
|
||||||
const TOTAL_WEIGHT = TEMPLATES.reduce((sum, t) => sum + t.weight, 0)
|
|
||||||
|
|
||||||
function randomElement<T>(arr: T[]): T {
|
function randomElement<T>(arr: T[]): T {
|
||||||
return arr[Math.floor(Math.random() * arr.length)]
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickTemplate(): Template {
|
|
||||||
let random = Math.random() * TOTAL_WEIGHT
|
|
||||||
for (const template of TEMPLATES) {
|
|
||||||
random -= template.weight
|
|
||||||
if (random <= 0) {
|
|
||||||
return template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TEMPLATES[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillTemplate(pattern: string): string {
|
function fillTemplate(pattern: string): string {
|
||||||
let formula = pattern
|
let formula = pattern
|
||||||
|
|
||||||
|
|||||||
20
src/utils/formatters.ts
Normal file
20
src/utils/formatters.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { SAMPLE_RATES } from '../config/effects'
|
||||||
|
|
||||||
|
export function getComplexityLabel(index: number): string {
|
||||||
|
const labels = ['Simple', 'Medium', 'Complex']
|
||||||
|
return labels[index] || 'Medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBitDepthLabel(index: number): string {
|
||||||
|
const labels = ['8bit', '16bit', '24bit']
|
||||||
|
return labels[index] || '8bit'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClipModeLabel(index: number): string {
|
||||||
|
const labels = ['Wrap', 'Clamp', 'Fold']
|
||||||
|
return labels[index] || 'Wrap'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSampleRateLabel(index: number): string {
|
||||||
|
return `${SAMPLE_RATES[index]}Hz`
|
||||||
|
}
|
||||||
58
src/utils/waveformGenerator.ts
Normal file
58
src/utils/waveformGenerator.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export function generateWaveformData(formula: string, width: number, sampleRate: number = 8000, duration: number = 0.5): number[] {
|
||||||
|
try {
|
||||||
|
const compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
|
||||||
|
const samplesPerPixel = Math.floor((sampleRate * duration) / width)
|
||||||
|
const waveform: number[] = []
|
||||||
|
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let min = Infinity
|
||||||
|
let max = -Infinity
|
||||||
|
|
||||||
|
for (let s = 0; s < samplesPerPixel; s++) {
|
||||||
|
const t = x * samplesPerPixel + s
|
||||||
|
try {
|
||||||
|
const value = compiledFormula(t)
|
||||||
|
const byteValue = value & 0xFF
|
||||||
|
const normalized = (byteValue - 128) / 128
|
||||||
|
min = Math.min(min, normalized)
|
||||||
|
max = Math.max(max, normalized)
|
||||||
|
} catch {
|
||||||
|
min = Math.min(min, 0)
|
||||||
|
max = Math.max(max, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waveform.push(min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
return waveform
|
||||||
|
} catch {
|
||||||
|
return new Array(width * 2).fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawWaveform(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
waveformData: number[],
|
||||||
|
color: string = 'rgba(255, 255, 255, 0.15)'
|
||||||
|
): void {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const width = canvas.width
|
||||||
|
const height = canvas.height
|
||||||
|
const centerY = height / 2
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.beginPath()
|
||||||
|
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const min = waveformData[x * 2] || 0
|
||||||
|
const max = waveformData[x * 2 + 1] || 0
|
||||||
|
const y1 = centerY + min * centerY
|
||||||
|
const y2 = centerY + max * centerY
|
||||||
|
|
||||||
|
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user