Refactor radio audio effects and use in learn page

This commit is contained in:
Remi
2025-09-19 13:20:00 +02:00
parent 1a2f042d07
commit 9e25c91446
4 changed files with 777 additions and 106 deletions

View File

@@ -553,6 +553,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
import { useApi } from '~/composables/useApi'
import { createDefaultLearnConfig } from '~~/shared/learn/config'
import type { LearnConfig, LearnProgress, LearnState } from '~~/shared/learn/config'
import { loadPizzicatoLite } from '~~/shared/utils/pizzicatoLite'
import type { PizzicatoLite } from '~~/shared/utils/pizzicatoLite'
import { createNoiseGenerators, getReadabilityProfile } from '~~/shared/utils/radioEffects'
definePageMeta({ middleware: 'require-auth' })
@@ -1944,10 +1947,16 @@ const result = ref<ScoreResult | null>(null)
const evaluating = ref(false)
const ttsLoading = ref(false)
const audioElement = ref<HTMLAudioElement | null>(null)
let speechContext: AudioContext | null = null
let pizzicatoLiteInstance: PizzicatoLite | null = null
type RadioSoundInstance = Awaited<ReturnType<PizzicatoLite['createSoundFromBase64']>>
let activeRadioSound: RadioSoundInstance | null = null
let activeRadioCleanup: Array<() => void> = []
let radioNoiseContext: AudioContext | null = null
let radioNoiseSource: AudioBufferSourceNode | null = null
const sayCache = new Map<string, string>()
const pendingSayRequests = new Map<string, Promise<string>>()
type CachedAudio = { base64: string; mime?: string }
const sayCache = new Map<string, CachedAudio>()
const pendingSayRequests = new Map<string, Promise<CachedAudio>>()
const audioReveal = ref(true)
const toast = ref({ show: false, text: '' })
@@ -2148,12 +2157,18 @@ onBeforeUnmount(() => {
if (dirtyState.xp || dirtyState.progress || dirtyState.config) {
void persistLearnState(true)
}
stopAudio()
stopRadioNoise()
if (radioNoiseContext) {
const ctx = radioNoiseContext
radioNoiseContext = null
void ctx.close().catch(() => {})
}
if (speechContext) {
const ctx = speechContext
speechContext = null
void ctx.close().catch(() => {})
}
})
const fieldMap = computed<Record<string, LessonField>>(() => {
@@ -2587,13 +2602,43 @@ function tilt(event: MouseEvent) {
worldTiltStyle.value = { transform: `perspective(1200px) rotateX(${dy * -3}deg) rotateY(${dx * 3}deg)` }
}
async function ensureSpeechAudioContext(): Promise<AudioContext | null> {
if (!isClient) return null
const audioWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext }
const AudioContextCtor = audioWindow.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) return null
if (!speechContext || speechContext.state === 'closed') {
speechContext = new AudioContextCtor()
}
if (speechContext.state === 'suspended') {
try {
await speechContext.resume()
} catch (err) {
console.warn('Failed to resume speech audio context', err)
}
}
return speechContext
}
async function ensurePizzicato(ctx: AudioContext | null): Promise<PizzicatoLite | null> {
if (!ctx) return null
if (!pizzicatoLiteInstance) {
pizzicatoLiteInstance = await loadPizzicatoLite()
}
return pizzicatoLiteInstance
}
function buildSayCacheKey(text: string, rate: number): string {
const voice = cfg.value.voice?.trim().toLowerCase() || 'default'
const radioLevel = cfg.value.radioLevel
return `${voice}|${radioLevel}|${rate.toFixed(2)}|${text}`
}
async function requestSayAudio(cacheKey: string, payload: Record<string, unknown>): Promise<string> {
async function requestSayAudio(cacheKey: string, payload: Record<string, unknown>): Promise<CachedAudio> {
const pending = pendingSayRequests.get(cacheKey)
if (pending) {
return pending
@@ -2605,38 +2650,125 @@ async function requestSayAudio(cacheKey: string, payload: Record<string, unknown
if (!audioData?.base64) {
throw new Error('Missing audio data')
}
const mime = audioData.mime || 'audio/wav'
return `data:${mime};base64,${audioData.base64}`
return { base64: audioData.base64 as string, mime: audioData.mime || 'audio/wav' }
})()
pendingSayRequests.set(cacheKey, request)
try {
const dataUrl = await request
sayCache.set(cacheKey, dataUrl)
return dataUrl
const audioPayload = await request
sayCache.set(cacheKey, audioPayload)
return audioPayload
} finally {
pendingSayRequests.delete(cacheKey)
}
}
async function playAudioSource(source: string) {
const audio = new Audio(source)
audioElement.value = audio
audio.onended = () => {
if (audioElement.value === audio) {
audioElement.value = null
}
}
audio.onerror = () => {
if (audioElement.value === audio) {
audioElement.value = null
async function playAudioSource(source: CachedAudio) {
if (!source?.base64) return
audioElement.value = null
const readability = Math.max(1, Math.min(5, cfg.value.radioLevel || 3))
const mime = source.mime || 'audio/wav'
const dataUrl = `data:${mime};base64,${source.base64}`
const playWithoutEffects = async () => {
const audio = new Audio(dataUrl)
audioElement.value = audio
audio.onended = () => {
if (audioElement.value === audio) {
audioElement.value = null
}
}
audio.onerror = () => {
if (audioElement.value === audio) {
audioElement.value = null
}
}
try {
await audio.play()
} catch (err) {
console.error('Audio playback failed', err)
}
}
try {
await audio.play()
const ctx = await ensureSpeechAudioContext()
const pizzicato = await ensurePizzicato(ctx)
if (!ctx || !pizzicato) {
throw new Error('Audio engine unavailable')
}
const sound = await pizzicato.createSoundFromBase64(ctx, source.base64)
const profile = getReadabilityProfile(readability)
const { Effects } = pizzicato
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 noiseStops = createNoiseGenerators(ctx, sound.duration, profile, readability)
activeRadioSound = sound
activeRadioCleanup = noiseStops
try {
await sound.play()
} finally {
if (activeRadioSound === sound) {
activeRadioSound = null
}
if (activeRadioCleanup === noiseStops) {
activeRadioCleanup = []
}
noiseStops.forEach(stop => {
try {
stop()
} catch {
// ignore
}
})
sound.clearEffects()
}
} catch (err) {
console.error('Audio playback failed', err)
console.error('Failed to apply radio effect', err)
await playWithoutEffects()
}
}
@@ -2777,11 +2909,11 @@ async function say(text: string) {
ttsLoading.value = true
try {
let dataUrl = sayCache.get(cacheKey)
if (!dataUrl) {
dataUrl = await requestSayAudio(cacheKey, payload)
let audioData = sayCache.get(cacheKey)
if (!audioData) {
audioData = await requestSayAudio(cacheKey, payload)
}
await playAudioSource(dataUrl)
await playAudioSource(audioData)
} catch (err) {
console.error('TTS request failed', err)
} finally {
@@ -2793,6 +2925,26 @@ function stopAudio() {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel()
}
if (activeRadioSound) {
const sound = activeRadioSound
activeRadioSound = null
try {
sound.stop()
} catch {
// ignore stop errors
}
}
if (activeRadioCleanup.length) {
const stops = activeRadioCleanup
activeRadioCleanup = []
stops.forEach(stop => {
try {
stop()
} catch {
// ignore cleanup errors
}
})
}
if (audioElement.value) {
try {
audioElement.value.pause()

View File

@@ -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)