diff --git a/app/pages/learn.vue b/app/pages/learn.vue index d00272c..b9a3564 100644 --- a/app/pages/learn.vue +++ b/app/pages/learn.vue @@ -583,6 +583,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' }) @@ -1986,10 +1989,16 @@ const result = ref(null) const evaluating = ref(false) const ttsLoading = ref(false) const audioElement = ref(null) +let speechContext: AudioContext | null = null +let pizzicatoLiteInstance: PizzicatoLite | null = null +type RadioSoundInstance = Awaited> +let activeRadioSound: RadioSoundInstance | null = null +let activeRadioCleanup: Array<() => void> = [] let radioNoiseContext: AudioContext | null = null let radioNoiseSource: AudioBufferSourceNode | null = null -const sayCache = new Map() -const pendingSayRequests = new Map>() +type CachedAudio = { base64: string; mime?: string } +const sayCache = new Map() +const pendingSayRequests = new Map>() const audioReveal = ref(true) const toast = ref({ show: false, text: '' }) @@ -2188,12 +2197,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>(() => { @@ -2757,13 +2772,43 @@ function tilt(event: MouseEvent) { worldTiltStyle.value = { transform: `perspective(1200px) rotateX(${dy * -3}deg) rotateY(${dx * 3}deg)` } } +async function ensureSpeechAudioContext(): Promise { + 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 { + 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): Promise { +async function requestSayAudio(cacheKey: string, payload: Record): Promise { const pending = pendingSayRequests.get(cacheKey) if (pending) { return pending @@ -2775,38 +2820,125 @@ async function requestSayAudio(cacheKey: string, payload: Record { - 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() } } @@ -2947,11 +3079,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 { @@ -2963,6 +3095,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() diff --git a/app/pages/pm.vue b/app/pages/pm.vue index ca88856..7e8b4a4 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -896,6 +896,7 @@ 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() @@ -1352,172 +1353,6 @@ const ensurePizzicato = async (ctx: AudioContext | null): Promise - 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 } - } -} - -const getReadabilityProfile = (level: number): ReadabilityProfile => { - const clamped = Math.max(1, Math.min(5, level)) - return readabilityProfiles[clamped] || readabilityProfiles[3] -} - -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 -} const playAudioWithEffects = async (base64: string, mime = 'audio/wav') => { if (typeof window === 'undefined') return 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 +}