From 7db4d0357eb352636cc57acf85674fa22a19e7b2 Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:20:00 +0200 Subject: [PATCH] Refactor radio audio effects and use in learn page --- app/pages/pm.vue | 157 ++++++++------- shared/utils/pizzicatoLite.ts | 356 ++++++++++++++++++++++++++++++++++ shared/utils/radioEffects.ts | 168 ++++++++++++++++ 3 files changed, 600 insertions(+), 81 deletions(-) create mode 100644 shared/utils/pizzicatoLite.ts create mode 100644 shared/utils/radioEffects.ts diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 7a136cd..7e8b4a4 100644 --- a/app/pages/pm.vue +++ b/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(resolve => setTimeout(resolve, ms)) let audioContext: AudioContext | null = null +let pizzicatoLite: PizzicatoLite | null = null let speechQueue: Promise = Promise.resolve() const enqueueSpeech = (task: () => Promise) => { @@ -1341,102 +1345,93 @@ const ensureAudioContext = async (): Promise => { return audioContext } -const playAudioWithEffects = async (base64: string) => { +const ensurePizzicato = async (ctx: AudioContext | null): Promise => { + 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((resolve) => { - const audio = new Audio(`data:audio/wav;base64,${base64}`) + const dataUrl = `data:${mime || 'audio/wav'};base64,${base64}` + + const playWithoutEffects = () => + new Promise((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((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((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) diff --git a/shared/utils/pizzicatoLite.ts b/shared/utils/pizzicatoLite.ts new file mode 100644 index 0000000..77bb0ab --- /dev/null +++ b/shared/utils/pizzicatoLite.ts @@ -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 + 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 { + 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 => { + 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 } diff --git a/shared/utils/radioEffects.ts b/shared/utils/radioEffects.ts new file mode 100644 index 0000000..0e0635e --- /dev/null +++ b/shared/utils/radioEffects.ts @@ -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 = { + 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 +}