modularity
This commit is contained in:
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'
|
||||
Reference in New Issue
Block a user