mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +08:00
Refactor radio audio effects and use in learn page
This commit is contained in:
157
app/pages/pm.vue
157
app/pages/pm.vue
@@ -894,6 +894,9 @@ import { useRouter } from 'vue-router'
|
||||
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||
import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||
import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects'
|
||||
|
||||
// Core State
|
||||
const engine = useCommunicationsEngine()
|
||||
@@ -1266,6 +1269,7 @@ const simulationStepCount = simulationPilotSteps.length
|
||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let pizzicatoLite: PizzicatoLite | null = null
|
||||
let speechQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
const enqueueSpeech = (task: () => Promise<void>) => {
|
||||
@@ -1341,102 +1345,93 @@ const ensureAudioContext = async (): Promise<AudioContext | null> => {
|
||||
return audioContext
|
||||
}
|
||||
|
||||
const playAudioWithEffects = async (base64: string) => {
|
||||
const ensurePizzicato = async (ctx: AudioContext | null): Promise<PizzicatoLite | null> => {
|
||||
if (!ctx) return null
|
||||
if (!pizzicatoLite) {
|
||||
pizzicatoLite = await loadPizzicatoLite()
|
||||
}
|
||||
return pizzicatoLite
|
||||
}
|
||||
|
||||
|
||||
const playAudioWithEffects = async (base64: string, mime = 'audio/wav') => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
if (!radioEffectsEnabled.value) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const audio = new Audio(`data:audio/wav;base64,${base64}`)
|
||||
const dataUrl = `data:${mime || 'audio/wav'};base64,${base64}`
|
||||
|
||||
const playWithoutEffects = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const audio = new Audio(dataUrl)
|
||||
audio.onended = () => resolve()
|
||||
audio.onerror = () => resolve()
|
||||
audio.play().catch(() => resolve())
|
||||
})
|
||||
|
||||
if (!radioEffectsEnabled.value) {
|
||||
await playWithoutEffects()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = await ensureAudioContext()
|
||||
if (!ctx) throw new Error('AudioContext unavailable')
|
||||
const pizzicato = await ensurePizzicato(ctx)
|
||||
if (!ctx || !pizzicato) throw new Error('Audio engine unavailable')
|
||||
|
||||
const binary = Uint8Array.from(atob(base64), c => c.charCodeAt(0))
|
||||
const arrayBuffer = binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength)
|
||||
const buffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
const sound = await pizzicato.createSoundFromBase64(ctx, base64)
|
||||
const readability = Math.max(1, Math.min(5, signalStrength.value))
|
||||
const profile = getReadabilityProfile(readability)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const source = ctx.createBufferSource()
|
||||
source.buffer = buffer
|
||||
const { Effects } = pizzicato
|
||||
|
||||
const highpass = ctx.createBiquadFilter()
|
||||
highpass.type = 'highpass'
|
||||
highpass.frequency.value = 320
|
||||
|
||||
const lowpass = ctx.createBiquadFilter()
|
||||
lowpass.type = 'lowpass'
|
||||
lowpass.frequency.value = 3100
|
||||
|
||||
const compressor = ctx.createDynamicsCompressor()
|
||||
compressor.threshold.value = -28
|
||||
compressor.knee.value = 30
|
||||
compressor.ratio.value = 12
|
||||
compressor.attack.value = 0.003
|
||||
compressor.release.value = 0.25
|
||||
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.value = 0.9
|
||||
|
||||
source.connect(highpass)
|
||||
highpass.connect(lowpass)
|
||||
lowpass.connect(compressor)
|
||||
compressor.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
let noiseSource: AudioBufferSourceNode | null = null
|
||||
|
||||
if (buffer.duration > 0) {
|
||||
const length = Math.ceil(buffer.duration * ctx.sampleRate)
|
||||
const noiseBuffer = ctx.createBuffer(1, length, ctx.sampleRate)
|
||||
const channel = noiseBuffer.getChannelData(0)
|
||||
const strength = Math.max(1, Math.min(5, signalStrength.value))
|
||||
const intensity = (6 - strength) / 6
|
||||
const amplitude = 0.015 + intensity * 0.045
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
channel[i] = (Math.random() * 2 - 1) * amplitude
|
||||
}
|
||||
noiseSource = ctx.createBufferSource()
|
||||
noiseSource.buffer = noiseBuffer
|
||||
const bandPass = ctx.createBiquadFilter()
|
||||
bandPass.type = 'bandpass'
|
||||
bandPass.frequency.value = 1800
|
||||
bandPass.Q.value = 1.2
|
||||
const noiseGain = ctx.createGain()
|
||||
noiseGain.gain.value = amplitude * 0.6
|
||||
noiseSource.connect(bandPass)
|
||||
bandPass.connect(noiseGain)
|
||||
noiseGain.connect(ctx.destination)
|
||||
noiseSource.start(0)
|
||||
}
|
||||
|
||||
source.onended = () => {
|
||||
if (noiseSource) {
|
||||
try {
|
||||
noiseSource.stop()
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
source.start(0)
|
||||
const highpass = new Effects.HighPassFilter(ctx, {
|
||||
frequency: profile.eq.highpass,
|
||||
q: profile.eq.highpassQ
|
||||
})
|
||||
const lowpass = new Effects.LowPassFilter(ctx, {
|
||||
frequency: profile.eq.lowpass,
|
||||
q: profile.eq.lowpassQ
|
||||
})
|
||||
sound.addEffect(highpass)
|
||||
sound.addEffect(lowpass)
|
||||
|
||||
if (profile.eq.bandpass) {
|
||||
sound.addEffect(
|
||||
new Effects.BandPassFilter(ctx, {
|
||||
frequency: profile.eq.bandpass.frequency,
|
||||
q: profile.eq.bandpass.q
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (profile.presence) {
|
||||
sound.addEffect(new Effects.PeakingFilter(ctx, profile.presence))
|
||||
}
|
||||
|
||||
profile.distortions.forEach((amount) => {
|
||||
sound.addEffect(new Effects.Distortion(ctx, { amount }))
|
||||
})
|
||||
|
||||
sound.addEffect(new Effects.Compressor(ctx, profile.compressor))
|
||||
|
||||
if (profile.tremolos) {
|
||||
profile.tremolos.forEach((tremolo) => {
|
||||
sound.addEffect(new Effects.Tremolo(ctx, tremolo))
|
||||
})
|
||||
}
|
||||
|
||||
sound.setVolume(profile.gain)
|
||||
|
||||
const stopNoiseGenerators = createNoiseGenerators(ctx, sound.duration, profile, readability)
|
||||
|
||||
try {
|
||||
await sound.play()
|
||||
} finally {
|
||||
stopNoiseGenerators.forEach((stop) => stop())
|
||||
sound.clearEffects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to apply radio effect', err)
|
||||
await new Promise<void>((resolve) => {
|
||||
const audio = new Audio(`data:audio/wav;base64,${base64}`)
|
||||
audio.onended = () => resolve()
|
||||
audio.onerror = () => resolve()
|
||||
audio.play().catch(() => resolve())
|
||||
})
|
||||
await playWithoutEffects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,7 +1451,7 @@ const speakPrepared = async (prepared: PreparedSpeech, options: SpeechOptions =
|
||||
if (options.updateLastTransmission !== false) {
|
||||
setLastTransmission(options.lastTransmissionLabel || `ATC: ${prepared.plain}`)
|
||||
}
|
||||
await playAudioWithEffects(response.audio.base64)
|
||||
await playAudioWithEffects(response.audio.base64, response.audio.mime)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TTS failed:', err)
|
||||
@@ -1499,7 +1494,7 @@ const speakPlainText = (text: string, options: SpeechOptions = {}) => {
|
||||
if (options.updateLastTransmission !== false) {
|
||||
setLastTransmission(options.lastTransmissionLabel || trimmed)
|
||||
}
|
||||
await playAudioWithEffects(response.audio.base64)
|
||||
await playAudioWithEffects(response.audio.base64, response.audio.mime)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TTS failed:', err)
|
||||
|
||||
356
shared/utils/pizzicatoLite.ts
Normal file
356
shared/utils/pizzicatoLite.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
// Minimal client-side audio helper inspired by Pizzicato.js for radio processing.
|
||||
// This is not a full implementation of the original library, but exposes a similar
|
||||
// API surface for the features we need (filters, distortion and tremolo) so that we
|
||||
// can build complex chains on the client without an external dependency.
|
||||
|
||||
export type SupportedEffect =
|
||||
| HighPassFilterEffect
|
||||
| LowPassFilterEffect
|
||||
| BandPassFilterEffect
|
||||
| PeakingFilterEffect
|
||||
| CompressorEffect
|
||||
| DistortionEffect
|
||||
| TremoloEffect
|
||||
|
||||
export interface PizzicatoLite {
|
||||
createSoundFromBase64(context: AudioContext, base64: string): Promise<PizzicatoSound>
|
||||
Effects: {
|
||||
HighPassFilter: typeof HighPassFilterEffect
|
||||
LowPassFilter: typeof LowPassFilterEffect
|
||||
BandPassFilter: typeof BandPassFilterEffect
|
||||
PeakingFilter: typeof PeakingFilterEffect
|
||||
Compressor: typeof CompressorEffect
|
||||
Distortion: typeof DistortionEffect
|
||||
Tremolo: typeof TremoloEffect
|
||||
}
|
||||
}
|
||||
|
||||
interface EffectNode {
|
||||
inputNode: AudioNode
|
||||
outputNode: AudioNode
|
||||
onActivate?(): void
|
||||
onDeactivate?(): void
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
||||
|
||||
const createDistortionCurve = (amount: number, context: AudioContext) => {
|
||||
const samples = 44100
|
||||
const curve = new Float32Array(samples)
|
||||
const deg = Math.PI / 180
|
||||
const scaled = clamp(amount, 0, 1000)
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const x = (i * 2) / samples - 1
|
||||
curve[i] = ((3 + scaled) * x * 20 * deg) / (Math.PI + scaled * Math.abs(x))
|
||||
}
|
||||
return curve
|
||||
}
|
||||
|
||||
class PizzicatoSound {
|
||||
private context: AudioContext
|
||||
private buffer: AudioBuffer
|
||||
private outputNode: GainNode
|
||||
private sourceNode: AudioBufferSourceNode | null = null
|
||||
private effects: EffectNode[] = []
|
||||
private isPlaying = false
|
||||
|
||||
constructor(context: AudioContext, buffer: AudioBuffer) {
|
||||
this.context = context
|
||||
this.buffer = buffer
|
||||
this.outputNode = context.createGain()
|
||||
this.outputNode.gain.value = 1
|
||||
this.outputNode.connect(this.context.destination)
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.buffer.duration
|
||||
}
|
||||
|
||||
addEffect(effect: EffectNode) {
|
||||
this.effects.push(effect)
|
||||
}
|
||||
|
||||
clearEffects() {
|
||||
this.effects.forEach(effect => effect.onDeactivate?.())
|
||||
this.effects = []
|
||||
}
|
||||
|
||||
setVolume(value: number) {
|
||||
this.outputNode.gain.value = clamp(value, 0, 2)
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.isPlaying) {
|
||||
this.stop()
|
||||
}
|
||||
|
||||
const source = this.context.createBufferSource()
|
||||
source.buffer = this.buffer
|
||||
|
||||
const connectedNodes: AudioNode[] = []
|
||||
|
||||
let currentNode: AudioNode = source
|
||||
for (const effect of this.effects) {
|
||||
connectedNodes.push(currentNode)
|
||||
currentNode.connect(effect.inputNode)
|
||||
currentNode = effect.outputNode
|
||||
effect.onActivate?.()
|
||||
}
|
||||
|
||||
connectedNodes.push(currentNode)
|
||||
currentNode.connect(this.outputNode)
|
||||
|
||||
this.sourceNode = source
|
||||
this.isPlaying = true
|
||||
|
||||
return new Promise((resolve) => {
|
||||
source.onended = () => {
|
||||
this.isPlaying = false
|
||||
connectedNodes.forEach(node => {
|
||||
try {
|
||||
node.disconnect()
|
||||
} catch {
|
||||
// ignore disconnect errors
|
||||
}
|
||||
})
|
||||
this.effects.forEach(effect => effect.onDeactivate?.())
|
||||
this.sourceNode = null
|
||||
resolve()
|
||||
}
|
||||
|
||||
try {
|
||||
source.start(0)
|
||||
} catch {
|
||||
// If start fails we still resolve to avoid blocking playback queue
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isPlaying || !this.sourceNode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.sourceNode.stop()
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FilterEffect implements EffectNode {
|
||||
protected context: AudioContext
|
||||
protected filter: BiquadFilterNode
|
||||
inputNode: AudioNode
|
||||
outputNode: AudioNode
|
||||
|
||||
constructor(context: AudioContext, type: BiquadFilterType, options: { frequency?: number; q?: number; gain?: number }) {
|
||||
this.context = context
|
||||
this.filter = context.createBiquadFilter()
|
||||
this.filter.type = type
|
||||
if (typeof options.frequency === 'number') {
|
||||
this.filter.frequency.value = options.frequency
|
||||
}
|
||||
if (typeof options.q === 'number') {
|
||||
this.filter.Q.value = options.q
|
||||
}
|
||||
if (typeof options.gain === 'number') {
|
||||
this.filter.gain.value = options.gain
|
||||
}
|
||||
this.inputNode = this.filter
|
||||
this.outputNode = this.filter
|
||||
}
|
||||
|
||||
onActivate() {}
|
||||
onDeactivate() {}
|
||||
}
|
||||
|
||||
class HighPassFilterEffect extends FilterEffect {
|
||||
constructor(context: AudioContext, options: { frequency?: number; q?: number } = {}) {
|
||||
super(context, 'highpass', options)
|
||||
if (!options.frequency) {
|
||||
this.filter.frequency.value = 300
|
||||
}
|
||||
if (!options.q) {
|
||||
this.filter.Q.value = 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LowPassFilterEffect extends FilterEffect {
|
||||
constructor(context: AudioContext, options: { frequency?: number; q?: number } = {}) {
|
||||
super(context, 'lowpass', options)
|
||||
if (!options.frequency) {
|
||||
this.filter.frequency.value = 3200
|
||||
}
|
||||
if (!options.q) {
|
||||
this.filter.Q.value = 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BandPassFilterEffect extends FilterEffect {
|
||||
constructor(context: AudioContext, options: { frequency?: number; q?: number } = {}) {
|
||||
super(context, 'bandpass', options)
|
||||
if (!options.frequency) {
|
||||
this.filter.frequency.value = 1500
|
||||
}
|
||||
if (!options.q) {
|
||||
this.filter.Q.value = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PeakingFilterEffect extends FilterEffect {
|
||||
constructor(context: AudioContext, options: { frequency?: number; q?: number; gain?: number } = {}) {
|
||||
super(context, 'peaking', options)
|
||||
if (!options.frequency) {
|
||||
this.filter.frequency.value = 2200
|
||||
}
|
||||
if (!options.q) {
|
||||
this.filter.Q.value = 1.1
|
||||
}
|
||||
if (!options.gain) {
|
||||
this.filter.gain.value = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CompressorEffect implements EffectNode {
|
||||
private compressor: DynamicsCompressorNode
|
||||
inputNode: AudioNode
|
||||
outputNode: AudioNode
|
||||
|
||||
constructor(context: AudioContext, options: Partial<{ threshold: number; knee: number; ratio: number; attack: number; release: number }> = {}) {
|
||||
this.compressor = context.createDynamicsCompressor()
|
||||
this.compressor.threshold.value = options.threshold ?? -28
|
||||
this.compressor.knee.value = options.knee ?? 30
|
||||
this.compressor.ratio.value = options.ratio ?? 12
|
||||
this.compressor.attack.value = options.attack ?? 0.003
|
||||
this.compressor.release.value = options.release ?? 0.25
|
||||
this.inputNode = this.compressor
|
||||
this.outputNode = this.compressor
|
||||
}
|
||||
|
||||
onActivate() {}
|
||||
onDeactivate() {}
|
||||
}
|
||||
|
||||
class DistortionEffect implements EffectNode {
|
||||
private shaper: WaveShaperNode
|
||||
inputNode: AudioNode
|
||||
outputNode: AudioNode
|
||||
|
||||
constructor(context: AudioContext, options: { amount?: number } = {}) {
|
||||
this.shaper = context.createWaveShaper()
|
||||
const amount = options.amount ?? 180
|
||||
this.shaper.curve = createDistortionCurve(amount, context)
|
||||
this.shaper.oversample = '4x'
|
||||
this.inputNode = this.shaper
|
||||
this.outputNode = this.shaper
|
||||
}
|
||||
|
||||
setAmount(context: AudioContext, amount: number) {
|
||||
this.shaper.curve = createDistortionCurve(amount, context)
|
||||
}
|
||||
|
||||
onActivate() {}
|
||||
onDeactivate() {}
|
||||
}
|
||||
|
||||
class TremoloEffect implements EffectNode {
|
||||
private context: AudioContext
|
||||
private input: GainNode
|
||||
private output: GainNode
|
||||
private oscillator: OscillatorNode
|
||||
private modulationGain: GainNode
|
||||
private started = false
|
||||
private depth: number
|
||||
|
||||
inputNode: AudioNode
|
||||
outputNode: AudioNode
|
||||
|
||||
constructor(context: AudioContext, options: { speed?: number; depth?: number; type?: OscillatorType } = {}) {
|
||||
this.context = context
|
||||
this.input = context.createGain()
|
||||
this.output = context.createGain()
|
||||
this.modulationGain = context.createGain()
|
||||
|
||||
this.depth = clamp(options.depth ?? 0.4, 0, 0.99)
|
||||
const base = 1 - this.depth / 2
|
||||
this.output.gain.value = base
|
||||
this.modulationGain.gain.value = this.depth / 2
|
||||
|
||||
this.oscillator = context.createOscillator()
|
||||
this.oscillator.type = options.type ?? 'sine'
|
||||
this.oscillator.frequency.value = options.speed ?? 5
|
||||
|
||||
this.input.connect(this.output)
|
||||
this.oscillator.connect(this.modulationGain)
|
||||
this.modulationGain.connect(this.output.gain)
|
||||
|
||||
this.inputNode = this.input
|
||||
this.outputNode = this.output
|
||||
}
|
||||
|
||||
onActivate() {
|
||||
if (this.started) return
|
||||
try {
|
||||
this.oscillator.start()
|
||||
this.started = true
|
||||
} catch {
|
||||
// oscillator already started
|
||||
}
|
||||
}
|
||||
|
||||
onDeactivate() {
|
||||
if (!this.started) return
|
||||
try {
|
||||
this.oscillator.stop(this.context.currentTime + 0.05)
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
this.started = false
|
||||
}
|
||||
}
|
||||
|
||||
const decodeBase64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
||||
const binary = atob(base64)
|
||||
const len = binary.length
|
||||
const buffer = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
buffer[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return buffer.buffer
|
||||
}
|
||||
|
||||
let cachedInstance: PizzicatoLite | null = null
|
||||
|
||||
export const loadPizzicatoLite = async (): Promise<PizzicatoLite | null> => {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (cachedInstance) return cachedInstance
|
||||
|
||||
const instance: PizzicatoLite = {
|
||||
async createSoundFromBase64(context: AudioContext, base64: string) {
|
||||
const arrayBuffer = decodeBase64ToArrayBuffer(base64)
|
||||
const audioBuffer = await context.decodeAudioData(arrayBuffer.slice(0))
|
||||
return new PizzicatoSound(context, audioBuffer)
|
||||
},
|
||||
Effects: {
|
||||
HighPassFilter: HighPassFilterEffect,
|
||||
LowPassFilter: LowPassFilterEffect,
|
||||
BandPassFilter: BandPassFilterEffect,
|
||||
PeakingFilter: PeakingFilterEffect,
|
||||
Compressor: CompressorEffect,
|
||||
Distortion: DistortionEffect,
|
||||
Tremolo: TremoloEffect
|
||||
}
|
||||
}
|
||||
|
||||
cachedInstance = instance
|
||||
return instance
|
||||
}
|
||||
|
||||
export type { PizzicatoSound }
|
||||
168
shared/utils/radioEffects.ts
Normal file
168
shared/utils/radioEffects.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type ReadabilityProfile = {
|
||||
eq: {
|
||||
highpass: number
|
||||
highpassQ: number
|
||||
lowpass: number
|
||||
lowpassQ: number
|
||||
bandpass?: { frequency: number; q: number }
|
||||
}
|
||||
presence?: { frequency: number; q: number; gain: number }
|
||||
distortions: number[]
|
||||
tremolos?: Array<{ depth: number; speed: number; type?: OscillatorType }>
|
||||
gain: number
|
||||
compressor: Partial<{ threshold: number; knee: number; ratio: number; attack: number; release: number }>
|
||||
noise: { amplitude: number; bandFrequency: number; bandQ: number; crackle?: boolean }
|
||||
}
|
||||
|
||||
const readabilityProfiles: Record<number, ReadabilityProfile> = {
|
||||
1: {
|
||||
eq: { highpass: 520, highpassQ: 1.1, lowpass: 1700, lowpassQ: 0.85, bandpass: { frequency: 1100, q: 2.6 } },
|
||||
presence: { frequency: 1300, q: 2.1, gain: 4 },
|
||||
distortions: [420, 260],
|
||||
tremolos: [
|
||||
{ depth: 0.92, speed: 7.6, type: 'square' },
|
||||
{ depth: 0.38, speed: 5.1 }
|
||||
],
|
||||
gain: 0.84,
|
||||
compressor: { threshold: -32, ratio: 14, attack: 0.004, release: 0.3 },
|
||||
noise: { amplitude: 0.12, bandFrequency: 1500, bandQ: 1.6, crackle: true }
|
||||
},
|
||||
2: {
|
||||
eq: { highpass: 440, highpassQ: 0.95, lowpass: 2100, lowpassQ: 0.9, bandpass: { frequency: 1650, q: 1.9 } },
|
||||
presence: { frequency: 1700, q: 1.2, gain: 2.5 },
|
||||
distortions: [320],
|
||||
tremolos: [{ depth: 0.24, speed: 5.2 }],
|
||||
gain: 0.88,
|
||||
compressor: { threshold: -30, ratio: 13, attack: 0.0035, release: 0.28 },
|
||||
noise: { amplitude: 0.085, bandFrequency: 1800, bandQ: 1.4, crackle: true }
|
||||
},
|
||||
3: {
|
||||
eq: { highpass: 360, highpassQ: 0.8, lowpass: 2600, lowpassQ: 0.9, bandpass: { frequency: 1850, q: 1.5 } },
|
||||
presence: { frequency: 2100, q: 1.3, gain: 1.8 },
|
||||
distortions: [220],
|
||||
tremolos: [{ depth: 0.14, speed: 4.5 }],
|
||||
gain: 0.9,
|
||||
compressor: { threshold: -28, ratio: 12, attack: 0.003, release: 0.26 },
|
||||
noise: { amplitude: 0.055, bandFrequency: 1900, bandQ: 1.2 }
|
||||
},
|
||||
4: {
|
||||
eq: { highpass: 310, highpassQ: 0.7, lowpass: 3050, lowpassQ: 0.85, bandpass: { frequency: 2000, q: 1.2 } },
|
||||
presence: { frequency: 2300, q: 1.4, gain: 1.4 },
|
||||
distortions: [140],
|
||||
tremolos: [{ depth: 0.08, speed: 3.6 }],
|
||||
gain: 0.93,
|
||||
compressor: { threshold: -27, ratio: 11, attack: 0.0028, release: 0.23 },
|
||||
noise: { amplitude: 0.035, bandFrequency: 2000, bandQ: 1.1 }
|
||||
},
|
||||
5: {
|
||||
eq: { highpass: 280, highpassQ: 0.65, lowpass: 3300, lowpassQ: 0.8, bandpass: { frequency: 2150, q: 1.1 } },
|
||||
presence: { frequency: 2450, q: 1.5, gain: 1.1 },
|
||||
distortions: [90],
|
||||
tremolos: [{ depth: 0.05, speed: 3 }],
|
||||
gain: 0.96,
|
||||
compressor: { threshold: -26, ratio: 10, attack: 0.0025, release: 0.2 },
|
||||
noise: { amplitude: 0.02, bandFrequency: 2100, bandQ: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
export const clampReadability = (level: number) => Math.max(1, Math.min(5, level))
|
||||
|
||||
export const getReadabilityProfile = (level: number): ReadabilityProfile => {
|
||||
const clamped = clampReadability(level)
|
||||
return readabilityProfiles[clamped] || readabilityProfiles[3]
|
||||
}
|
||||
|
||||
export const createNoiseGenerators = (
|
||||
ctx: AudioContext,
|
||||
duration: number,
|
||||
profile: ReadabilityProfile,
|
||||
level: number
|
||||
): Array<() => void> => {
|
||||
const stops: Array<() => void> = []
|
||||
const bufferLength = Math.max(1, Math.ceil((duration + 0.2) * ctx.sampleRate))
|
||||
const noiseBuffer = ctx.createBuffer(1, bufferLength, ctx.sampleRate)
|
||||
const channel = noiseBuffer.getChannelData(0)
|
||||
const amplitude = profile.noise.amplitude
|
||||
const crackle = profile.noise.crackle
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
if (crackle && Math.random() < 0.002) {
|
||||
const burstLength = Math.min(Math.floor(ctx.sampleRate * 0.02), channel.length - i)
|
||||
for (let j = 0; j < burstLength; j++, i++) {
|
||||
channel[i] = (Math.random() * 2 - 1) * amplitude * 3.2
|
||||
}
|
||||
i--
|
||||
continue
|
||||
}
|
||||
|
||||
channel[i] = (Math.random() * 2 - 1) * amplitude
|
||||
}
|
||||
|
||||
const noiseSource = ctx.createBufferSource()
|
||||
noiseSource.buffer = noiseBuffer
|
||||
|
||||
const bandPass = ctx.createBiquadFilter()
|
||||
bandPass.type = 'bandpass'
|
||||
bandPass.frequency.value = profile.noise.bandFrequency
|
||||
bandPass.Q.value = profile.noise.bandQ
|
||||
|
||||
const noiseGain = ctx.createGain()
|
||||
noiseGain.gain.value = amplitude
|
||||
|
||||
noiseSource.connect(bandPass)
|
||||
bandPass.connect(noiseGain)
|
||||
noiseGain.connect(ctx.destination)
|
||||
|
||||
try {
|
||||
noiseSource.start()
|
||||
} catch (err) {
|
||||
console.warn('Noise source start failed', err)
|
||||
}
|
||||
|
||||
stops.push(() => {
|
||||
try {
|
||||
noiseSource.stop()
|
||||
} catch {
|
||||
// ignore stop failure
|
||||
}
|
||||
noiseSource.disconnect()
|
||||
bandPass.disconnect()
|
||||
noiseGain.disconnect()
|
||||
})
|
||||
|
||||
if (level <= 3) {
|
||||
const hissBuffer = ctx.createBuffer(1, bufferLength, ctx.sampleRate)
|
||||
const hissChannel = hissBuffer.getChannelData(0)
|
||||
const hissAmplitude = amplitude * (level === 1 ? 0.9 : 0.6)
|
||||
for (let i = 0; i < hissChannel.length; i++) {
|
||||
hissChannel[i] = (Math.random() * 2 - 1) * hissAmplitude
|
||||
}
|
||||
const hissSource = ctx.createBufferSource()
|
||||
hissSource.buffer = hissBuffer
|
||||
const highPass = ctx.createBiquadFilter()
|
||||
highPass.type = 'highpass'
|
||||
highPass.frequency.value = 2800
|
||||
const hissGain = ctx.createGain()
|
||||
hissGain.gain.value = hissAmplitude * 0.6
|
||||
hissSource.connect(highPass)
|
||||
highPass.connect(hissGain)
|
||||
hissGain.connect(ctx.destination)
|
||||
try {
|
||||
hissSource.start()
|
||||
} catch (err) {
|
||||
console.warn('Hiss source start failed', err)
|
||||
}
|
||||
stops.push(() => {
|
||||
try {
|
||||
hissSource.stop()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
hissSource.disconnect()
|
||||
highPass.disconnect()
|
||||
hissGain.disconnect()
|
||||
})
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
Reference in New Issue
Block a user