Optimize learn TTS playback caching

This commit is contained in:
Remi
2025-09-18 20:31:36 +02:00
committed by itsrubberduck
parent 6eb8b98e22
commit 43acf243de

View File

@@ -216,13 +216,37 @@
<div class="target-row">
<div class="target-main">
<div class="muted small">{{ activeLesson.desc }}</div>
<div class="target-text">{{ targetPhrase }}</div>
<div
class="target-text"
:class="{ 'audio-blur': audioContentHidden }"
:aria-hidden="audioContentHidden ? 'true' : 'false'"
>
{{ targetPhrase }}
</div>
<div v-if="audioContentHidden" class="audio-note muted small">
Audio Challenge aktiv zuerst anhören.
</div>
</div>
<div class="row wrap">
<button class="btn soft mini" type="button" :disabled="!targetPhrase" @click="say(targetPhrase)">
<v-icon size="16">mdi-volume-high</v-icon>
<button
class="btn soft mini"
type="button"
:disabled="!targetPhrase || ttsLoading"
:aria-busy="ttsLoading ? 'true' : 'false'"
@click="say(targetPhrase)"
>
<v-icon size="16" :class="{ spin: ttsLoading }">{{ ttsLoading ? 'mdi-loading' : 'mdi-volume-high' }}</v-icon>
Say
</button>
<button
v-if="audioContentHidden"
class="btn ghost mini"
type="button"
@click="revealAudioContent"
>
<v-icon size="16">mdi-eye</v-icon>
Anzeigen
</button>
<button class="btn ghost mini" type="button" @click="rollScenario(true)">
<v-icon size="16">mdi-dice-5</v-icon>
Würfeln
@@ -235,7 +259,12 @@
<v-icon size="16">mdi-lightbulb-on-outline</v-icon>
{{ hint }}
</div>
<div v-for="info in lessonInfo" :key="info" class="hint secondary">
<div
v-for="info in lessonInfo"
:key="info"
:class="['hint', 'secondary', { 'audio-blur': audioContentHidden }]"
:aria-hidden="audioContentHidden ? 'true' : 'false'"
>
<v-icon size="16">mdi-information-outline</v-icon>
{{ info }}
</div>
@@ -328,10 +357,21 @@
<div class="settings">
<div class="set-row">
<span>Browser-TTS (Web Speech)</span>
<div class="set-info">
<span>Browser-TTS (Web Speech)</span>
<small class="muted">Läuft offline, startet schneller und spart unsere API-Anfragen.</small>
</div>
<v-switch v-model="cfg.tts" color="cyan" hide-details inset/>
</div>
<div class="set-row">
<div class="set-info">
<span>Audio Challenge</span>
<small class="muted">Blendet Zieltext & Infos, bis du sie manuell einblendest.</small>
</div>
<v-switch v-model="cfg.audioChallenge" color="cyan" hide-details inset/>
</div>
<div class="set-row">
<span>Funk-Level (1..5)</span>
<v-slider v-model="cfg.radioLevel" :min="1" :max="5" :step="1" color="cyan" thumb-label/>
@@ -374,11 +414,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
type BlankWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
type FrequencyType = 'ATIS' | 'DEL' | 'GND' | 'TWR' | 'DEP' | 'APP' | 'CTR'
type Frequency = {
type: string
type: FrequencyType
label: string
value: string
}
@@ -400,6 +443,7 @@ type AirportData = {
tower: string
departure: string
approach: string
center: string
}
transLevel: string
}
@@ -465,13 +509,14 @@ type Scenario = {
readabilityWord: string
readabilityPhrase: string
frequencies: Frequency[]
frequencyWords: Record<string, string>
frequencyWords: Record<FrequencyType, string>
atisFreq: string
deliveryFreq: string
groundFreq: string
towerFreq: string
departureFreq: string
approachFreq: string
centerFreq: string
transLevel: string
remarks: string
}
@@ -612,7 +657,8 @@ const airportsData: AirportData[] = [
ground: '121.800',
tower: '118.700',
departure: '125.350',
approach: '120.800'
approach: '120.800',
center: '134.200'
},
transLevel: 'FL070'
},
@@ -632,7 +678,8 @@ const airportsData: AirportData[] = [
ground: '121.800',
tower: '118.700',
departure: '129.050',
approach: '120.800'
approach: '120.800',
center: '133.700'
},
transLevel: 'FL070'
},
@@ -652,7 +699,8 @@ const airportsData: AirportData[] = [
ground: '121.900',
tower: '119.220',
departure: '123.875',
approach: '121.200'
approach: '121.200',
center: '135.050'
},
transLevel: 'FL060'
}
@@ -828,13 +876,14 @@ function createBaseScenario(): Scenario {
{ type: 'GND', label: 'Ground', value: airport.freqs.ground },
{ type: 'TWR', label: 'Tower', value: airport.freqs.tower },
{ type: 'DEP', label: 'Departure', value: airport.freqs.departure },
{ type: 'APP', label: 'Approach', value: airport.freqs.approach }
{ type: 'APP', label: 'Approach', value: airport.freqs.approach },
{ type: 'CTR', label: 'Center', value: airport.freqs.center }
]
const frequencyWords: Record<string, string> = frequencies.reduce((acc, freq) => {
const frequencyWords = frequencies.reduce((acc, freq) => {
acc[freq.type] = frequencyToSpeech(freq.value)
return acc
}, {} as Record<string, string>)
}, {} as Record<FrequencyType, string>)
const readability = choice(readabilityScale)
@@ -907,6 +956,7 @@ function createBaseScenario(): Scenario {
towerFreq: airport.freqs.tower,
departureFreq: airport.freqs.departure,
approachFreq: airport.freqs.approach,
centerFreq: airport.freqs.center,
transLevel: airport.transLevel,
remarks
}
@@ -1007,8 +1057,8 @@ const modules = ref<ModuleDef[]>([
desc: 'Kennung und Kerndaten aus der ATIS ziehen',
keywords: ['ATIS', 'Wetter'],
hints: [
'Schreibe nur den Buchstaben für die ATIS-Information.',
'Runway, Wind und QNH exakt übernehmen.'
'ATIS-Kennung als einzelnen Buchstaben merken.',
'Reihenfolge: Runway Wind Sicht Temperatur Taupunkt QNH.'
],
fields: [
{
@@ -1020,7 +1070,7 @@ const modules = ref<ModuleDef[]>([
scenario.atisCode.toLowerCase(),
`Information ${scenario.atisCode}`
],
placeholder: 'Kilo',
placeholder: 'Buchstabe',
width: 'xs',
threshold: 0.9
},
@@ -1042,6 +1092,27 @@ const modules = ref<ModuleDef[]>([
],
width: 'md'
},
{
key: 'atis-visibility',
label: 'Sicht',
expected: scenario => scenario.atisSummary.visibility,
alternatives: scenario => [scenario.visibility, scenario.visibilityWords],
width: 'sm'
},
{
key: 'atis-temp',
label: 'Temperatur',
expected: scenario => scenario.atisSummary.temperature,
alternatives: scenario => [scenario.temperatureWords],
width: 'sm'
},
{
key: 'atis-dew',
label: 'Taupunkt',
expected: scenario => scenario.atisSummary.dewpoint,
alternatives: scenario => [scenario.dewpointWords],
width: 'sm'
},
{
key: 'atis-qnh',
label: 'QNH',
@@ -1057,16 +1128,20 @@ const modules = ref<ModuleDef[]>([
{ type: 'field', key: 'atis-runway', width: 'sm' },
{ type: 'text', text: ', wind ' },
{ type: 'field', key: 'atis-wind', width: 'md' },
{ type: 'text', text: ', visibility ' },
{ type: 'field', key: 'atis-visibility', width: 'sm' },
{ type: 'text', text: ', temperature ' },
{ type: 'field', key: 'atis-temp', width: 'sm' },
{ type: 'text', text: ', dew point ' },
{ type: 'field', key: 'atis-dew', width: 'sm' },
{ type: 'text', text: ', QNH ' },
{ type: 'field', key: 'atis-qnh', width: 'sm' }
],
defaultFrequency: 'ATIS',
phrase: scenario => scenario.atisText,
info: scenario => [
`Runway: ${scenario.runway}`,
`Wind: ${scenario.wind} (${scenario.windWords})`,
`Sicht: ${scenario.visibility} (${scenario.visibilityWords})`,
`QNH: ${scenario.qnh}`
info: () => [
'Notiere Kennung, Runway, Wind, Sichtweite, Temperatur, Taupunkt und QNH.',
'Sicht: vier Ziffern oder 9999 für ≥10 km · QNH als vierstellige Zahl.'
],
generate: createBaseScenario
},
@@ -1424,6 +1499,49 @@ const modules = ref<ModuleDef[]>([
],
generate: createBaseScenario
},
{
id: 'lineup',
title: 'Line-up Clearance',
desc: 'Aufrollen und warten bestätigen',
keywords: ['Tower', 'Line Up'],
hints: [
'Runway wiederholen, dann "line up and wait".',
'Callsign am Ende setzen.'
],
fields: [
{
key: 'lineup-runway',
label: 'Runway',
expected: scenario => scenario.runway,
alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
width: 'sm'
},
{
key: 'lineup-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Runway ' },
{ type: 'field', key: 'lineup-runway', width: 'sm' },
{ type: 'text', text: ', line up and wait, ' },
{ type: 'field', key: 'lineup-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`,
info: scenario => [
`Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
`Line-up: runway ${scenario.runway}`
],
generate: createBaseScenario
},
{
id: 'takeoff',
title: 'Takeoff Clearance',
@@ -1513,6 +1631,163 @@ const modules = ref<ModuleDef[]>([
`Tower Handoff nach Start.`
],
generate: createBaseScenario
},
{
id: 'departure-checkin',
title: 'Departure Check-in',
desc: 'Erstmeldung bei Departure',
keywords: ['Departure', 'Check-in'],
hints: [
'Einheit ansprechen, dann Callsign nennen.',
'Altitude und SID exakt wie gehört wiederholen.'
],
fields: [
{
key: 'depcheck-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
},
{
key: 'depcheck-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [
scenario.altitudes.initialWords,
scenario.altitudes.initial.toString(),
`${scenario.altitudes.initial} feet`
],
width: 'md'
},
{
key: 'depcheck-sid',
label: 'SID',
expected: scenario => scenario.sid,
width: 'lg'
}
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
{ type: 'field', key: 'depcheck-callsign', width: 'lg' },
{ type: 'text', text: ', passing ' },
{ type: 'field', key: 'depcheck-alt', width: 'md' },
{ type: 'text', text: ', on SID ' },
{ type: 'field', key: 'depcheck-sid', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.airport.city} Departure, ${scenario.radioCall}, passing ${scenario.altitudes.initialWords}, on SID ${scenario.sid}.`,
info: scenario => [
`Initial Altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`,
`SID: ${scenario.sid}`
],
generate: createBaseScenario
},
{
id: 'climb-instruction',
title: 'Climb & Direct',
desc: 'Steigauftrag komplett zurücklesen',
keywords: ['Departure', 'Climb'],
hints: [
'Mit "Climb" beginnen, dann Höhe und gegebenenfalls Direct.',
'Callsign am Ende wiederholen.'
],
fields: [
{
key: 'climb-alt',
label: 'Altitude',
expected: scenario => scenario.altitudes.climbWords,
alternatives: scenario => [
scenario.altitudes.climbWords,
scenario.altitudes.climb.toString(),
`${scenario.altitudes.climb} feet`
],
width: 'md'
},
{
key: 'climb-direct',
label: 'Direct',
expected: scenario => scenario.transition,
width: 'md'
},
{
key: 'climb-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Climb ' },
{ type: 'field', key: 'climb-alt', width: 'md' },
{ type: 'text', text: ', direct ' },
{ type: 'field', key: 'climb-direct', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'climb-callsign', width: 'lg' }
],
defaultFrequency: 'DEP',
phrase: scenario => `${scenario.radioCall}, climb ${scenario.altitudes.climb} feet, proceed direct ${scenario.transition}.`,
info: scenario => [
`Climb: ${scenario.altitudes.climb} ft (${scenario.altitudes.climbWords})`,
`Direct to: ${scenario.transition}`
],
generate: createBaseScenario
},
{
id: 'center-handoff',
title: 'Center Handoff',
desc: 'Übergabe an Center bestätigen',
keywords: ['Center', 'Handoff'],
hints: [
'Frequenz exakt wiedergeben (mit oder ohne Dezimalpunkt).',
'Callsign am Ende platzieren.'
],
fields: [
{
key: 'ctr-freq',
label: 'Frequenz',
expected: scenario => scenario.centerFreq,
alternatives: scenario => [
scenario.centerFreq,
scenario.centerFreq.replace('.', ''),
scenario.frequencyWords.CTR
],
width: 'md'
},
{
key: 'ctr-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
scenario.callsign
],
width: 'lg'
}
],
readback: [
{ type: 'text', text: 'Contact center ' },
{ type: 'field', key: 'ctr-freq', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'ctr-callsign', width: 'lg' }
],
defaultFrequency: 'CTR',
phrase: scenario => `${scenario.radioCall}, contact center ${scenario.centerFreq}.`,
info: scenario => [
`Center: ${scenario.centerFreq} (${scenario.frequencyWords.CTR})`,
`Nächste Einheit: Center`
],
generate: createBaseScenario
}
]
}
@@ -1525,9 +1800,17 @@ const activeFrequency = ref<Frequency | null>(null)
const userAnswers = reactive<Record<string, string>>({})
const result = ref<ScoreResult | null>(null)
const evaluating = ref(false)
const ttsLoading = ref(false)
const audioElement = ref<HTMLAudioElement | null>(null)
const sayCache = new Map<string, string>()
const pendingSayRequests = new Map<string, Promise<string>>()
const audioReveal = ref(!cfg.value.audioChallenge)
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
const toast = ref({ show: false, text: '' })
const showSettings = ref(false)
const api = useApi()
const isClient = typeof window !== 'undefined'
@@ -1551,20 +1834,30 @@ function readNumber(key: string, fallback: number): number {
return Number.isFinite(value) ? value : fallback
}
const defaultCfg = { tts: true, radioLevel: 4, voice: '' }
const cfg = ref({ ...defaultCfg })
type LearnConfig = {
tts: boolean
radioLevel: number
voice: string
audioChallenge: boolean
}
const defaultCfg: LearnConfig = { tts: false, radioLevel: 4, voice: '', audioChallenge: false }
const cfg = ref<LearnConfig>({ ...defaultCfg })
if (isClient) {
const storedCfg = readStorage<{ tts?: boolean }>('os_cfg', {})
const storedCfg = readStorage<{ tts?: boolean; audioChallenge?: boolean }>('os_cfg', {})
const storedLevel = readStorage<{ v?: number }>('os_cfg_level', {})
const storedVoice = readStorage<{ v?: string }>('os_cfg_voice', {})
cfg.value = {
tts: storedCfg.tts ?? defaultCfg.tts,
radioLevel: storedLevel.v ?? defaultCfg.radioLevel,
voice: storedVoice.v ?? defaultCfg.voice
voice: storedVoice.v ?? defaultCfg.voice,
audioChallenge: storedCfg.audioChallenge ?? defaultCfg.audioChallenge
}
}
audioReveal.value = !cfg.value.audioChallenge
const xp = ref(readNumber('os_xp', 0))
const level = computed(() => 1 + Math.floor(xp.value / 300))
const seasonPct = computed(() => Math.min(100, Math.round((xp.value % 1000) / 10)))
@@ -1575,11 +1868,19 @@ const progress = ref<Prog>(readStorage<Prog>('os_progress', {} as Prog))
if (isClient) {
watch(progress, value => localStorage.setItem('os_progress', JSON.stringify(value)), { deep: true })
watch(xp, value => localStorage.setItem('os_xp', String(value)))
watch(() => cfg.value.tts, value => localStorage.setItem('os_cfg', JSON.stringify({ tts: value })))
const persistGeneralCfg = () => {
localStorage.setItem('os_cfg', JSON.stringify({ tts: cfg.value.tts, audioChallenge: cfg.value.audioChallenge }))
}
watch(() => cfg.value.tts, persistGeneralCfg)
watch(() => cfg.value.audioChallenge, persistGeneralCfg)
watch(() => cfg.value.radioLevel, value => localStorage.setItem('os_cfg_level', JSON.stringify({ v: value })))
watch(() => cfg.value.voice, value => localStorage.setItem('os_cfg_voice', JSON.stringify({ v: value })))
}
watch(() => cfg.value.audioChallenge, () => resetAudioReveal())
const fieldMap = computed<Record<string, LessonField>>(() => {
const map: Record<string, LessonField> = {}
if (activeLesson.value) {
@@ -1613,6 +1914,14 @@ const fieldStates = computed<Record<string, FieldState>>(() => {
return map
})
function resetAudioReveal() {
audioReveal.value = !cfg.value.audioChallenge
}
function revealAudioContent() {
audioReveal.value = true
}
function blankSizeClass(key: string, override?: BlankWidth): string {
const field = fieldMap.value[key]
const size = (override || field?.width || 'md') as BlankWidth
@@ -1670,6 +1979,7 @@ function rollScenario(clear = false) {
const defaultType = activeLesson.value.defaultFrequency
activeFrequency.value = generated.frequencies.find(freq => freq.type === (defaultType || 'DEL')) || generated.frequencies[0] || null
resetAnswers(true)
resetAudioReveal()
if (clear) {
result.value = null
}
@@ -1840,30 +2150,133 @@ function tilt(event: MouseEvent) {
worldTiltStyle.value = { transform: `perspective(1200px) rotateX(${dy * -3}deg) rotateY(${dx * 3}deg)` }
}
function say(text: string) {
if (!text || !cfg.value.tts) return
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return
const synth = window.speechSynthesis
const utterance = new SpeechSynthesisUtterance(text)
const rate = 0.9 + (cfg.value.radioLevel - 3) * 0.12
utterance.rate = Math.min(1.5, Math.max(0.6, rate))
if (cfg.value.voice) {
const voiceName = cfg.value.voice.toLowerCase()
const voice = synth.getVoices().find(item => item.name.toLowerCase().includes(voiceName))
if (voice) utterance.voice = voice
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> {
const pending = pendingSayRequests.get(cacheKey)
if (pending) {
return pending
}
const request = (async () => {
const response: any = await api.post('/api/atc/say', payload, { auth: false })
const audioData = response?.audio
if (!audioData?.base64) {
throw new Error('Missing audio data')
}
const mime = audioData.mime || 'audio/wav'
return `data:${mime};base64,${audioData.base64}`
})()
pendingSayRequests.set(cacheKey, request)
try {
const dataUrl = await request
sayCache.set(cacheKey, dataUrl)
return dataUrl
} 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
}
}
try {
await audio.play()
} catch (err) {
console.error('Audio playback failed', err)
}
}
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 hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window
stopAudio()
if (hasBrowserTts) {
const synth = window.speechSynthesis
const utterance = new SpeechSynthesisUtterance(trimmed)
utterance.rate = normalizedRate
if (cfg.value.voice) {
const voiceName = cfg.value.voice.toLowerCase()
const voice = synth.getVoices().find(item => item.name.toLowerCase().includes(voiceName))
if (voice) utterance.voice = voice
}
synth.cancel()
synth.speak(utterance)
return
}
if (ttsLoading.value) return
const payload: Record<string, unknown> = {
text: trimmed,
level: cfg.value.radioLevel,
speed: normalizedRate,
moduleId: current.value?.id || 'learn',
lessonId: activeLesson.value?.id || null,
tag: 'learn-target'
}
if (cfg.value.voice) {
payload.voice = cfg.value.voice
}
const cacheKey = buildSayCacheKey(trimmed, normalizedRate)
ttsLoading.value = true
try {
let dataUrl = sayCache.get(cacheKey)
if (!dataUrl) {
dataUrl = await requestSayAudio(cacheKey, payload)
}
await playAudioSource(dataUrl)
} catch (err) {
console.error('TTS request failed', err)
} finally {
ttsLoading.value = false
}
synth.cancel()
synth.speak(utterance)
}
function stopAudio() {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel()
}
if (audioElement.value) {
try {
audioElement.value.pause()
audioElement.value.currentTime = 0
} catch (err) {
// ignore pause errors
}
audioElement.value = null
}
}
function testBeep() {
say('Frankfurt Ground, Lufthansa one two three, request taxi.')
void say('Frankfurt Ground, Lufthansa one two three, request taxi.')
}
onMounted(() => {
@@ -2558,6 +2971,46 @@ onMounted(() => {
opacity: 0.8;
}
.audio-blur {
filter: blur(8px);
pointer-events: none;
user-select: none;
}
.audio-note {
margin-top: 6px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.settings {
display: flex;
flex-direction: column;
gap: 12px;
}
.set-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.set-info {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 70%;
}
.sr-only {
position: absolute;
width: 1px;