mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-13 05:05:41 +08:00
- Neutralize the local engine's autonomous auto-advance (evaluateAutoTransitions/ evaluateSimpleAutoFlow) — the Python backend now drives state exclusively via moveToSilent; the old walker could self-answer pilot states and race the backend, producing loops. - Dedupe applyBackendDecision's ATC log entry + TTS: moveToSilent no longer logs say_tpl for backend-driven auto-advanced states (suppressSay), and a lastAppliedSay guard drops a repeated decision from overlapping sources (transmit reply, telemetry tick, silence timeout). - Cap consecutive silence-timeout re-fires on the same state at 2 instead of re-arming forever. - Pause telemetry forwarding while a pilot transmission is in flight, and guard against overlapping telemetry POSTs. - Add request timeouts to TTS (20s) and backend transmit/telemetry/timeout (30s)/createSession (60s) calls so a hung request can no longer freeze the whole session. Verified live against the Python backend: a full clearance→taxi chain now logs each ATC line exactly once instead of 2-3x.
843 lines
37 KiB
TypeScript
843 lines
37 KiB
TypeScript
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
|
import type { Ref } from 'vue'
|
|
import { pmLog } from '../../shared/utils/pmLog'
|
|
import { SCENARIOS, type Scenario } from '../../shared/constants/scenarios'
|
|
import { normalizedFrequencyValue, type useFrequencyPresets } from '~/composables/useFrequencyPresets'
|
|
import type { useSessionState } from '~/composables/useSessionState'
|
|
import { useApi } from '~/composables/useApi'
|
|
import type { useRadioSpeech } from '~/composables/useRadioSpeech'
|
|
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
|
import {
|
|
isSimControlMatch,
|
|
isSimControlRejection,
|
|
parseSimControl,
|
|
simControlRejectionSpeech,
|
|
simControlResultSpeech,
|
|
type SimControlCommand,
|
|
type SimControlCommandResult,
|
|
} from '../../shared/utils/simControl'
|
|
|
|
export interface LiveAtcSessionDeps {
|
|
state: ReturnType<typeof useSessionState>
|
|
freq: ReturnType<typeof useFrequencyPresets>
|
|
speech: ReturnType<typeof useRadioSpeech>
|
|
radioBackend: ReturnType<typeof useRadioBackend>
|
|
api: ReturnType<typeof useApi>
|
|
/** NUXT_PUBLIC_* runtime config (radioBackendUrl, pttMinWords). */
|
|
config: ReturnType<typeof useRuntimeConfig>
|
|
/** Pre-generate ATIS audio once the airport is known (useAtisPlayback). */
|
|
prefetchAtisAudio: () => void
|
|
/** True while the pilot holds PTT — the silence timer must not talk over them. */
|
|
isRecording: Ref<boolean>
|
|
bridgeConnected: Ref<boolean>
|
|
bridgePosition: Ref<{ lat: number; lon: number } | null>
|
|
/** ?token=… from the route — auths the frequency-sim-control channel (design §4). */
|
|
bridgeToken: Ref<string>
|
|
persistSelectedPlan: (plan: any | null) => void
|
|
maybeShowFirstRunHelp: () => void
|
|
}
|
|
|
|
/**
|
|
* The session controller: everything that talks to the Python backend and moves
|
|
* the user between screens.
|
|
*
|
|
* Constructed last, after every composable it calls into. The composables that
|
|
* in turn need handlePilotTransmission/applyBackendDecision (speech, PTT,
|
|
* bridge) receive them as thin forwarding closures from the page, which is what
|
|
* breaks the cycle — see useSessionState for the other half of the split.
|
|
*/
|
|
export function useLiveAtcSession(
|
|
engine: ReturnType<typeof useCommunicationsEngine>,
|
|
deps: LiveAtcSessionDeps,
|
|
) {
|
|
const {
|
|
currentState, activeFlow, setActiveFlow, moveToSilent, patchVariables, patchFlags,
|
|
appendLogEntry, initializeFlight, fetchRuntimeTree, isReady: engineReady,
|
|
collectAtcStatesUntilPilotTurn, variables: vars, clearCommunicationLog,
|
|
} = engine
|
|
|
|
const {
|
|
state, freq, speech, radioBackend, api, config, prefetchAtisAudio,
|
|
isRecording, bridgeConnected, bridgePosition, bridgeToken,
|
|
persistSelectedPlan, maybeShowFirstRunHelp,
|
|
} = deps
|
|
|
|
const {
|
|
currentScreen, loading, error, pilotInput,
|
|
vatsimId, flightPlans, selectedPlan,
|
|
completedScenario, activeScenario,
|
|
sessionStarting, sessionStartingMessage,
|
|
backendSessionId, lastControllerSay, backendExpectedPhrase,
|
|
lastReadbackReport, lastReadbackTranscript,
|
|
setLastTransmission, clearLastTransmission,
|
|
} = state
|
|
|
|
const {
|
|
frequencies, airportFrequencies, frequencySources, activeAirportIcao,
|
|
expectedFrequencyForState, acceptedFrequenciesForState, extractAtisRunway,
|
|
fetchAirportFrequencies,
|
|
} = freq
|
|
|
|
const {
|
|
RADIO_CHECK_RE, SAY_AGAIN_RE, FAREWELL_RE,
|
|
scheduleControllerSpeech, speakPilotReadback, answerRadioCheck,
|
|
} = speech
|
|
|
|
const { readbackEnabled } = speech
|
|
|
|
// Monotonic counter so a newer pilot transmission supersedes an older one still
|
|
// awaiting its backend response — only the latest result is applied (#16).
|
|
let transmitGeneration = 0
|
|
|
|
// True while a pilot transmission is out at the backend. Read by useAiTraffic's
|
|
// gating chain: simulated traffic must not key up in the window between the
|
|
// user finishing their call and ATC answering it, or the traffic call reads as
|
|
// the reply. A counter rather than a boolean, so two overlapping transmissions
|
|
// can't have the first one's finally clear the flag out from under the second.
|
|
const transmitInFlightCount = ref(0)
|
|
const transmitInFlight = computed(() => transmitInFlightCount.value > 0)
|
|
|
|
// --- Silence auto-advance ----------------------------------------------------
|
|
// Some pilot states let ATC continue on its own when the pilot stays quiet
|
|
// (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a
|
|
// while anyway). Such states carry auto_advance_on_silence +
|
|
// auto_advance_timeout_ms in the runtime tree; whenever the session lands on
|
|
// one, arm a timer that fires the backend /timeout endpoint. Any pilot
|
|
// transmission or telemetry-fired advance re-arms or clears it via
|
|
// applyBackendDecision.
|
|
let silenceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Consecutive silence-timeouts fired for the SAME state. If the backend keeps
|
|
// re-prompting the identical auto_advance_on_silence state (e.g. it re-asks the
|
|
// same read-back-less confirmation every timeout window), re-arming forever
|
|
// reads as ATC repeating itself on a loop. Reset on any pilot transmission or
|
|
// once the decision actually lands on a different state.
|
|
let silenceFireCount: { stateId: string | null; count: number } = { stateId: null, count: 0 }
|
|
const MAX_CONSECUTIVE_SILENCE_FIRES = 2
|
|
|
|
function clearSilenceTimer() {
|
|
if (silenceTimer) {
|
|
clearTimeout(silenceTimer)
|
|
silenceTimer = null
|
|
}
|
|
}
|
|
|
|
function resetSilenceFireCount() {
|
|
silenceFireCount = { stateId: null, count: 0 }
|
|
}
|
|
|
|
function armSilenceTimer() {
|
|
clearSilenceTimer()
|
|
const state = currentState.value as any
|
|
if (!state?.auto_advance_on_silence || !backendSessionId.value) return
|
|
|
|
if (silenceFireCount.stateId !== state.id) {
|
|
silenceFireCount = { stateId: state.id, count: 0 }
|
|
}
|
|
if (silenceFireCount.count >= MAX_CONSECUTIVE_SILENCE_FIRES) {
|
|
pmLog.warn('SILENCE TIMER not re-armed — state fired', silenceFireCount.count, 'times already:', state.id)
|
|
return
|
|
}
|
|
|
|
const ms = Math.max(1000, Number(state.auto_advance_timeout_ms ?? 30000))
|
|
const sessionAtArm = backendSessionId.value
|
|
const stateAtArm = state.id
|
|
pmLog.debug('SILENCE TIMER armed', { state: stateAtArm, ms })
|
|
const fire = async () => {
|
|
silenceTimer = null
|
|
// Never fire stale: the session ended or the state moved on while waiting.
|
|
if (backendSessionId.value !== sessionAtArm) return
|
|
if ((currentState.value as any)?.id !== stateAtArm) return
|
|
// Don't talk over the pilot mid-PTT — check again shortly.
|
|
if (isRecording.value) {
|
|
silenceTimer = setTimeout(fire, 5000)
|
|
return
|
|
}
|
|
try {
|
|
pmLog.info('SILENCE TIMEOUT fired →', stateAtArm)
|
|
silenceFireCount = {
|
|
stateId: stateAtArm,
|
|
count: (silenceFireCount.stateId === stateAtArm ? silenceFireCount.count : 0) + 1,
|
|
}
|
|
const response = await radioBackend.timeout(sessionAtArm)
|
|
applyBackendDecision(response)
|
|
} catch (e: any) {
|
|
// 422 = the state changed under us (race with a transmission) — harmless.
|
|
const status = e?.status ?? e?.response?.status
|
|
if (status !== 422) pmLog.warn('SILENCE TIMEOUT failed', e)
|
|
}
|
|
}
|
|
silenceTimer = setTimeout(fire, ms)
|
|
}
|
|
|
|
// Guards the ATC reply (log entry + TTS) against being applied twice for the
|
|
// same decision — applyBackendDecision can be reached from four sources
|
|
// (transmit reply, telemetry tick, silence timeout, bug-report restore) and
|
|
// two of them can legitimately fire back-to-back for the same outcome (e.g.
|
|
// a silence timeout racing an in-flight transmit reply), which otherwise
|
|
// spoke/logged the same confirmation 2-3x (design doc WP1 Fix 2).
|
|
let lastAppliedSay: { text: string; stateId: string; atMs: number } | null = null
|
|
const APPLIED_SAY_DEDUPE_MS = 15_000
|
|
|
|
// Apply a backend decision (from a pilot transmission, a telemetry tick, or a
|
|
// silence timeout) to the local engine + UI: sync the
|
|
// flow/cursor/variables/flags, speak the controller reply, and surface
|
|
// completion. Shared so all backend-driven ATC lands through one code path.
|
|
const applyBackendDecision = (
|
|
response: import('./useRadioBackend').RadioTransmitResponse,
|
|
opts: { transcript?: string } = {},
|
|
) => {
|
|
// If the backend has chained to a different flow, switch the local engine
|
|
// first so moveToSilent can find the new states and expectedFrequencyForState()
|
|
// returns the correct frequency_name.
|
|
if (response.active_flow && response.active_flow !== activeFlow.value) {
|
|
pmLog.info('FLOW CHANGE local:', activeFlow.value, '→ backend:', response.active_flow)
|
|
try {
|
|
setActiveFlow(response.active_flow)
|
|
} catch (e) {
|
|
pmLog.warn('setActiveFlow failed for', response.active_flow, e)
|
|
}
|
|
}
|
|
|
|
// Advance local cursor through every state the backend auto-walked, then the
|
|
// final state. moveToSilent updates current_unit, actions and handoffs
|
|
// without scheduling further auto-transitions. suppressSay: the ATC line for
|
|
// this decision is logged exactly once below, from the backend-rendered
|
|
// sayText — logging each walked state's own say_tpl too would duplicate it
|
|
// (2-3x on chained flows with multiple auto-advanced ATC/system states).
|
|
// TTS is unaffected either way: only scheduleControllerSpeech below plays audio.
|
|
for (const stateId of response.auto_advanced_states ?? []) {
|
|
pmLog.debug('moveToSilent ← auto_advanced:', stateId)
|
|
moveToSilent(stateId, { suppressSay: true })
|
|
}
|
|
pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id)
|
|
moveToSilent(response.next_state_id, { suppressSay: true })
|
|
|
|
// Capture the per-field readback diagnostic for the STT debug panel.
|
|
lastReadbackReport.value = response.readback_report ?? []
|
|
if (opts.transcript !== undefined) lastReadbackTranscript.value = opts.transcript
|
|
|
|
// Update expected phrase AFTER cursor moves so any local-engine reactive
|
|
// updates from moveToSilent have already settled.
|
|
backendExpectedPhrase.value = response.expected_pilot_template ?? null
|
|
|
|
// Sync variables + routing flags from backend so the local renderer stays in step.
|
|
if (response.variables && Object.keys(response.variables).length) {
|
|
patchVariables(response.variables)
|
|
pmLog.debug('variables synced:', response.variables)
|
|
}
|
|
if (response.flags && Object.keys(response.flags).length) {
|
|
patchFlags(response.flags)
|
|
pmLog.debug('flags synced:', response.flags)
|
|
}
|
|
|
|
// TTS: use the pre-rendered string from the backend (correct variable values).
|
|
let sayText = response.controller_say_rendered || response.controller_say_template
|
|
const rb = response.readback_report ?? []
|
|
const readbackPassed = rb.length > 0 && rb.every((r: any) => r.matched)
|
|
if (sayText && readbackPassed && !/^\s*(readback correct|correct[,.\s]|negative|i say again)/i.test(sayText)) {
|
|
sayText = `Readback correct. ${sayText}`
|
|
}
|
|
if (sayText) {
|
|
const isDuplicate =
|
|
lastAppliedSay
|
|
&& lastAppliedSay.text === sayText
|
|
&& lastAppliedSay.stateId === response.next_state_id
|
|
&& Date.now() - lastAppliedSay.atMs < APPLIED_SAY_DEDUPE_MS
|
|
if (isDuplicate) {
|
|
pmLog.info('TTS SKIPPED (duplicate of last-applied decision) →', sayText)
|
|
} else {
|
|
pmLog.info('TTS →', sayText)
|
|
lastControllerSay.value = sayText
|
|
scheduleControllerSpeech(sayText)
|
|
appendLogEntry('atc', sayText, response.next_state_id, {
|
|
flow: response.active_flow,
|
|
frequency: frequencies.value.active,
|
|
})
|
|
lastAppliedSay = { text: sayText, stateId: response.next_state_id, atMs: Date.now() }
|
|
}
|
|
}
|
|
|
|
if (response.fallback_used) {
|
|
pmLog.warn('FALLBACK USED:', response.fallback_reason)
|
|
console.warn('[Backend] Fallback used:', response.fallback_reason)
|
|
}
|
|
|
|
if (response.session_complete) {
|
|
pmLog.info('SESSION COMPLETE — showing completion screen')
|
|
completedScenario.value = activeScenario.value
|
|
currentScreen.value = 'complete'
|
|
}
|
|
|
|
// Re-arm (or clear) the silence auto-advance for whatever state we're now on.
|
|
armSilenceTimer()
|
|
}
|
|
|
|
// --- Frequency-sim-control (design doc §4) ------------------------------------
|
|
// A matched command from handlePilotTransmission is queued server-side for the
|
|
// bridge to execute; the result comes back asynchronously via the /api/bridge/live
|
|
// poll (see handleSimControlResult below), so this call itself stays silent on
|
|
// success — only a network/enqueue failure gets an immediate ATC reply.
|
|
const sendSimControlCommand = async (command: SimControlCommand) => {
|
|
try {
|
|
await $fetch('/api/bridge/command', {
|
|
method: 'POST',
|
|
headers: { 'x-bridge-token': bridgeToken.value },
|
|
body: { command },
|
|
})
|
|
} catch (e) {
|
|
pmLog.warn('SIM CONTROL enqueue failed', e)
|
|
const reply = 'unable to reach bridge, say again'
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
}
|
|
}
|
|
|
|
/** Speak the outcome of a bridge command surfaced by useSimBridgeSync's telemetry poll. */
|
|
const handleSimControlResult = (result: SimControlCommandResult) => {
|
|
const reply = simControlResultSpeech(result)
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
}
|
|
|
|
const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => {
|
|
const transcript = message.trim()
|
|
// Ignore empty or content-free transmissions: silence, a stray PTT tap, or
|
|
// Whisper hallucinating punctuation on near-silent audio. A genuine short call
|
|
// ("roger", "wilco") still contains letters/digits and passes.
|
|
if (!transcript || !/[a-z0-9]/i.test(transcript)) return
|
|
|
|
// STT MINIMUM-WORD GATE — search here if a spoken transmission was ignored.
|
|
// Voice (PTT) only: drop transcripts shorter than the configured minimum.
|
|
// Whisper hallucinates short real words ("Test", "Thank you", "Okay") on
|
|
// near-silent or noisy audio; left unfiltered those reach the backend as a
|
|
// (wrong) readback attempt — counting toward the 3x-skip — and now also
|
|
// trigger a paid LLM-router call. Text input is exempt so deliberate short
|
|
// commands still work. Threshold is the NUXT_PUBLIC_PTT_MIN_WORDS env var
|
|
// (default 2); set it to 1 to effectively disable the gate.
|
|
const minPttWords = Number(config.public.pttMinWords ?? 2)
|
|
if (source === 'ptt' && transcript.split(/\s+/).filter(Boolean).length < minPttWords) {
|
|
pmLog.info(`IGNORED short PTT transcript (<${minPttWords} words):`, transcript)
|
|
return
|
|
}
|
|
|
|
// Mark this as the latest transmission; a newer one started before the backend
|
|
// replies supersedes it (the stale reply is dropped below).
|
|
const myGen = ++transmitGeneration
|
|
|
|
const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot'
|
|
setLastTransmission(`${prefix}: ${transcript}`)
|
|
|
|
// Log the pilot's call with the ACTUAL tuned frequency (the engine's own
|
|
// notion of "active frequency" is unit-derived and can lag what's tuned).
|
|
appendLogEntry('pilot', transcript, currentState.value?.id ?? '', {
|
|
frequency: frequencies.value.active,
|
|
})
|
|
|
|
if (readbackEnabled.value) {
|
|
speakPilotReadback(transcript)
|
|
}
|
|
|
|
// --- Radio check ---
|
|
// A service call, valid on ANY frequency and answered by whoever owns it.
|
|
// Handled entirely locally: it must bypass the wrong-frequency gate (its
|
|
// purpose is verifying the tuned frequency) and never reach the backend
|
|
// flow engine (the training state stays untouched).
|
|
if (RADIO_CHECK_RE.test(transcript)) {
|
|
answerRadioCheck(transcript)
|
|
return
|
|
}
|
|
|
|
// --- Say again ---
|
|
// Pilot asks ATC to repeat. Handled locally: re-speak the last controller
|
|
// transmission without advancing the flow.
|
|
if (SAY_AGAIN_RE.test(transcript) && lastControllerSay.value) {
|
|
const reply = `I say again. ${lastControllerSay.value}`
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
return
|
|
}
|
|
|
|
// --- Sign-off ---
|
|
// A bare goodbye / thank-you (no readback or request alongside): acknowledge
|
|
// politely instead of evaluating it as a transmission. Requires a farewell
|
|
// with no digits, no readback/acknowledgement words, and only a few words, so
|
|
// it never swallows a real call like "wilco, thanks" or a frequency readback.
|
|
const wordCount = transcript.split(/\s+/).filter(Boolean).length
|
|
const hasActionWord = /\b(wilco|roger|cleared|clear|runway|contact|ready|request|squawk|descend|climb|taxi|line\s?up|holding|hold short|approved|affirm|going|switching|push|startup)\b/i.test(transcript)
|
|
if (FAREWELL_RE.test(transcript) && !/\d/.test(transcript) && !hasActionWord && wordCount <= 6) {
|
|
const cs = (vars as any).value?.callsign ?? ''
|
|
const reply = cs ? `Good day, ${cs}.` : 'Good day.'
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
return
|
|
}
|
|
|
|
// --- Sim control (frequency-driven simulator command) ---
|
|
// A meta channel: the pilot talking to their OWN simulator, not ATC — so it
|
|
// runs before the frequency gate (like radio check) and, unlike every other
|
|
// transmission, never reaches radioBackend.transmit()/the decision flow.
|
|
// Only live while a bridge is actually connected (design doc §4); parseSimControl's
|
|
// anchors already keep it from ever matching real ICAO phraseology either way.
|
|
if (bridgeConnected.value) {
|
|
const simResult = parseSimControl(transcript)
|
|
if (isSimControlMatch(simResult)) {
|
|
void sendSimControlCommand(simResult.command)
|
|
return
|
|
}
|
|
// A rejection with a reason means the pilot clearly addressed their sim but
|
|
// got something wrong — tell them. 'no_intent' just means this was ordinary
|
|
// radio traffic, so it falls through to the flow untouched.
|
|
if (isSimControlRejection(simResult) && simResult.reason !== 'no_intent') {
|
|
const reply = simControlRejectionSpeech(simResult.reason)
|
|
scheduleControllerSpeech(reply)
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active })
|
|
return
|
|
}
|
|
}
|
|
|
|
// --- Frequency check ---
|
|
// Reject the transmission if the pilot is on the wrong frequency. A position
|
|
// can publish several valid frequencies (e.g. two Tower freqs); accept any of
|
|
// them, and only fall back to the single expected value when no list resolves.
|
|
const expectedFreq = expectedFrequencyForState()
|
|
const acceptedFreqs = acceptedFrequenciesForState()
|
|
const activeNorm = normalizedFrequencyValue(frequencies.value.active)
|
|
const onValidFreq = acceptedFreqs.length
|
|
? acceptedFreqs.includes(activeNorm)
|
|
: frequencies.value.active === expectedFreq
|
|
if (expectedFreq && !onValidFreq) {
|
|
const callsign = (vars as any).value?.callsign ?? ''
|
|
const freqName = (currentState.value as any)?.frequency_name as string | undefined
|
|
// Tell the pilot exactly which frequency to switch to (and the position),
|
|
// since tuning is manual and the call won't go through until they do.
|
|
const target = freqName ? `${freqName} on ${expectedFreq}` : expectedFreq
|
|
const reply = callsign
|
|
? `${callsign}, you are on the wrong frequency. Contact ${target}.`
|
|
: `Station calling, wrong frequency. Contact ${target}.`
|
|
pmLog.warn('WRONG FREQUENCY', { active: frequencies.value.active, expected: expectedFreq })
|
|
lastControllerSay.value = reply
|
|
scheduleControllerSpeech(reply)
|
|
// Add the ATC "wrong frequency" reply to the communication log.
|
|
appendLogEntry('atc', reply, currentState.value?.id ?? '', {
|
|
offSchema: true,
|
|
frequency: frequencies.value.active,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!backendSessionId.value) {
|
|
pmLog.error('TRANSMIT BLOCKED — no backend session', { transcript, source })
|
|
console.error('No backend session — cannot transmit')
|
|
setLastTransmission(`${prefix}: ${transcript} (no session)`)
|
|
return
|
|
}
|
|
|
|
pmLog.group(`▶ TRANSMIT [${source}] session=${backendSessionId.value.slice(0,8)}`, () => {
|
|
pmLog.info('utterance :', transcript)
|
|
pmLog.info('local state :', currentState.value?.id ?? '—')
|
|
pmLog.info('session_id :', backendSessionId.value)
|
|
})
|
|
|
|
transmitInFlightCount.value++
|
|
try {
|
|
// The pilot spoke — a pending silence auto-advance no longer applies, and
|
|
// a live transmission means the loop-guard's job is done for this state.
|
|
// The response re-arms it (fresh count) if the next state also allows silence.
|
|
clearSilenceTimer()
|
|
resetSilenceFireCount()
|
|
const response = await radioBackend.transmit(backendSessionId.value, transcript)
|
|
|
|
// Drop a stale reply if the pilot already transmitted again while this call
|
|
// was in flight — only the newest transmission's result is applied (#16).
|
|
if (myGen !== transmitGeneration) {
|
|
pmLog.info('Discarded stale transmission reply (superseded by a newer transmission)')
|
|
return
|
|
}
|
|
|
|
pmLog.group(`✓ RESPONSE ${currentState.value?.id ?? '?'} → ${response.next_state_id}`, () => {
|
|
pmLog.info('next_state :', response.next_state_id)
|
|
pmLog.info('auto_advanced :', response.auto_advanced_states ?? [])
|
|
pmLog.info('fallback_used :', response.fallback_used, response.fallback_reason ?? '')
|
|
pmLog.info('say_template :', response.controller_say_template ?? '—')
|
|
pmLog.info('say_rendered :', response.controller_say_rendered ?? '—')
|
|
pmLog.info('expected_pilot :', response.expected_pilot_template ?? '—')
|
|
pmLog.info('variables :', response.variables)
|
|
pmLog.info('flags :', response.flags)
|
|
if (response.trace?.length) {
|
|
pmLog.debug('trace:')
|
|
response.trace.forEach(t => pmLog.debug(` [${t.type}]`, t.message))
|
|
}
|
|
})
|
|
|
|
applyBackendDecision(response, { transcript })
|
|
} catch (e: any) {
|
|
const status = e?.status ?? e?.response?.status
|
|
if (status === 404) {
|
|
// Session expired (idle timeout) or backend restarted without the
|
|
// persisted session — reset to scenario selection for a clean restart.
|
|
pmLog.error('SESSION GONE (404) — resetting to scenario selection', { session: backendSessionId.value })
|
|
backendSessionId.value = null
|
|
error.value = 'Session expired — please start the scenario again.'
|
|
currentScreen.value = 'scenario'
|
|
return
|
|
}
|
|
const isTimeout =
|
|
e?.name === 'TimeoutError'
|
|
|| e?.name === 'AbortError'
|
|
|| e?.cause?.name === 'TimeoutError'
|
|
|| e?.cause?.name === 'AbortError'
|
|
pmLog.error(isTimeout ? 'TRANSMIT TIMED OUT' : 'TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e })
|
|
console.error('Backend transmission failed', e)
|
|
setLastTransmission(`${prefix}: ${transcript} (${isTimeout ? 'backend timeout' : 'backend failed'})`)
|
|
} finally {
|
|
transmitInFlightCount.value = Math.max(0, transmitInFlightCount.value - 1)
|
|
}
|
|
}
|
|
|
|
// VATSIM Integration
|
|
const loadFlightPlans = async () => {
|
|
if (!vatsimId.value) return
|
|
|
|
loading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
const response = await api.get('/api/vatsim/flightplans', {
|
|
query: { cid: vatsimId.value }
|
|
})
|
|
|
|
if (Array.isArray(response) && response.length > 0) {
|
|
flightPlans.value = response.slice(0, 10)
|
|
currentScreen.value = 'flightselect'
|
|
} else {
|
|
error.value = 'No flight plans found for this VATSIM ID'
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading flight plans:', err)
|
|
error.value = 'Error loading flight plans. Please check the VATSIM ID.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const startMonitoring = async (flightPlan: any, scenario: Scenario) => {
|
|
activeScenario.value = scenario
|
|
sessionStarting.value = true
|
|
sessionStartingMessage.value = /^taxi/.test(scenario.startFlow)
|
|
? 'Calculating taxi route…'
|
|
: 'Starting session…'
|
|
|
|
// 1. Ensure the local tree is loaded from the Python backend (same source as session)
|
|
try {
|
|
if (!engineReady.value) {
|
|
await fetchRuntimeTree(scenario.startFlow, config.public.radioBackendUrl as string)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to prepare decision engine', err)
|
|
error.value = 'Entscheidungsbaum konnte nicht geladen werden.'
|
|
sessionStarting.value = false
|
|
return
|
|
}
|
|
|
|
// 2. Initialize the local flight engine first so generated values (stand, SID, squawk)
|
|
// are computed and available in vars.value before we create the backend session.
|
|
initializeFlight(flightPlan)
|
|
|
|
// 3a. Resolve which airport to use for frequencies.
|
|
// Departure scenarios use the dep airport; arrival scenarios use arr.
|
|
const scenarioIcao: string | undefined =
|
|
scenario.airport === 'arr'
|
|
? (flightPlan.arr || flightPlan.arrival || flightPlan.dep || flightPlan.departure)
|
|
: (flightPlan.dep || flightPlan.departure || flightPlan.arr || flightPlan.arrival)
|
|
activeAirportIcao.value = scenarioIcao
|
|
|
|
// Fetch real airport frequencies BEFORE building backendVariables so that
|
|
// all freq vars are resolved from live VATSIM/OpenAIP data.
|
|
await fetchAirportFrequencies(scenarioIcao)
|
|
|
|
// Runway in use from live ATIS (the flight plan carries no runway). Falls
|
|
// back to the engine-generated one when no ATIS text mentions a runway.
|
|
const atisRunway = extractAtisRunway(scenario.airport === 'arr' ? 'arr' : 'dep')
|
|
if (atisRunway) {
|
|
patchVariables({ runway: atisRunway })
|
|
pmLog.info('Runway from ATIS:', atisRunway)
|
|
}
|
|
|
|
// Pre-generate the ATIS TTS audio now so the first tune-in plays instantly
|
|
// instead of waiting for generation. Fire-and-forget.
|
|
prefetchAtisAudio()
|
|
|
|
// 3b. Build the backend variable payload. The backend stores ALL keys so that
|
|
// frequencies declared in downstream chained flows (tower_freq for taxi-v1,
|
|
// departure_freq for tower-v1, etc.) are already populated with real airport
|
|
// values when the session advances to those flows.
|
|
const v = (vars as any).value
|
|
const backendVariables: Record<string, any> = {
|
|
callsign: v.callsign || flightPlan.callsign || 'UNKNOWN',
|
|
information: v.atis_code || 'K',
|
|
destination: v.dest || flightPlan.arr || flightPlan.arrival || 'Unknown',
|
|
stand: v.stand || 'A1',
|
|
sid: v.sid || 'UNKNOWN1A',
|
|
initial_altitude: String(v.initial_altitude_ft ?? 5000),
|
|
squawk: String(v.squawk ?? '2000'),
|
|
// Shared / arrival variables. The engine generates these, but they were not
|
|
// being forwarded — so arrival flows (and taxi/tower on departure) fell back
|
|
// to YAML defaults and ignored the selected flight. Names are mapped to the
|
|
// backend flow conventions (qnh_hpa→qnh, acf_type→aircraft_type, …).
|
|
runway: atisRunway || v.runway || '25R',
|
|
qnh: String(v.qnh_hpa ?? '1013'),
|
|
surface_wind: v.surface_wind || '250/08',
|
|
// taxi_route is intentionally NOT sent: the backend computes the real OSM
|
|
// taxi route (and crossings) from airport_icao + stand/runway, and falls
|
|
// back to the flow's YAML default on its own. Sending a placeholder here
|
|
// would count as a caller override and suppress that computation.
|
|
aircraft_type: v.acf_type || 'A320',
|
|
cruise_level: v.cruise_flight_level || 'FL360',
|
|
assigned_squawk: String(v.squawk ?? '2000'),
|
|
// All airport frequencies — available to every flow in the chain.
|
|
delivery_freq: v.delivery_freq || '121.950',
|
|
ground_freq: v.ground_freq || '121.800',
|
|
tower_freq: v.tower_freq || '118.700',
|
|
departure_freq: v.departure_freq || '120.000',
|
|
approach_freq: v.approach_freq || '119.000',
|
|
handoff_freq: v.handoff_freq || '131.150',
|
|
}
|
|
|
|
// VFR flights identify by aircraft registration, not an airline callsign.
|
|
// Assign a German D-registration and its abbreviated form (D-EMIL -> D-IL,
|
|
// first letter + last two), which ATC uses after the first call. Mirror it
|
|
// into the local engine vars and HUD so the display matches the radio.
|
|
if (scenario.startFlow.startsWith('vfr') || scenario.startFlow.startsWith('info')) {
|
|
const pool = ['D-EMIL', 'D-EKLM', 'D-ENNY', 'D-ELLA', 'D-EOMT', 'D-ELPC', 'D-EMTO', 'D-EBRA']
|
|
const reg = pool[Math.floor(Math.random() * pool.length)]
|
|
const short = `D-${reg.replace(/^D-/, '').slice(-2)}`
|
|
backendVariables.callsign = reg
|
|
backendVariables.callsign_short = short
|
|
patchVariables({ callsign: reg, callsign_short: short })
|
|
} else {
|
|
backendVariables.callsign_short = backendVariables.callsign
|
|
}
|
|
pmLog.info('backend variables payload:', backendVariables)
|
|
|
|
// 4. Create a backend session with the flight-plan-derived variables
|
|
try {
|
|
error.value = ''
|
|
const session = await radioBackend.createSession(
|
|
scenario.startFlow,
|
|
backendVariables,
|
|
scenario.noChain,
|
|
scenarioIcao,
|
|
v.dest || flightPlan.arr || flightPlan.arrival,
|
|
// Live aircraft position (bridge connected): backend starts the taxi
|
|
// route at the real parking position instead of a random stand.
|
|
bridgeConnected.value ? bridgePosition.value : null,
|
|
)
|
|
backendSessionId.value = session.session_id
|
|
pmLog.group(`SESSION CREATED id=${session.session_id.slice(0, 8)}`, () => {
|
|
pmLog.info('flow :', session.flow_slug)
|
|
pmLog.info('start state :', session.current_state)
|
|
pmLog.info('variables :', session.variables)
|
|
pmLog.info('flags :', session.flags)
|
|
})
|
|
// Sync cursor to wherever the backend initialised (usually start_state)
|
|
moveToSilent(session.current_state)
|
|
pmLog.debug('moveToSilent ← session start_state:', session.current_state)
|
|
// Sync session variables back so the local renderer stays in step.
|
|
if (session.variables && Object.keys(session.variables).length) {
|
|
patchVariables(session.variables)
|
|
}
|
|
// Sync boolean routing flags from session
|
|
if (session.flags && Object.keys(session.flags).length) {
|
|
patchFlags(session.flags)
|
|
}
|
|
// Seed the expected pilot phrase from the start state
|
|
backendExpectedPhrase.value = session.expected_pilot_template ?? null
|
|
pmLog.info('expected_pilot (session start):', backendExpectedPhrase.value ?? '—')
|
|
} catch (err) {
|
|
pmLog.error('SESSION CREATE FAILED', err)
|
|
console.error('Failed to create backend session', err)
|
|
error.value = 'Verbindung zum Training-Backend fehlgeschlagen.'
|
|
return
|
|
} finally {
|
|
// The slow part (route computation) is done once createSession resolves.
|
|
sessionStarting.value = false
|
|
}
|
|
|
|
error.value = ''
|
|
selectedPlan.value = flightPlan
|
|
currentScreen.value = 'monitor'
|
|
persistSelectedPlan(flightPlan)
|
|
maybeShowFirstRunHelp()
|
|
|
|
// Start from a known baseline; the first controller's frequency is tuned in
|
|
// automatically below, once the initial state-walk tells us which pilot state
|
|
// the scenario opens on.
|
|
frequencies.value.active = '121.900'
|
|
frequencies.value.standby = '118.100'
|
|
|
|
// 4. Walk the initial ATC/system states locally (deterministic, no LLM).
|
|
// Safe because we loaded the tree from the same Python backend, so the
|
|
// walk is identical to what the backend will do on the first transmission.
|
|
try {
|
|
await nextTick()
|
|
const startMessages = collectAtcStatesUntilPilotTurn()
|
|
for (const msg of startMessages) {
|
|
scheduleControllerSpeech(msg.say_tpl)
|
|
}
|
|
} catch (err) {
|
|
console.warn('Initial state advance failed:', err)
|
|
}
|
|
|
|
// Pre-tune COM1 active to the frequency the opening pilot call is expected on,
|
|
// so the very first transmission isn't met with a confusing 'wrong frequency'
|
|
// rejection. Later handoffs stay manual — tuning to the next controller
|
|
// remains part of the exercise.
|
|
await nextTick()
|
|
const firstFreq = expectedFrequencyForState()
|
|
if (firstFreq) {
|
|
frequencies.value.active = firstFreq
|
|
pmLog.info('Pre-tuned COM1 active to first frequency:', firstFreq)
|
|
}
|
|
}
|
|
|
|
const startDemoFlight = () => {
|
|
const demoFlight = {
|
|
callsign: 'DLH39A',
|
|
aircraft: 'A320/L',
|
|
dep: 'EDDF',
|
|
arr: 'EDDM',
|
|
altitude: '36000',
|
|
}
|
|
selectedPlan.value = demoFlight
|
|
currentScreen.value = 'scenario'
|
|
}
|
|
|
|
/** Launch a specific scenario with the current flight plan. */
|
|
const launchScenario = async (scenario: Scenario) => {
|
|
if (!selectedPlan.value) return
|
|
await startMonitoring(selectedPlan.value, scenario)
|
|
}
|
|
|
|
/** Re-fly the same scenario that was just completed. */
|
|
const flyAgain = async () => {
|
|
if (!completedScenario.value || !selectedPlan.value) {
|
|
currentScreen.value = 'scenario'
|
|
return
|
|
}
|
|
await startMonitoring(selectedPlan.value, completedScenario.value)
|
|
}
|
|
|
|
const backToSetup = () => {
|
|
currentScreen.value = 'login'
|
|
selectedPlan.value = null
|
|
persistSelectedPlan(null)
|
|
clearLastTransmission()
|
|
airportFrequencies.value = []
|
|
frequencySources.value = { vatsim: false, openaip: false }
|
|
}
|
|
|
|
const sendPilotText = async () => {
|
|
const text = pilotInput.value.trim()
|
|
if (!text) return
|
|
|
|
pilotInput.value = ''
|
|
await handlePilotTransmission(text, 'text')
|
|
}
|
|
|
|
/**
|
|
* Restore a /live-atc session from a saved bug-report snapshot (admin link
|
|
* `/live-atc?restoreBugReport=<id>`). The Python backend has no "resume mid-session"
|
|
* endpoint, so we recreate a real, working session for the SAME flight and
|
|
* scenario via startMonitoring(), then overlay the saved variables/flags and
|
|
* the captured conversation so the admin can reproduce and try out the bug.
|
|
*/
|
|
async function restoreBugReportState(restoreId: string) {
|
|
try {
|
|
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
|
|
const state = report?.pmState
|
|
if (!state) {
|
|
error.value = 'Bug-Report enthält keinen gespeicherten State.'
|
|
return
|
|
}
|
|
|
|
// Locate the scenario the report was captured in.
|
|
const scenario =
|
|
SCENARIOS.find(s => s.id === state.scenarioId) ||
|
|
SCENARIOS.find(s => s.startFlow === state.flowSlug)
|
|
if (!scenario) {
|
|
error.value = `Bug-Report-Restore: Szenario "${state.scenarioId || state.flowSlug || '?'}" nicht gefunden.`
|
|
return
|
|
}
|
|
|
|
// Reconstruct a flight plan from the snapshot so startMonitoring resolves the
|
|
// correct airport/frequencies and creates a backend session for the same flight.
|
|
const v = state.variables || {}
|
|
const fc = state.flightContext || {}
|
|
const dep = v.dep || fc.dep
|
|
const dest = v.dest || fc.dest
|
|
const flightPlan: Record<string, any> = {
|
|
callsign: v.callsign || fc.callsign || 'UNKNOWN',
|
|
aircraft: v.acf_type || fc.acf_type || 'A320',
|
|
dep,
|
|
departure: dep,
|
|
arr: dest,
|
|
arrival: dest,
|
|
route: fc.route || v.route || '',
|
|
assignedsquawk: v.squawk,
|
|
}
|
|
|
|
// Spin up a real session (loads tree, fetches frequencies, creates backend session).
|
|
await startMonitoring(flightPlan, scenario)
|
|
// startMonitoring bails out on error without entering the monitor screen.
|
|
if (currentScreen.value !== 'monitor') return
|
|
|
|
// Overlay the exact saved values over the freshly generated ones (stand, SID, …).
|
|
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
|
|
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
|
|
|
|
// Restore the captured conversation for context.
|
|
clearCommunicationLog?.()
|
|
if (Array.isArray(state.communicationLog)) {
|
|
for (const e of state.communicationLog) {
|
|
if (!e?.message) continue
|
|
appendLogEntry(e.speaker || 'system', e.message, e.state || '', {
|
|
frequency: e.frequency,
|
|
flow: e.flow,
|
|
radioCheck: e.radioCheck,
|
|
offSchema: e.offSchema,
|
|
})
|
|
}
|
|
}
|
|
|
|
// A fresh backend session always starts at the flow's start state, so we
|
|
// can't fake the local cursor onto the captured mid-flow state without
|
|
// desyncing transmits. Tell the admin where the bug was captured instead.
|
|
error.value =
|
|
`Bug-Report wiederhergestellt: ${scenario.name} · ${flightPlan.callsign} (${dep || '?'}→${dest || '?'}). ` +
|
|
`Erfasster State: ${state.currentStateId || '?'} (Flow ${state.flowSlug || '?'}).`
|
|
} catch (err) {
|
|
console.warn('[PM] Bug report restore failed', err)
|
|
error.value = 'Bug-Report konnte nicht wiederhergestellt werden.'
|
|
}
|
|
}
|
|
|
|
onUnmounted(clearSilenceTimer)
|
|
|
|
return {
|
|
clearSilenceTimer,
|
|
transmitInFlight,
|
|
applyBackendDecision,
|
|
handlePilotTransmission,
|
|
handleSimControlResult,
|
|
loadFlightPlans,
|
|
startMonitoring,
|
|
startDemoFlight,
|
|
launchScenario,
|
|
flyAgain,
|
|
backToSetup,
|
|
sendPilotText,
|
|
restoreBugReportState,
|
|
}
|
|
}
|