feat: improve learn experience

This commit is contained in:
Remi
2025-09-19 23:00:58 +02:00
committed by itsrubberduck
parent 7c6630e444
commit 29ee3143e4
5 changed files with 106 additions and 49 deletions

View File

@@ -493,10 +493,36 @@
</div>
<div class="set-row">
<span>Radio level (1..5)</span>
<v-slider hide-details v-model="cfg.radioLevel" :min="1" :max="5" :step="1" color="cyan" thumb-label/>
<div class="set-info">
<span>Radio level (1..5)</span>
<small class="muted">Simulate readability from barely readable to loud and clear.</small>
</div>
<v-slider
hide-details
v-model="cfg.radioLevel"
:min="1"
:max="5"
:step="1"
color="cyan"
thumb-label
/>
</div>
<div class="set-row">
<div class="set-info">
<span>ATC speaking speed</span>
<small class="muted">{{ audioSpeedDisplay }}× — slow practice to rapid live traffic.</small>
</div>
<v-slider
hide-details
v-model="cfg.audioSpeed"
:min="0.7"
:max="1.3"
:step="0.05"
color="cyan"
thumb-label
/>
</div>
<div class="set-row">
<span>Test TTS</span>
@@ -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 {

View File

@@ -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')
}
})
</script>

View File

@@ -65,7 +65,7 @@ function sanitizeConfig(input: Partial<LearnConfig> | 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))
}

View File

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

View File

@@ -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<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],
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 }
}
}