mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 10:05:50 +08:00
merge
This commit is contained in:
@@ -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<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: '' })
|
||||
@@ -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<Record<string, LessonField>>(() => {
|
||||
@@ -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<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
|
||||
@@ -2775,38 +2820,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
167
app/pages/pm.vue
167
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<PizzicatoLite
|
||||
return pizzicatoLite
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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