@@ -22,6 +30,8 @@ export function EffectsBar({ values, onChange }: EffectsBarProps) {
step={param.step}
unit={param.unit}
onChange={(value) => onChange(param.id, value)}
+ formatValue={param.id === 'clipMode' ? formatValue : undefined}
+ valueId={param.id}
/>
))
)}
diff --git a/src/components/EngineControls.tsx b/src/components/EngineControls.tsx
index 2370bf0b..20109295 100644
--- a/src/components/EngineControls.tsx
+++ b/src/components/EngineControls.tsx
@@ -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'
interface EngineControlsProps {
@@ -10,9 +11,11 @@ export function EngineControls({ values, onChange }: EngineControlsProps) {
const formatValue = (id: string, value: number): string => {
switch (id) {
case 'sampleRate':
- return `${SAMPLE_RATES[value]}Hz`
+ return getSampleRateLabel(value)
case 'complexity':
return getComplexityLabel(value)
+ case 'bitDepth':
+ return getBitDepthLabel(value)
default:
const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id)
return `${value}${param?.unit || ''}`
diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx
index 5ec2109c..ac49990d 100644
--- a/src/components/Slider.tsx
+++ b/src/components/Slider.tsx
@@ -6,9 +6,13 @@ interface SliderProps {
step: number
unit?: string
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 (
@@ -16,7 +20,7 @@ export function Slider({ label, value, min, max, step, unit, onChange }: SliderP
{label.toUpperCase()}
- {value}{unit}
+ {displayValue}
): 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
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/BytebeatCompiler.ts b/src/domain/audio/BytebeatCompiler.ts
new file mode 100644
index 00000000..6903c7b6
--- /dev/null
+++ b/src/domain/audio/BytebeatCompiler.ts
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/SampleGenerator.ts b/src/domain/audio/SampleGenerator.ts
new file mode 100644
index 00000000..47d718e0
--- /dev/null
+++ b/src/domain/audio/SampleGenerator.ts
@@ -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
+}
\ No newline at end of file
diff --git a/src/domain/audio/WavExporter.ts b/src/domain/audio/WavExporter.ts
new file mode 100644
index 00000000..22592338
--- /dev/null
+++ b/src/domain/audio/WavExporter.ts
@@ -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)
+}
\ No newline at end of file
diff --git a/src/domain/audio/effects/DelayEffect.ts b/src/domain/audio/effects/DelayEffect.ts
new file mode 100644
index 00000000..7bc3e910
--- /dev/null
+++ b/src/domain/audio/effects/DelayEffect.ts
@@ -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
): 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()
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/effects/Effect.interface.ts b/src/domain/audio/effects/Effect.interface.ts
new file mode 100644
index 00000000..d2f2dd3d
--- /dev/null
+++ b/src/domain/audio/effects/Effect.interface.ts
@@ -0,0 +1,11 @@
+export interface Effect {
+ readonly id: string
+ getInputNode(): AudioNode
+ getOutputNode(): AudioNode
+ updateParams(values: Record): void
+ dispose(): void
+}
+
+export interface EffectFactory {
+ create(audioContext: AudioContext): Effect
+}
\ No newline at end of file
diff --git a/src/domain/audio/effects/EffectsChain.ts b/src/domain/audio/effects/EffectsChain.ts
new file mode 100644
index 00000000..1ed63c84
--- /dev/null
+++ b/src/domain/audio/effects/EffectsChain.ts
@@ -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): 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()
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/effects/PassThroughEffect.ts b/src/domain/audio/effects/PassThroughEffect.ts
new file mode 100644
index 00000000..32274045
--- /dev/null
+++ b/src/domain/audio/effects/PassThroughEffect.ts
@@ -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): void {
+ }
+
+ dispose(): void {
+ this.node.disconnect()
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/effects/ReverbEffect.ts b/src/domain/audio/effects/ReverbEffect.ts
new file mode 100644
index 00000000..3015a452
--- /dev/null
+++ b/src/domain/audio/effects/ReverbEffect.ts
@@ -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): 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()
+ }
+}
\ No newline at end of file
diff --git a/src/domain/audio/index.ts b/src/domain/audio/index.ts
new file mode 100644
index 00000000..0f14e59e
--- /dev/null
+++ b/src/domain/audio/index.ts
@@ -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'
\ No newline at end of file
diff --git a/src/lib/bytebeat/BytebeatGenerator.ts b/src/lib/bytebeat/BytebeatGenerator.ts
index c09a65ee..b404b459 100644
--- a/src/lib/bytebeat/BytebeatGenerator.ts
+++ b/src/lib/bytebeat/BytebeatGenerator.ts
@@ -1,25 +1,20 @@
import type { BytebeatOptions, BitDepth } from './types'
-import { encodeWAV } from './wavEncoder'
-import { EffectsChain } from './EffectsChain'
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 {
private sampleRate: number
private duration: number
- private formula: string | null = null
- private compiledFormula: ((t: number) => number) | null = null
private audioBuffer: Float32Array | null = null
- 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 audioPlayer: AudioPlayer
constructor(options: BytebeatOptions = {}) {
this.sampleRate = options.sampleRate ?? 8000
this.duration = options.duration ?? 10
+ this.audioPlayer = new AudioPlayer({ sampleRate: this.sampleRate, duration: this.duration })
}
updateOptions(options: Partial): void {
@@ -31,133 +26,71 @@ export class BytebeatGenerator {
this.duration = options.duration
this.audioBuffer = null
}
+ this.audioPlayer.updateOptions({ sampleRate: this.sampleRate, duration: this.duration })
}
setFormula(formula: string): void {
- this.formula = formula
- try {
- this.compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
- this.audioBuffer = null
- } catch (error) {
- throw new Error(`Invalid formula: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ const result = compileFormula(formula)
+
+ if (!result.success || !result.compiledFormula) {
+ throw new Error(`Invalid formula: ${result.error}`)
}
+
+ this.audioBuffer = generateSamples(result.compiledFormula, {
+ sampleRate: this.sampleRate,
+ duration: this.duration
+ })
}
generate(): Float32Array {
- if (!this.compiledFormula) {
+ if (!this.audioBuffer) {
throw new Error('No formula set. Call setFormula() first.')
}
-
- 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
+ return this.audioBuffer
}
setEffects(values: EffectValues): void {
- this.effectValues = values
- if (this.effectsChain) {
- this.effectsChain.updateEffects(values)
- }
+ this.audioPlayer.setEffects(values)
}
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
+ return this.audioPlayer.getPlaybackPosition()
}
play(): void {
if (!this.audioBuffer) {
- this.generate()
- }
-
- 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
+ throw new Error('No audio buffer. Call setFormula() first.')
}
+ this.audioPlayer.play(this.audioBuffer)
}
onLoopEnd(callback: () => void): void {
- if (this.sourceNode && !this.sourceNode.loop) {
- this.sourceNode.onended = callback
- }
+ if (!this.audioBuffer) return
+ this.audioPlayer.setLooping(false)
+ this.audioPlayer.play(this.audioBuffer, callback)
}
setLooping(loop: boolean): void {
- this.isLooping = loop
+ this.audioPlayer.setLooping(loop)
}
scheduleNextTrack(callback: () => void): void {
- if (this.audioContext && this.sourceNode) {
- this.sourceNode.loop = false
- this.sourceNode.onended = () => {
- callback()
- }
- }
+ this.audioPlayer.scheduleNextTrack(callback)
}
pause(): void {
- if (this.sourceNode && this.audioContext) {
- this.pauseTime = this.audioContext.currentTime - this.startTime
- this.sourceNode.stop()
- this.sourceNode = null
- }
+ this.audioPlayer.pause()
}
stop(): void {
- if (this.sourceNode) {
- this.sourceNode.stop()
- this.sourceNode = null
- }
- this.startTime = 0
- this.pauseTime = 0
+ this.audioPlayer.stop()
}
exportWAV(bitDepth: BitDepth = 8): Blob {
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 {
@@ -171,14 +104,6 @@ export class BytebeatGenerator {
}
dispose(): void {
- this.stop()
- if (this.effectsChain) {
- this.effectsChain.dispose()
- this.effectsChain = null
- }
- if (this.audioContext) {
- this.audioContext.close()
- this.audioContext = null
- }
+ this.audioPlayer.dispose()
}
}
\ No newline at end of file
diff --git a/src/lib/bytebeat/EffectsChain.ts b/src/lib/bytebeat/EffectsChain.ts
index 56d6d340..a1936173 100644
--- a/src/lib/bytebeat/EffectsChain.ts
+++ b/src/lib/bytebeat/EffectsChain.ts
@@ -70,7 +70,6 @@ export class EffectsChain {
const right = impulse.getChannelData(1)
for (let i = 0; i < length; i++) {
- const n = 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)
}
diff --git a/src/services/DownloadService.ts b/src/services/DownloadService.ts
new file mode 100644
index 00000000..c5388cdc
--- /dev/null
+++ b/src/services/DownloadService.ts
@@ -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 {
+ 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')
+ }
+}
\ No newline at end of file
diff --git a/src/services/PlaybackManager.ts b/src/services/PlaybackManager.ts
new file mode 100644
index 00000000..d5681fdb
--- /dev/null
+++ b/src/services/PlaybackManager.ts
@@ -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): 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()
+ }
+}
\ No newline at end of file
diff --git a/src/services/index.ts b/src/services/index.ts
new file mode 100644
index 00000000..fa6ed36b
--- /dev/null
+++ b/src/services/index.ts
@@ -0,0 +1,2 @@
+export * from './PlaybackManager'
+export * from './DownloadService'
\ No newline at end of file
diff --git a/src/stores/settings.ts b/src/stores/settings.ts
index 026d8c51..e2a6e65c 100644
--- a/src/stores/settings.ts
+++ b/src/stores/settings.ts
@@ -1,9 +1,15 @@
import { persistentMap } from '@nanostores/persistent'
import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects'
-export const engineSettings = persistentMap>('engine:', getDefaultEngineValues())
+export const engineSettings = persistentMap('engine:', getDefaultEngineValues(), {
+ encode: JSON.stringify,
+ decode: JSON.parse
+})
-export const effectSettings = persistentMap>('effects:', {
+export const effectSettings = persistentMap('effects:', {
...getDefaultEffectValues(),
masterVolume: 75
+}, {
+ encode: JSON.stringify,
+ decode: JSON.parse
})
\ No newline at end of file
diff --git a/src/utils/bytebeatFormulas.ts b/src/utils/bytebeatFormulas.ts
index 34b1926e..43d86a86 100644
--- a/src/utils/bytebeatFormulas.ts
+++ b/src/utils/bytebeatFormulas.ts
@@ -40,23 +40,10 @@ const TEMPLATES: Template[] = [
{ pattern: "((t>>S1)|(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 }
]
-const TOTAL_WEIGHT = TEMPLATES.reduce((sum, t) => sum + t.weight, 0)
-
function randomElement(arr: T[]): T {
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 {
let formula = pattern
diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts
new file mode 100644
index 00000000..b71ba662
--- /dev/null
+++ b/src/utils/formatters.ts
@@ -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`
+}
\ No newline at end of file
diff --git a/src/utils/waveformGenerator.ts b/src/utils/waveformGenerator.ts
new file mode 100644
index 00000000..5b6ea35e
--- /dev/null
+++ b/src/utils/waveformGenerator.ts
@@ -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))
+ }
+}
\ No newline at end of file