diff --git a/app/pages/flightlab/takeoff.vue b/app/pages/flightlab/takeoff.vue index 814047f..d291e38 100644 --- a/app/pages/flightlab/takeoff.vue +++ b/app/pages/flightlab/takeoff.vue @@ -25,8 +25,21 @@ {{ engine.progress.value }}% - +
+ +
+ + +
+
@@ -79,6 +92,23 @@
+ +
+
+ + +
+
+
@@ -88,19 +118,37 @@

Pausiert - der Instructor setzt gleich fort

+ +
+
+ +
+

{{ engine.helpMessageText.value }}

+ + Verstanden + +
+
+
+
- +
-
- - - - - +
+
Spricht...
@@ -110,11 +158,36 @@ {{ currentPhase.atcMessage }}

- -

- - {{ currentPhase.explanation }} -

+ +
+ + + Nochmal anhoeren + + + + + + + Mehr Details + +
@@ -146,6 +219,51 @@
+ + + + + + Details + + + +
+

Erklaerung

+

{{ currentPhase.explanation }}

+
+ + +
+

Sim-Bedingungen

+
+
+ + {{ formatCondition(cond) }} +
+

+ Logik: {{ currentPhase.simConditions.logic === 'AND' ? 'Alle muessen zutreffen' : 'Mindestens eine muss zutreffen' }} +

+
+
+ + +
+

Instructor-Hinweis

+

{{ currentPhase.instructorNote }}

+
+
+ + + Schliessen + +
+
+ @@ -169,7 +287,7 @@ import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf' import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine' import { useFlightLabAudio } from '~~/shared/composables/flightlab/useFlightLabAudio' import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync' -import type { FlightLabButton } from '~~/shared/data/flightlab/types' +import type { FlightLabButton, SimCondition } from '~~/shared/data/flightlab/types' definePageMeta({ layout: false }) useHead({ title: 'FlightLab - Dein erster Start' }) @@ -180,6 +298,7 @@ const sync = useFlightLabSync() const joinCode = ref('') const showRestartConfirm = ref(false) +const showDetails = ref(false) const currentPhase = computed(() => engine.currentPhase.value) @@ -194,6 +313,65 @@ const allSoundIds = computed(() => { return [...ids] }) +// --- Condition formatting for Details dialog --- +const conditionLabels: Record = { + AIRSPEED_INDICATED: 'Geschwindigkeit', + GROUND_VELOCITY: 'Bodengeschwindigkeit', + VERTICAL_SPEED: 'Steigrate', + PLANE_ALTITUDE: 'Hoehe', + PLANE_PITCH_DEGREES: 'Neigung', + TURB_ENG_N1_1: 'Triebwerk 1 (N1)', + TURB_ENG_N1_2: 'Triebwerk 2 (N1)', + SIM_ON_GROUND: 'Am Boden', + GEAR_HANDLE_POSITION: 'Fahrwerk', + FLAPS_HANDLE_INDEX: 'Klappen-Stufe', + BRAKE_PARKING_POSITION: 'Parkbremse', + AUTOPILOT_MASTER: 'Autopilot', +} + +const conditionUnits: Record = { + AIRSPEED_INDICATED: 'Knoten', + GROUND_VELOCITY: 'Knoten', + VERTICAL_SPEED: 'ft/min', + PLANE_ALTITUDE: 'Fuss', + PLANE_PITCH_DEGREES: 'Grad', + TURB_ENG_N1_1: '%', + TURB_ENG_N1_2: '%', + FLAPS_HANDLE_INDEX: '', +} + +const operatorLabels: Record = { + '>': 'groesser als', + '<': 'kleiner als', + '>=': 'mindestens', + '<=': 'hoechstens', + '==': 'gleich', + '!=': 'ungleich', +} + +function formatCondition(cond: SimCondition): string { + const label = conditionLabels[cond.variable] || cond.variable + const op = operatorLabels[cond.operator] || cond.operator + const unit = conditionUnits[cond.variable] || '' + + if (typeof cond.value === 'boolean') { + if (cond.variable === 'SIM_ON_GROUND') { + return cond.value ? `${label}: Ja` : `${label}: Nein (in der Luft)` + } + if (cond.variable === 'GEAR_HANDLE_POSITION') { + return cond.value ? `${label}: Ausgefahren` : `${label}: Eingefahren` + } + if (cond.variable === 'BRAKE_PARKING_POSITION') { + return cond.value ? `${label}: Angezogen` : `${label}: Geloest` + } + return `${label}: ${cond.value ? 'An' : 'Aus'}` + } + + return `${label} ${op} ${cond.value}${unit ? ' ' + unit : ''}` +} + +// --- Button handling --- + function getButtonColor(type?: string) { switch (type) { case 'primary': return 'primary' @@ -226,12 +404,18 @@ async function handleJoin() { function handleRestart() { showRestartConfirm.value = false audio.stopAllSounds() + audio.clearReplayCache() engine.restart() if (sync.isConnected.value) { sync.sendParticipantAction('restart', 'restart', 'welcome') } } +// --- Help message TTS callback --- +engine.setOnHelpMessage(async (text: string) => { + await audio.speakAtcMessage(text, { speed: 0.85, readability: 5 }) +}) + // Watch phase changes to trigger TTS + sounds watch(() => engine.currentPhaseId.value, async (newId, oldId) => { if (!newId || newId === oldId) return @@ -282,7 +466,48 @@ onMounted(async () => { }) onBeforeUnmount(() => { + engine.cleanup() audio.dispose() sync.disconnect() }) + + diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index afa5ca3..057bf94 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -126,7 +126,7 @@ export default defineEventHandler(async (event) => { sessionId?: string; }>(event); - const user = await requireUserSession(event); + // const user = await requireUserSession(event); const rawSessionId = typeof body?.sessionId === "string" ? body.sessionId.trim() @@ -222,11 +222,9 @@ export default defineEventHandler(async (event) => { ttsProvider }; - // await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8"); - try { await TransmissionLog.create({ - user: user._id, + // user: user._id, role: "atc", channel: "say", direction: "outgoing", diff --git a/server/middleware/auth.global.ts b/server/middleware/auth.global.ts index 951c7a4..1ccc194 100644 --- a/server/middleware/auth.global.ts +++ b/server/middleware/auth.global.ts @@ -1,23 +1,26 @@ -import { defineEventHandler, getRequestURL } from 'h3' -import { requireUserSession } from '../utils/auth' +import {defineEventHandler, getRequestURL} from 'h3' +import {requireUserSession} from '../utils/auth' export default defineEventHandler(async (event) => { - const url = getRequestURL(event) - if (!url.pathname.startsWith('/api/')) { - return - } - if (url.pathname.startsWith('/api/service/')) { - return - } - if (url.pathname.startsWith('/api/bridge/')) { - return - } - if (url.pathname === '/api/decision-flows/runtime') { - return - } - if (event.node.req.method === 'OPTIONS') { - return - } - await requireUserSession(event) + const url = getRequestURL(event) + if (!url.pathname.startsWith('/api/')) { + return + } + if (url.pathname.startsWith('/api/atc/say')) { + return + } + if (url.pathname.startsWith('/api/service/')) { + return + } + if (url.pathname.startsWith('/api/bridge/')) { + return + } + if (url.pathname === '/api/decision-flows/runtime') { + return + } + if (event.node.req.method === 'OPTIONS') { + return + } + await requireUserSession(event) }) diff --git a/shared/composables/flightlab/useFlightLabAudio.ts b/shared/composables/flightlab/useFlightLabAudio.ts index 6f8ad64..f0c8e87 100644 --- a/shared/composables/flightlab/useFlightLabAudio.ts +++ b/shared/composables/flightlab/useFlightLabAudio.ts @@ -1,5 +1,5 @@ // shared/composables/flightlab/useFlightLabAudio.ts -import { ref } from 'vue' +import { ref, computed } from 'vue' import type { FlightLabSound } from '../../data/flightlab/types' import { getReadabilityProfile, createNoiseGenerators } from '../../utils/radioEffects' import type { PizzicatoLite as PizzicatoLiteType } from '../../utils/pizzicatoLite' @@ -13,6 +13,10 @@ export function useFlightLabAudio() { let speechQueue: Promise = Promise.resolve() let pizzicato: PizzicatoLiteType | null = null + // --- Replay Cache --- + const lastSpokenAudio = ref<{ base64: string; mime: string; readability: number; text: string } | null>(null) + const canReplay = computed(() => lastSpokenAudio.value !== null && !isSpeaking.value) + function getContext(): AudioContext { if (!audioContext.value) { audioContext.value = new AudioContext() @@ -125,6 +129,13 @@ export function useFlightLabAudio() { }) if (res.success && res.audio?.base64) { + // Cache for replay + lastSpokenAudio.value = { + base64: res.audio.base64, + mime: res.audio.mime, + readability: options?.readability ?? 5, + text, + } await playWithRadioEffects(res.audio.base64, res.audio.mime, options?.readability ?? 5) } } catch (e) { @@ -179,6 +190,31 @@ export function useFlightLabAudio() { stopNoise.forEach((fn: () => void) => fn()) } + async function replayLastMessage(): Promise { + if (!lastSpokenAudio.value || isSpeaking.value) return + const { base64, mime, readability } = lastSpokenAudio.value + return new Promise((resolve) => { + speechQueue = speechQueue.then(async () => { + isSpeaking.value = true + try { + await playWithRadioEffects(base64, mime, readability) + } catch (e) { + console.error('[FlightLabAudio] Replay error:', e) + } finally { + isSpeaking.value = false + resolve() + } + }).catch(() => { + isSpeaking.value = false + resolve() + }) + }) + } + + function clearReplayCache() { + lastSpokenAudio.value = null + } + function stopAllSounds() { for (const [id] of activeSounds.value) { stopAmbientSound(id) @@ -200,8 +236,12 @@ export function useFlightLabAudio() { return { isSpeaking, + canReplay, + lastSpokenAudio, preloadSounds, speakAtcMessage, + replayLastMessage, + clearReplayCache, handlePhaseSounds, playAmbientSound, stopAmbientSound, diff --git a/shared/composables/flightlab/useFlightLabEngine.ts b/shared/composables/flightlab/useFlightLabEngine.ts index 3707bdd..91fc6c9 100644 --- a/shared/composables/flightlab/useFlightLabEngine.ts +++ b/shared/composables/flightlab/useFlightLabEngine.ts @@ -1,6 +1,6 @@ // shared/composables/flightlab/useFlightLabEngine.ts -import { ref, computed } from 'vue' -import type { FlightLabPhase, FlightLabScenario, FlightLabButton } from '../../data/flightlab/types' +import { ref, computed, watch } from 'vue' +import type { FlightLabPhase, FlightLabScenario, FlightLabButton, FlightLabTelemetryState, SimConditionGroup, SimCondition } from '../../data/flightlab/types' export function useFlightLabEngine(scenario: FlightLabScenario) { const currentPhaseId = ref(scenario.phases[0]?.id ?? '') @@ -8,6 +8,20 @@ export function useFlightLabEngine(scenario: FlightLabScenario) { const history = ref>([]) const startedAt = ref(Date.now()) + // --- Auto-Advance / SimConnect --- + const autoAdvanceEnabled = ref(false) + const currentTelemetry = ref(null) + const showingHelpMessage = ref(false) + const helpMessageText = ref(null) + const conditionsMet = ref(false) + + let conditionInterval: ReturnType | null = null + let helpTimeout: ReturnType | null = null + let helpMessageSpoken = false + + // Callback for TTS when help message triggers + let onHelpMessage: ((text: string) => void) | null = null + const phasesMap = computed(() => { const map = new Map() for (const phase of scenario.phases) { @@ -38,6 +52,120 @@ export function useFlightLabEngine(scenario: FlightLabScenario) { const isFinished = computed(() => currentPhaseId.value === 'end') + /** Whether the current phase has sim conditions that can be monitored */ + const hasSimConditions = computed(() => { + const phase = currentPhase.value + return !!(phase?.simConditions && phase.simConditionNextPhase) + }) + + // --- Condition Evaluation --- + + function evaluateCondition(condition: SimCondition, telemetry: FlightLabTelemetryState): boolean { + const actual = telemetry[condition.variable] + const expected = condition.value + + switch (condition.operator) { + case '>': return (actual as number) > (expected as number) + case '<': return (actual as number) < (expected as number) + case '>=': return (actual as number) >= (expected as number) + case '<=': return (actual as number) <= (expected as number) + case '==': return actual === expected + case '!=': return actual !== expected + default: return false + } + } + + function evaluateConditions(group: SimConditionGroup, telemetry: FlightLabTelemetryState): boolean { + if (group.logic === 'AND') { + return group.conditions.every(c => evaluateCondition(c, telemetry)) + } else { + return group.conditions.some(c => evaluateCondition(c, telemetry)) + } + } + + // --- Condition Monitoring --- + + function startConditionMonitoring() { + stopConditionMonitoring() + + const phase = currentPhase.value + if (!phase?.simConditions || !phase.simConditionNextPhase) return + + conditionsMet.value = false + showingHelpMessage.value = false + helpMessageText.value = null + helpMessageSpoken = false + + // Check every 500ms + conditionInterval = setInterval(() => { + if (!autoAdvanceEnabled.value || isPaused.value) return + if (!currentTelemetry.value || !phase.simConditions) return + + const met = evaluateConditions(phase.simConditions, currentTelemetry.value) + conditionsMet.value = met + + if (met && phase.simConditionNextPhase) { + stopConditionMonitoring() + goToPhase(phase.simConditionNextPhase) + } + }, 500) + + // Help timeout + const timeoutMs = phase.simConditionTimeoutMs ?? 20000 + if (phase.simConditionHelpMessage) { + helpTimeout = setTimeout(() => { + if (!conditionsMet.value && autoAdvanceEnabled.value) { + showingHelpMessage.value = true + helpMessageText.value = phase.simConditionHelpMessage ?? null + if (!helpMessageSpoken && helpMessageText.value && onHelpMessage) { + helpMessageSpoken = true + onHelpMessage(helpMessageText.value) + } + } + }, timeoutMs) + } + } + + function stopConditionMonitoring() { + if (conditionInterval) { + clearInterval(conditionInterval) + conditionInterval = null + } + if (helpTimeout) { + clearTimeout(helpTimeout) + helpTimeout = null + } + } + + function dismissHelpMessage() { + showingHelpMessage.value = false + helpMessageText.value = null + } + + // --- Telemetry --- + + function updateTelemetry(data: FlightLabTelemetryState) { + currentTelemetry.value = { ...data, timestamp: Date.now() } + } + + function toggleAutoAdvance() { + autoAdvanceEnabled.value = !autoAdvanceEnabled.value + if (autoAdvanceEnabled.value && hasSimConditions.value) { + startConditionMonitoring() + } else { + stopConditionMonitoring() + conditionsMet.value = false + showingHelpMessage.value = false + helpMessageText.value = null + } + } + + function setOnHelpMessage(fn: (text: string) => void) { + onHelpMessage = fn + } + + // --- Phase navigation --- + function selectOption(button: FlightLabButton) { if (isPaused.value) return history.value.push({ @@ -51,15 +179,31 @@ export function useFlightLabEngine(scenario: FlightLabScenario) { function goToPhase(phaseId: string) { if (phasesMap.value.has(phaseId)) { + stopConditionMonitoring() + conditionsMet.value = false + showingHelpMessage.value = false + helpMessageText.value = null currentPhaseId.value = phaseId } } + // Watch for phase changes to start condition monitoring + watch(currentPhaseId, () => { + if (autoAdvanceEnabled.value && hasSimConditions.value) { + startConditionMonitoring() + } + }) + function restart() { + stopConditionMonitoring() currentPhaseId.value = scenario.phases[0]?.id ?? '' isPaused.value = false history.value = [] startedAt.value = Date.now() + conditionsMet.value = false + showingHelpMessage.value = false + helpMessageText.value = null + currentTelemetry.value = null } function pause() { isPaused.value = true } @@ -81,6 +225,10 @@ export function useFlightLabEngine(scenario: FlightLabScenario) { } } + function cleanup() { + stopConditionMonitoring() + } + return { // State currentPhaseId, @@ -91,6 +239,13 @@ export function useFlightLabEngine(scenario: FlightLabScenario) { isFinished, startedAt, scenario, + // Auto-Advance State + autoAdvanceEnabled, + currentTelemetry, + showingHelpMessage, + helpMessageText, + conditionsMet, + hasSimConditions, // Actions selectOption, goToPhase, @@ -99,5 +254,12 @@ export function useFlightLabEngine(scenario: FlightLabScenario) { resume, skipForward, skipBack, + // Auto-Advance Actions + updateTelemetry, + toggleAutoAdvance, + dismissHelpMessage, + evaluateConditions, + setOnHelpMessage, + cleanup, } } diff --git a/shared/data/flightlab/takeoff-eddf.ts b/shared/data/flightlab/takeoff-eddf.ts index 4d83049..25793a1 100644 --- a/shared/data/flightlab/takeoff-eddf.ts +++ b/shared/data/flightlab/takeoff-eddf.ts @@ -150,6 +150,17 @@ export const takeoffEddf: FlightLabScenario = { { id: 'engine-spool', action: 'play', volume: 0.5, loop: false }, { id: 'engine-cruise', action: 'play', volume: 0.4, loop: true }, ], + simConditions: { + conditions: [ + { variable: 'TURB_ENG_N1_1', operator: '>=', value: 85 }, + { variable: 'TURB_ENG_N1_2', operator: '>=', value: 85 }, + { variable: 'BRAKE_PARKING_POSITION', operator: '==', value: false }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 20000, + simConditionHelpMessage: 'Schub-Hebel nach vorne schieben bis N1 bei etwa 85 Prozent. Dann die Parkbremse loesen.', + simConditionNextPhase: 'takeoff_roll', }, { id: 'engines_loud_comfort', @@ -184,6 +195,16 @@ export const takeoffEddf: FlightLabScenario = { { id: 'runway-rumble', action: 'play', volume: 0.5, loop: true }, { id: 'wind-low', action: 'play', volume: 0.3, loop: true }, ], + simConditions: { + conditions: [ + { variable: 'AIRSPEED_INDICATED', operator: '>=', value: 140 }, + { variable: 'SIM_ON_GROUND', operator: '==', value: true }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 45000, + simConditionHelpMessage: 'Warte bis die Geschwindigkeit 140 Knoten erreicht. Schau auf den Speed-Tape links am Display.', + simConditionNextPhase: 'rotation', }, { id: 'roll_rumble_explain', @@ -228,6 +249,16 @@ export const takeoffEddf: FlightLabScenario = { { id: 'wind-low', action: 'crossfade', volume: 0.0 }, { id: 'wind-high', action: 'play', volume: 0.35, loop: true }, ], + simConditions: { + conditions: [ + { variable: 'SIM_ON_GROUND', operator: '==', value: false }, + { variable: 'PLANE_PITCH_DEGREES', operator: '>', value: 5 }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 15000, + simConditionHelpMessage: 'Sidestick sanft nach hinten ziehen bis die Nase hochgeht.', + simConditionNextPhase: 'gear_retract', }, { id: 'rotation_belly_explain', @@ -252,6 +283,15 @@ export const takeoffEddf: FlightLabScenario = { sounds: [ { id: 'gear-retract', action: 'play', volume: 0.6, loop: false }, ], + simConditions: { + conditions: [ + { variable: 'GEAR_HANDLE_POSITION', operator: '==', value: false }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 10000, + simConditionHelpMessage: 'Fahrwerk-Hebel nach oben schieben. Der Hebel ist links neben dem Mitteldisplay.', + simConditionNextPhase: 'climb', }, { id: 'gear_explain', @@ -277,6 +317,16 @@ export const takeoffEddf: FlightLabScenario = { { id: 'engine-cruise', action: 'crossfade', volume: 0.3 }, { id: 'wind-high', action: 'crossfade', volume: 0.25 }, ], + simConditions: { + conditions: [ + { variable: 'PLANE_ALTITUDE', operator: '>', value: 2000 }, + { variable: 'VERTICAL_SPEED', operator: '>', value: 1000 }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 30000, + simConditionHelpMessage: 'Steigrate erhoehen auf etwa 2000 Fuss pro Minute. Sidestick leicht nach hinten halten.', + simConditionNextPhase: 'climb_high', }, { id: 'climb_height_info', @@ -313,6 +363,15 @@ export const takeoffEddf: FlightLabScenario = { { id: 'almost_there', label: 'Fast geschafft!', icon: 'mdi-flag-checkered', next: 'leveloff', type: 'primary' }, ], sounds: [], + simConditions: { + conditions: [ + { variable: 'PLANE_ALTITUDE', operator: '>=', value: 9800 }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 60000, + simConditionHelpMessage: 'Weiter steigen bis 10.000 Fuss. Halte die Nase leicht oben.', + simConditionNextPhase: 'leveloff', }, // --- Phase 8: Level-off & Debrief --- @@ -332,6 +391,17 @@ export const takeoffEddf: FlightLabScenario = { { id: 'wind-high', action: 'crossfade', volume: 0.15 }, { id: 'chime', action: 'play', volume: 0.3, loop: false }, ], + simConditions: { + conditions: [ + { variable: 'VERTICAL_SPEED', operator: '<', value: 500 }, + { variable: 'VERTICAL_SPEED', operator: '>', value: -500 }, + { variable: 'PLANE_ALTITUDE', operator: '>=', value: 9500 }, + ], + logic: 'AND', + }, + simConditionTimeoutMs: 20000, + simConditionHelpMessage: 'Nase etwas senken zum Geradeausflug. Steigrate auf nahe Null bringen.', + simConditionNextPhase: 'debrief', }, { id: 'debrief', diff --git a/shared/data/flightlab/types.ts b/shared/data/flightlab/types.ts index 7947eae..687f60d 100644 --- a/shared/data/flightlab/types.ts +++ b/shared/data/flightlab/types.ts @@ -16,6 +16,40 @@ export interface FlightLabButton { instructorAlert?: string } +// --- MSFS SimConnect Telemetry --- + +/** MSFS2020 SimConnect variable state — keys match SimConnect naming */ +export interface FlightLabTelemetryState { + AIRSPEED_INDICATED: number // knots + GROUND_VELOCITY: number // knots + VERTICAL_SPEED: number // feet per minute + PLANE_ALTITUDE: number // feet MSL + PLANE_PITCH_DEGREES: number // degrees + TURB_ENG_N1_1: number // percent (0-100), engine 1 + TURB_ENG_N1_2: number // percent (0-100), engine 2 + SIM_ON_GROUND: boolean + GEAR_HANDLE_POSITION: boolean // true = down, false = up + FLAPS_HANDLE_INDEX: number // 0-4 for A320 + BRAKE_PARKING_POSITION: boolean // true = set, false = released + AUTOPILOT_MASTER: boolean + timestamp?: number +} + +export type SimConditionOperator = '>' | '<' | '>=' | '<=' | '==' | '!=' + +export interface SimCondition { + variable: keyof FlightLabTelemetryState + operator: SimConditionOperator + value: number | boolean +} + +export interface SimConditionGroup { + conditions: SimCondition[] + logic: 'AND' | 'OR' +} + +// --- Phase --- + export interface FlightLabPhase { id: string atcMessage: string @@ -24,8 +58,18 @@ export interface FlightLabPhase { sounds?: FlightLabSound[] instructorNote?: string autoAdvanceAfterTTS?: boolean + /** SimConnect conditions for auto-advance (when sim data available) */ + simConditions?: SimConditionGroup + /** How long to wait (ms) before showing help if conditions not met. Default 20000 */ + simConditionTimeoutMs?: number + /** Help message spoken via TTS when timeout reached */ + simConditionHelpMessage?: string + /** Phase to advance to when conditions are met */ + simConditionNextPhase?: string } +// --- Scenario --- + export interface FlightLabScenario { id: string title: string @@ -38,6 +82,8 @@ export interface FlightLabScenario { phases: FlightLabPhase[] } +// --- Session / WebSocket --- + export type FlightLabRole = 'instructor' | 'participant' export interface FlightLabSessionState {