diff --git a/app/pages/learn.vue b/app/pages/learn.vue
index 377f22e..af3195d 100644
--- a/app/pages/learn.vue
+++ b/app/pages/learn.vue
@@ -493,10 +493,36 @@
-
Radio level (1..5)
-
+
+ Radio level (1..5)
+ Simulate readability from barely readable to loud and clear.
+
+
+
+
+ ATC speaking speed
+ {{ audioSpeedDisplay }}× — slow practice to rapid live traffic.
+
+
+
Test TTS
@@ -717,6 +743,7 @@ const globalAccuracy = computed(() => {
})
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
+const audioSpeedDisplay = computed(() => (cfg.value.audioSpeed ?? 1).toFixed(2))
type LearnStateResponse = LearnState
@@ -831,6 +858,7 @@ if (isClient) {
void playRadioNoise(level)
}
})
+ watch(() => cfg.value.audioSpeed, markConfigDirty)
watch(() => cfg.value.voice, markConfigDirty)
}
@@ -1633,7 +1661,7 @@ async function playRadioNoise(level: number) {
const strength = Math.max(1, Math.min(5, level))
const intensity = (6 - strength) / 5
- const amplitude = 0.04 + intensity * 0.12
+ const amplitude = 0.025 + intensity * 0.15
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * amplitude
@@ -1679,12 +1707,21 @@ async function playRadioNoise(level: number) {
radioNoiseSource = source
}
+function computeSpeechRate(): number {
+ const base =
+ typeof cfg.value.audioSpeed === 'number' && Number.isFinite(cfg.value.audioSpeed)
+ ? cfg.value.audioSpeed
+ : 1
+ const adjustment = (cfg.value.radioLevel - 3) * 0.05
+ const rate = base + adjustment
+ return Math.min(1.4, Math.max(0.7, Math.round(rate * 100) / 100))
+}
+
async function say(text: string) {
const trimmed = text?.trim()
if (!trimmed) return
- const rateBase = 0.9 + (cfg.value.radioLevel - 3) * 0.12
- const normalizedRate = Math.min(1.5, Math.max(0.6, rateBase))
+ const normalizedRate = computeSpeechRate()
const hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window
@@ -2655,12 +2692,12 @@ onMounted(() => {
background: color-mix(in srgb, var(--text) 4%, transparent);
box-shadow: 0 24px 48px rgba(2, 6, 23, .5);
backdrop-filter: blur(18px);
- overflow: hidden;
}
.module-overview {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: linear-gradient(150deg, color-mix(in srgb, var(--accent) 14%, transparent) 0%, color-mix(in srgb, var(--text) 4%, transparent) 100%);
+ overflow: hidden;
}
.module-overview::before {
@@ -2676,6 +2713,9 @@ onMounted(() => {
.module-detail {
border-color: color-mix(in srgb, var(--text) 22%, transparent);
background: linear-gradient(160deg, color-mix(in srgb, var(--text) 6%, transparent) 0%, color-mix(in srgb, var(--bg2) 70%, transparent) 100%);
+ overflow: visible;
+ display: flex;
+ flex-direction: column;
}
.module-detail .console {
@@ -2767,7 +2807,8 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 16px;
- margin-top: 16px
+ margin-top: 16px;
+ flex: 1 1 auto
}
.console-grid {
diff --git a/app/pages/login.vue b/app/pages/login.vue
index 640413d..89fcfe1 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -219,7 +219,7 @@ async function submitLogin() {
try {
await auth.login({ ...loginForm })
await auth.fetchUser()
- router.push('/pm')
+ router.push('/learn')
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Login failed'
loginError.value = message
@@ -269,7 +269,7 @@ async function submitRegister() {
acceptPrivacy: registerForm.acceptPrivacy,
})
await auth.fetchUser()
- router.push('/pm')
+ router.push('/learn')
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Registration failed'
registerError.value = message
@@ -287,7 +287,7 @@ useHead({
onMounted(() => {
if (auth.isAuthenticated) {
- router.push('/pm')
+ router.push('/learn')
}
})
diff --git a/server/api/learn/state.put.ts b/server/api/learn/state.put.ts
index 7223ef7..5f2941c 100644
--- a/server/api/learn/state.put.ts
+++ b/server/api/learn/state.put.ts
@@ -65,7 +65,7 @@ function sanitizeConfig(input: Partial | undefined): LearnConfig |
}
if (typeof input.audioSpeed === 'number' && Number.isFinite(input.audioSpeed)) {
- const rounded = Math.round(input.audioSpeed * 10) / 10
+ const rounded = Math.round(input.audioSpeed * 20) / 20
config.audioSpeed = Math.min(1.3, Math.max(0.7, rounded))
}
diff --git a/shared/utils/pizzicatoLite.ts b/shared/utils/pizzicatoLite.ts
index 77bb0ab..d3bffc6 100644
--- a/shared/utils/pizzicatoLite.ts
+++ b/shared/utils/pizzicatoLite.ts
@@ -268,11 +268,16 @@ class TremoloEffect implements EffectNode {
private modulationGain: GainNode
private started = false
private depth: number
+ private offset?: number
+ private randomizePhase: boolean
inputNode: AudioNode
outputNode: AudioNode
- constructor(context: AudioContext, options: { speed?: number; depth?: number; type?: OscillatorType } = {}) {
+ constructor(
+ context: AudioContext,
+ options: { speed?: number; depth?: number; type?: OscillatorType; offset?: number; randomizePhase?: boolean } = {}
+ ) {
this.context = context
this.input = context.createGain()
this.output = context.createGain()
@@ -286,6 +291,8 @@ class TremoloEffect implements EffectNode {
this.oscillator = context.createOscillator()
this.oscillator.type = options.type ?? 'sine'
this.oscillator.frequency.value = options.speed ?? 5
+ this.offset = options.offset
+ this.randomizePhase = options.randomizePhase !== false
this.input.connect(this.output)
this.oscillator.connect(this.modulationGain)
@@ -298,7 +305,12 @@ class TremoloEffect implements EffectNode {
onActivate() {
if (this.started) return
try {
- this.oscillator.start()
+ let offset = this.offset ?? 0
+ if (this.offset === undefined && this.randomizePhase) {
+ const frequency = Math.max(0.0001, this.oscillator.frequency.value)
+ offset = Math.random() * (1 / frequency)
+ }
+ this.oscillator.start(0, offset)
this.started = true
} catch {
// oscillator already started
diff --git a/shared/utils/radioEffects.ts b/shared/utils/radioEffects.ts
index 0e0635e..407f9c9 100644
--- a/shared/utils/radioEffects.ts
+++ b/shared/utils/radioEffects.ts
@@ -8,7 +8,7 @@ export type ReadabilityProfile = {
}
presence?: { frequency: number; q: number; gain: number }
distortions: number[]
- tremolos?: Array<{ depth: number; speed: number; type?: OscillatorType }>
+ tremolos?: Array<{ depth: number; speed: number; type?: OscillatorType; offset?: number; randomizePhase?: boolean }>
gain: number
compressor: Partial<{ threshold: number; knee: number; ratio: number; attack: number; release: number }>
noise: { amplitude: number; bandFrequency: number; bandQ: number; crackle?: boolean }
@@ -16,52 +16,56 @@ export type ReadabilityProfile = {
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],
+ eq: { highpass: 540, highpassQ: 1.2, lowpass: 1600, lowpassQ: 0.8, bandpass: { frequency: 1150, q: 2.8 } },
+ presence: { frequency: 1350, q: 2.4, gain: 5 },
+ distortions: [360, 240],
tremolos: [
- { depth: 0.92, speed: 7.6, type: 'square' },
- { depth: 0.38, speed: 5.1 }
+ { depth: 0.7, speed: 4.9, type: 'sine' },
+ { depth: 0.28, speed: 9.4, type: 'triangle' },
+ { depth: 0.2, speed: 2.6, type: 'sine' }
],
- 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 }
+ gain: 0.82,
+ compressor: { threshold: -33, ratio: 16, attack: 0.004, release: 0.32 },
+ noise: { amplitude: 0.13, 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 }
+ eq: { highpass: 470, highpassQ: 1, lowpass: 2100, lowpassQ: 0.85, bandpass: { frequency: 1700, q: 2 } },
+ presence: { frequency: 1750, q: 1.3, gain: 3 },
+ distortions: [280, 160],
+ tremolos: [
+ { depth: 0.42, speed: 5.6, type: 'sine' },
+ { depth: 0.24, speed: 3.3, type: 'triangle' }
+ ],
+ gain: 0.86,
+ compressor: { threshold: -30, ratio: 14, attack: 0.0038, release: 0.3 },
+ noise: { amplitude: 0.09, bandFrequency: 1850, bandQ: 1.35, 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 }],
+ eq: { highpass: 380, highpassQ: 0.85, lowpass: 2600, lowpassQ: 0.88, bandpass: { frequency: 1900, q: 1.5 } },
+ presence: { frequency: 2150, q: 1.3, gain: 2 },
+ distortions: [210],
+ tremolos: [{ depth: 0.18, speed: 4.2, type: 'sine' }],
gain: 0.9,
- compressor: { threshold: -28, ratio: 12, attack: 0.003, release: 0.26 },
- noise: { amplitude: 0.055, bandFrequency: 1900, bandQ: 1.2 }
+ compressor: { threshold: -28, ratio: 12, attack: 0.0032, release: 0.26 },
+ noise: { amplitude: 0.055, bandFrequency: 1950, 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 }
+ eq: { highpass: 310, highpassQ: 0.7, lowpass: 3150, lowpassQ: 0.82, bandpass: { frequency: 2050, q: 1.2 } },
+ presence: { frequency: 2350, q: 1.35, gain: 1.5 },
+ distortions: [130],
+ tremolos: [{ depth: 0.08, speed: 3.1, type: 'sine' }],
+ gain: 0.94,
+ compressor: { threshold: -27, ratio: 11, attack: 0.0028, release: 0.22 },
+ noise: { amplitude: 0.035, bandFrequency: 2050, bandQ: 1.08 }
},
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 }
+ eq: { highpass: 260, highpassQ: 0.6, lowpass: 3600, lowpassQ: 0.78, bandpass: { frequency: 2250, q: 1.05 } },
+ presence: { frequency: 2550, q: 1.5, gain: 1.1 },
+ distortions: [60],
+ tremolos: [{ depth: 0.04, speed: 2.4, type: 'sine' }],
+ gain: 0.98,
+ compressor: { threshold: -25, ratio: 9.5, attack: 0.0025, release: 0.18 },
+ noise: { amplitude: 0.016, bandFrequency: 2150, bandQ: 1 }
}
}