mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
feat: improve learn experience
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user