feat(live-atc): add simulated AI background traffic on the tuned frequency

Implements the ai-traffic roadmap item per
docs/plans/2026-07-14-ai-traffic-architecture-design.md.

Simulated other aircraft on the user's frequency — callsigns, ATC
instructions, readbacks in their own stable voice, handovers — as pure
scenery. It never touches radioBackend: the Python backend keeps owning
the dialogue *with* the user, useAiTraffic owns the radio *around* the
user. The two share only the speech queue (arbitration) and the log.

Rules live as pure, seeded, framework-free modules under
shared/utils/aiTraffic/ so they run in tsx --test without a browser:
callsign collision rules, wake/in-trail separation, runway slots, the
speed ladder, direct validation, the §3 decision table, and the gating
chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick,
spawner, scheduler).

Gating is evaluated twice — before enqueue and again at playback, since
seconds pass in between. Traffic never keys up while the user holds PTT,
while their transmission is out at the backend, or inside the fresh
readback window. Off by default; the toggle surfaces the feature's v1
limitations rather than burying them in a doc.

Zero LLM calls: variance comes from seeded RNG over template variants.

Deviations from the design, both documented in the design doc:
- Adds SimAircraft.quietUntilSec. The design's rule table says "first
  matching row per tick" but never says an instruction must be allowed to
  take effect before the next one. Without it the planner re-derives the
  same unresolved condition every second and nags one aircraft with the
  same vector: 624 calls/30min measured, vs 90 with the cooldown.
- Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY
  already knows; UAE/AUA/WZZ from the design would be spelled out letter
  by letter instead of spoken as airline names.

Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc
compiles and serves. The manual in-session walkthrough (audible traffic,
toggle mid-session) is NOT verified — it needs a login and the Python
backend. The 30-minute deterministic integration run stands in for it and
caught two of the three bugs found during development.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-16 17:46:52 +02:00
parent ab04411bc5
commit 70f0b26d90
25 changed files with 3968 additions and 3 deletions

View File

@@ -19,6 +19,20 @@ const learningMode = defineModel<boolean>('learningMode', { required: true })
const debugMode = defineModel<boolean>('debugMode', { required: true })
const prerecEnabled = defineModel<boolean>('prerecEnabled', { required: true })
const prerecSeconds = defineModel<number>('prerecSeconds', { required: true })
const aiTrafficEnabled = defineModel<boolean>('aiTrafficEnabled', { required: true })
/**
* Shown while AI traffic is on. These are deliberate v1 boundaries from the
* architecture design, not bugs — stating them up front is cheaper than having
* someone discover them mid-session and file them as defects.
*/
const AI_TRAFFIC_LIMITS = [
'Background scenery only — it never affects how your own radio work is scored.',
'Audible on the tuned frequency only. Other frequencies stay busy, just silent.',
'Never transmits while you hold PTT, while ATC is answering you, or in the ~12s after an instruction.',
'Fixed phraseology, no AI text generation. Invented waypoint names, not real SIDs/STARs.',
'Traffic never conflicts with you: it always yields, and you get no extra instructions from it.',
]
</script>
<template>
@@ -116,6 +130,34 @@ const prerecSeconds = defineModel<number>('prerecSeconds', { required: true })
/>
</div>
<div class="pt-2 border-t border-white/10 space-y-3">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Frequency</p>
<p class="text-[11px] text-white/50 mt-1">
Simulated other aircraft on your frequency callsigns, readbacks, handovers so the
band sounds alive while you fly.
</p>
</div>
<v-switch
v-model="aiTrafficEnabled"
color="cyan"
inset
label="AI traffic (background chatter)"
hide-details
/>
<!-- Surfaced rather than buried in a doc: every one of these is a
deliberate v1 boundary, and knowing them up front is what stops
them from reading as broken behaviour. -->
<v-expand-transition>
<ul v-if="aiTrafficEnabled" class="pm-ai-limits">
<li v-for="limit in AI_TRAFFIC_LIMITS" :key="limit">
<v-icon size="13" class="pm-ai-limits__icon">mdi-information-outline</v-icon>
<span>{{ limit }}</span>
</li>
</ul>
</v-expand-transition>
</div>
<div class="pt-2 border-t border-white/10 space-y-3">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Voice input</p>
@@ -155,6 +197,33 @@ const prerecSeconds = defineModel<number>('prerecSeconds', { required: true })
</template>
<style scoped>
/* The AI-traffic caveats. Quiet by design — they inform, they don't warn. */
.pm-ai-limits {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, #22d3ee 22%, transparent);
background: color-mix(in srgb, #22d3ee 7%, transparent);
list-style: none;
}
.pm-ai-limits li {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 11px;
line-height: 1.45;
color: var(--t3);
}
.pm-ai-limits__icon {
flex: none;
margin-top: 1px;
color: #22d3ee;
opacity: 0.75;
}
/* Mirrors the page's segmented control so the sheet doesn't depend on
live-atc.vue's scoped styles reaching a teleported dialog. */
.pm-seg {

View File

@@ -0,0 +1,456 @@
import { computed, onUnmounted, ref, type Ref } from 'vue'
import { pmLog } from '../../shared/utils/pmLog'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
import type { useFrequencyPresets } from '~/composables/useFrequencyPresets'
import type { useRadioSpeech } from '~/composables/useRadioSpeech'
import { FREQ_ROLE_LABEL, normalizedFrequencyValue } from '~/composables/useFrequencyPresets'
import { MAX_ACTIVE_TRAFFIC, resolveTrafficTier, targetTrafficCount } from '../../shared/data/trafficTiers'
import { createRng, trafficSeed, type Rng } from '../../shared/utils/aiTraffic/rng'
import { createCallsignFactory } from '../../shared/utils/aiTraffic/callsign'
import {
DEFAULT_READBACK_PROTECTION_MS,
evaluateGate,
type GateInput,
} from '../../shared/utils/aiTraffic/gating'
import {
applyDirect,
cooldownSecFor,
planInstruction,
renderInstruction,
type RadioEvent,
} from '../../shared/utils/aiTraffic/instructions'
import { nextFreeSlot } from '../../shared/utils/aiTraffic/separation'
import {
NM_PER_FIX,
advanceAircraft,
advancePhase,
createSimAircraft,
findLeader,
generateFixPool,
isArrival,
isDespawnable,
} from '../../shared/utils/aiTraffic/sim'
import type { SimAircraft } from '../../shared/utils/aiTraffic/types'
/**
* Simulated background traffic on the tuned frequency (`ai-traffic`).
*
* A pure OBSERVER: it reads the engine, the session and the PTT state, and it
* writes only to the speech queue and the communication log. It never touches
* radioBackend — the Python backend owns the dialogue *with* the user, this owns
* the radio *around* the user. The two share exactly two things: the speech
* queue (arbitration) and the log (display).
*
* See docs/plans/2026-07-14-ai-traffic-architecture-design.md — this composable
* is the design's four internal modules (CallsignFactory, TrafficSim,
* InstructionPlanner, RadioScheduler) wired to Vue; all the rules themselves live
* as pure functions under shared/utils/aiTraffic/ so they test without a browser.
*/
const TICK_MS = 1000
/** Traffic pairs are short by design, so a real ATC reply never waits long. */
const READBACK_DELAY_MS = 700
const ATC_REPLY_DELAY_MS = 900
/** Spawner cadence — a new arrival/departure every 30120 s while under target. */
const SPAWN_INTERVAL_MIN_SEC = 30
const SPAWN_INTERVAL_MAX_SEC = 120
/** Runway occupancy booked per movement. */
const RUNWAY_SLOT_SEC = 90
export interface AiTrafficDeps {
/** The settings toggle. */
aiTrafficEnabled: Ref<boolean>
/** usePttRecording — the user is holding PTT. */
isRecording: Ref<boolean>
/** useLiveAtcSession — a user transmission is out at the backend. */
transmitInFlight: Ref<boolean>
backendSessionId: Ref<string | null>
backendExpectedPhrase: Ref<string | null>
/** Stamped by scheduleControllerSpeech — opens the readback window. */
lastControllerSpeechAtMs: Ref<number | null>
currentScreen: Ref<'login' | 'flightselect' | 'scenario' | 'monitor' | 'complete'>
freq: ReturnType<typeof useFrequencyPresets>
speech: ReturnType<typeof useRadioSpeech>
/** Infinity for the literal-strict reading of "ATC is awaiting a readback". */
readbackProtectionMs?: number
}
export function useAiTraffic(
engine: ReturnType<typeof useCommunicationsEngine>,
deps: AiTrafficDeps,
) {
const { currentState, variables: vars, appendLogEntry } = engine
const {
aiTrafficEnabled, isRecording, transmitInFlight,
backendSessionId, backendExpectedPhrase, lastControllerSpeechAtMs,
currentScreen, freq, speech,
} = deps
const { frequencies, airportFrequencies, airportName, activeAirportIcao } = freq
const { speakWithRadioEffects } = speech
// --- Pool state --------------------------------------------------------------
let rng: Rng | null = null
let callsigns: ReturnType<typeof createCallsignFactory> | null = null
let fixPool: string[] = []
let timer: ReturnType<typeof setInterval> | null = null
/** Sim clock in seconds since start() — the timeline everything is booked on. */
let nowSec = 0
let nextSpawnAtSec = 0
/** When the tuned frequency last carried anything, for ambient chatter. */
let lastRadioAtSec = 0
let ambientAfterSec = 60
/** The last departure to use the runway, for the wake timer. */
let lastDeparture: { type: SimAircraft['type']; atSec: number } | null = null
const pool = ref<SimAircraft[]>([])
/** Events whose gate was shut — re-checked on the next tick, never dropped. */
const pending = ref<RadioEvent[]>([])
const running = ref(false)
/** Only what is on the tuned frequency is audible; the rest lives on, silently. */
const audible = computed(() =>
pool.value.filter(ac => normalizedFrequencyValue(ac.frequency) === normalizedFrequencyValue(frequencies.value.active)),
)
// --- Gating ------------------------------------------------------------------
const gateInput = (): GateInput => ({
aiTrafficEnabled: aiTrafficEnabled.value,
isRecording: isRecording.value,
transmitInFlight: transmitInFlight.value,
sessionActive: Boolean(backendSessionId.value) && currentScreen.value === 'monitor',
readback: {
currentStateRole: (currentState.value as any)?.role,
backendExpectedPhrase: backendExpectedPhrase.value,
lastControllerSpeechAtMs: lastControllerSpeechAtMs.value,
nowMs: Date.now(),
readbackProtectionMs: deps.readbackProtectionMs ?? DEFAULT_READBACK_PROTECTION_MS,
},
})
/** The chain, evaluated fresh. Called before enqueue AND again at playback. */
const gateOpen = () => evaluateGate(gateInput()).open
// --- Station / runway context ------------------------------------------------
/** e.g. 'Frankfurt Approach' — whoever owns the frequency the traffic is on. */
const stationName = () => {
const active = normalizedFrequencyValue(frequencies.value.active)
const entry = airportFrequencies.value.find(e => normalizedFrequencyValue(e.frequency) === active)
const role = entry ? (FREQ_ROLE_LABEL[entry.type] || entry.type) : 'Radar'
return airportName.value ? `${airportName.value} ${role}` : role
}
const activeRunway = () => String((vars as any).value?.runway || '25R')
/**
* Every reservation on the runway, the user's included. The user's aircraft is
* a slot reservation, never a simulated position — simulated traffic always
* yields to it, so the user never gets an extra instruction or delay out of
* background traffic. Their flow belongs to the backend alone.
*/
const occupiedSlots = () => {
const slots = pool.value.filter(ac => ac.runwaySlot).map(ac => ac.runwaySlot!)
const userSlot = userRunwayReservation()
return userSlot ? [...slots, userSlot] : slots
}
/**
* Derive the user's runway occupancy from the flow state. Departure flows block
* the runway from line-up until airborne; arrival flows from the landing
* clearance until the runway is vacated.
*/
const userRunwayReservation = () => {
const stateId = String((currentState.value as any)?.id || '').toLowerCase()
if (!stateId) return null
const blocking = /(line_?up|lineup|takeoff|take_?off|departure_roll|land|final|rollout|vacate)/.test(stateId)
if (!blocking) return null
// The user owns the runway for as long as they are on such a state; the slot
// simply keeps being re-derived each tick while that holds.
return { fromSec: nowSec, toSec: nowSec + RUNWAY_SLOT_SEC }
}
// --- Spawner -----------------------------------------------------------------
const trafficTier = () =>
resolveTrafficTier(activeAirportIcao.value, airportFrequencies.value.map(e => e.type))
const targetPopulation = () => targetTrafficCount(trafficTier(), new Date().getHours())
const spawnOne = () => {
if (!rng || !callsigns) return
if (pool.value.length >= MAX_ACTIVE_TRAFFIC) return
const generated = callsigns.next()
if (!generated) return // no distinct callsign available — simply don't spawn
const kind = rng.chance(0.6) ? 'arrival' : 'departure'
const aircraft = createSimAircraft(generated, kind, {
rng,
nowSec,
frequency: frequencies.value.active,
fixPool,
})
// Book the runway as late as it is free — traffic never cuts into a slot.
const desired = { fromSec: nowSec + 120, toSec: nowSec + 120 + RUNWAY_SLOT_SEC }
aircraft.runwaySlot = nextFreeSlot(desired, occupiedSlots())
pool.value.push(aircraft)
pmLog.debug('AI-TRAFFIC spawn', aircraft.callsign, aircraft.type.icao, kind)
}
const despawn = (aircraft: SimAircraft) => {
callsigns?.release(aircraft.callsign)
pool.value = pool.value.filter(ac => ac !== aircraft)
pending.value = pending.value.filter(e => e.callsign !== aircraft.callsign)
pmLog.debug('AI-TRAFFIC despawn', aircraft.callsign)
}
// --- RadioScheduler ----------------------------------------------------------
/**
* Speak one event as an atomic ATC-call + readback pair, so a real ATC reply
* can never slot itself between the two. Only the FIRST element carries the
* gate: once an exchange has started it plays out, exactly as a real frequency
* would. The gate on that first element is the playback-time re-check — the
* enqueue-time check already happened in dispatch().
*/
const speakPair = (event: RadioEvent, aircraft: SimAircraft) => {
const controllerFirst = event.order === 'atc_first'
const first = controllerFirst
? { text: event.atcText, voice: undefined, speaker: 'atc' as const, delay: ATC_REPLY_DELAY_MS }
: { text: event.pilotReadbackText, voice: aircraft.voiceId, speaker: 'pilot' as const, delay: 0 }
const second = controllerFirst
? { text: event.pilotReadbackText, voice: aircraft.voiceId, speaker: 'pilot' as const, delay: READBACK_DELAY_MS }
: { text: event.atcText, voice: undefined, speaker: 'atc' as const, delay: ATC_REPLY_DELAY_MS }
const log = (speaker: 'atc' | 'pilot', text: string) =>
appendLogEntry(speaker, text, currentState.value?.id ?? '', {
frequency: aircraft.frequency,
traffic: true,
})
// Only true once the first half has actually committed to being spoken. The
// model update and the log hang off that, not off enqueueing: if the gate
// shuts at playback the event goes back on the queue, and applying its
// effects here would then apply them twice — splicing a direct out of the
// route twice, or silently skipping a phase.
let firstSpoken = false
speakWithRadioEffects(first.text, {
voice: first.voice,
tag: 'ai-traffic',
delayMs: first.delay,
updateLastTransmission: false,
useNormalizedForTTS: true,
gate: gateOpen,
onGateClosed: () => {
pending.value.push(event)
pmLog.debug('AI-TRAFFIC gate closed at playback —', event.callsign, 'requeued')
},
onSpoken: () => {
firstSpoken = true
lastRadioAtSec = nowSec
log(first.speaker, first.text)
applyEventEffects(event, aircraft)
},
})
speakWithRadioEffects(second.text, {
voice: second.voice,
tag: 'ai-traffic',
delayMs: second.delay,
updateLastTransmission: false,
useNormalizedForTTS: true,
// Bound to the first half, not to the gating chain: once an exchange has
// started it plays out (a real frequency doesn't cut off mid-readback), but
// a pair that never started must not answer itself.
gate: () => firstSpoken,
onSpoken: () => log(second.speaker, second.text),
})
}
/** The model update an instruction implies, applied once it has been spoken. */
const applyEventEffects = (event: RadioEvent, aircraft: SimAircraft) => {
const plan = event.plan
// Leave this aircraft alone until the instruction has had time to work.
aircraft.quietUntilSec = nowSec + cooldownSecFor(plan)
switch (plan.kind) {
case 'speed':
if (aircraft.type.wake !== 'L' && plan.speedKts) aircraft.assignedSpeedKts = plan.speedKts
break
case 'vector':
aircraft.vectorDelaySec += plan.vectorDelaySec ?? 90
break
case 'direct':
if (plan.direct) applyDirect(aircraft, plan.direct)
break
case 'wake_hold':
aircraft.nextEventAtSec = nowSec + (plan.holdSec ?? 60)
if (aircraft.runwaySlot) {
aircraft.runwaySlot = nextFreeSlot(
{ fromSec: nowSec + (plan.holdSec ?? 60), toSec: nowSec + (plan.holdSec ?? 60) + RUNWAY_SLOT_SEC },
occupiedSlots().filter(s => s !== aircraft.runwaySlot),
)
}
break
case 'slot_hold':
if (aircraft.runwaySlot) {
aircraft.runwaySlot = nextFreeSlot(
aircraft.runwaySlot,
occupiedSlots().filter(s => s !== aircraft.runwaySlot),
)
aircraft.nextEventAtSec = aircraft.runwaySlot.fromSec
}
break
case 'handover':
aircraft.phase = 'handed_off'
break
case 'phase':
if (rng) {
advancePhase(aircraft, rng, nowSec)
if (aircraft.phase === 'takeoff') lastDeparture = { type: aircraft.type, atSec: nowSec }
}
break
default:
break
}
}
/** Try to get one pending event onto the frequency. One pair per tick, at most. */
const dispatch = () => {
if (!pending.value.length) return
// Enqueue-time check. The same chain runs again at playback (see speakPair).
const gate = evaluateGate(gateInput())
if (!gate.open) return
const event = pending.value.shift()!
const aircraft = pool.value.find(ac => ac.callsign === event.callsign)
if (!aircraft) return // despawned while the event sat in the queue
speakPair(event, aircraft)
}
// --- The 1 Hz tick -----------------------------------------------------------
const tick = () => {
if (!running.value || !rng) return
nowSec += TICK_MS / 1000
for (const aircraft of [...pool.value]) {
advanceAircraft(aircraft, TICK_MS / 1000)
if (isDespawnable(aircraft)) despawn(aircraft)
}
// Population control. `target` can legitimately be 0 (a GA field at 03:00).
const target = targetPopulation()
if (pool.value.length > target) {
// Over target (the clock crossed a band): let the extras leave quietly
// rather than teleporting them away mid-exchange.
const surplus = pool.value.filter(ac => !pending.value.some(e => e.callsign === ac.callsign))
surplus.slice(target).forEach(ac => despawn(ac))
} else if (pool.value.length < target && nowSec >= nextSpawnAtSec) {
spawnOne()
nextSpawnAtSec = nowSec + rng.int(SPAWN_INTERVAL_MIN_SEC, SPAWN_INTERVAL_MAX_SEC)
}
// Plan at most one new event per tick — the queue is FIFO and short pairs are
// the whole point; flooding it would delay a real ATC reply.
if (pending.value.length === 0) {
for (const aircraft of audible.value) {
const plan = planInstruction(aircraft, {
nowSec,
rng,
leader: findLeader(aircraft, audible.value.filter(isArrival)),
occupiedSlots: occupiedSlots(),
lastDeparture,
handover: aircraft.phase === 'handed_off' ? null : handoverFor(aircraft),
nmPerFix: NM_PER_FIX,
silentForSec: nowSec - lastRadioAtSec,
ambientAfterSec,
})
if (!plan) continue
pending.value.push(renderInstruction(aircraft, plan, {
rng,
station: stationName(),
runway: activeRunway(),
}))
// Re-roll the ambient threshold so the frequency doesn't fall into a rhythm.
ambientAfterSec = rng.int(45, 90)
break
}
}
dispatch()
}
/** Where an aircraft goes once it leaves this sector — the last call it makes. */
const handoverFor = (aircraft: SimAircraft) => {
const leaving =
(aircraft.phase === 'climbout' && aircraft.altitudeFt > 6000)
|| (aircraft.phase === 'rollout' && aircraft.iasKts <= 0)
if (!leaving) return null
const entry = airportFrequencies.value.find(e => (aircraft.phase === 'climbout' ? e.type === 'DEP' || e.type === 'CTR' : e.type === 'GND'))
if (!entry?.frequency) return null
return {
station: airportName.value ? `${airportName.value} ${FREQ_ROLE_LABEL[entry.type] || entry.type}` : (FREQ_ROLE_LABEL[entry.type] || entry.type),
frequency: entry.frequency,
}
}
// --- Lifecycle ---------------------------------------------------------------
/** Called once startMonitoring() has a live backend session. */
const start = (sessionId: string, airportIcao?: string) => {
stop()
const seed = trafficSeed(sessionId)
rng = createRng(seed)
fixPool = generateFixPool(createRng(`${seed}|${airportIcao ?? 'fixes'}`))
callsigns = createCallsignFactory({
rng,
tier: trafficTier(),
userCallsigns: [
(vars as any).value?.callsign,
(vars as any).value?.callsign_short,
].filter(Boolean),
})
nowSec = 0
lastRadioAtSec = 0
nextSpawnAtSec = 0
lastDeparture = null
ambientAfterSec = rng.int(45, 90)
pool.value = []
pending.value = []
running.value = true
timer = setInterval(tick, TICK_MS)
pmLog.info('AI-TRAFFIC start', { seed, tier: trafficTier(), target: targetPopulation() })
}
/**
* Spawner off, pool empty, pending events dropped. Anything already enqueued
* invalidates itself through the playback-time gate, and the call that is
* physically playing right now finishes (≤ ~8 s) — stopCurrentSpeech() is
* global and would take a real queued ATC reply down with it.
*/
const stop = () => {
if (timer) { clearInterval(timer); timer = null }
running.value = false
pool.value = []
pending.value = []
rng = null
callsigns = null
lastDeparture = null
}
onUnmounted(stop)
return {
start,
stop,
running,
/** Exposed for the debug panel: who is currently on the tuned frequency. */
audible,
pool,
gateState: () => evaluateGate(gateInput()),
}
}

View File

@@ -1,4 +1,4 @@
import { nextTick, onUnmounted } from 'vue'
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'
@@ -87,6 +87,14 @@ export function useLiveAtcSession(
// 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
@@ -378,6 +386,7 @@ export function useLiveAtcSession(
pmLog.info('session_id :', backendSessionId.value)
})
transmitInFlightCount.value++
try {
// The pilot spoke — a pending silence auto-advance no longer applies. The
// response re-arms it if the next state also allows silence.
@@ -421,6 +430,8 @@ export function useLiveAtcSession(
pmLog.error('TRANSMIT FAILED', { transcript, session: backendSessionId.value, error: e })
console.error('Backend transmission failed', e)
setLastTransmission(`${prefix}: ${transcript} (backend failed)`)
} finally {
transmitInFlightCount.value = Math.max(0, transmitInFlightCount.value - 1)
}
}
@@ -755,6 +766,7 @@ export function useLiveAtcSession(
return {
clearSilenceTimer,
transmitInFlight,
applyBackendDecision,
handlePilotTransmission,
handleSimControlResult,

View File

@@ -28,12 +28,33 @@ export type SpeechOptions = {
useNormalizedForTTS?: boolean
speed?: number
lessonId?: string
/**
* Re-checked inside the queued task, immediately before playback. Enqueue time
* and play time can be seconds apart, so a caller whose right to the frequency
* can lapse in between (today: useAiTraffic's gating chain) passes this and the
* task drops itself instead of talking over whoever now owns it.
*/
gate?: () => boolean
/** Called when `gate` returned false — the caller can re-queue its event. */
onGateClosed?: () => void
/**
* Called once the task has committed to this transmission and run it. The
* counterpart to `onGateClosed`: exactly one of the two fires, so a caller can
* apply state changes only for what was actually said.
*/
onSpoken?: () => void
}
export interface RadioSpeechDeps {
setLastTransmission: (text: string) => void
handlePilotTransmission: (message: string, source: 'text' | 'ptt') => Promise<void>
lastControllerSay: Ref<string | null>
/**
* Stamped by scheduleControllerSpeech — opens the readback window that keeps
* simulated traffic silent (useAiTraffic). Only the user-facing ATC reply goes
* through that function, so traffic never opens a window against itself.
*/
lastControllerSpeechAtMs: Ref<number | null>
signalStrength: Ref<number>
speechSpeed: Ref<number>
radioCheckLoading: Ref<boolean>
@@ -56,7 +77,7 @@ export function useRadioSpeech(
setCurrentAudio, setCurrentPizzicatoSound, addPendingAbort, deletePendingAbort,
} = speechInterrupt
const {
setLastTransmission, handlePilotTransmission, lastControllerSay,
setLastTransmission, handlePilotTransmission, lastControllerSay, lastControllerSpeechAtMs,
signalStrength, speechSpeed, radioCheckLoading, radioEffectsEnabled, readbackEnabled,
} = deps
const api = useApi()
@@ -263,6 +284,11 @@ export function useRadioSpeech(
await wait(delay)
}
if (generation !== getSpeechGeneration()) return // stopped while waiting
if (options.gate && !options.gate()) {
options.onGateClosed?.()
return // the frequency stopped being ours while we sat in the queue
}
options.onSpoken?.()
await speakPrepared(prepared, options, audioPromise)
})
}
@@ -303,6 +329,9 @@ export function useRadioSpeech(
const scheduleControllerSpeech = (tpl: string) => {
const plain = renderATCMessage(tpl)
// Opens the readback window: from here until the pilot answers (and for at
// least readbackProtectionMs), background traffic stays off the frequency.
lastControllerSpeechAtMs.value = Date.now()
speakWithRadioEffects(tpl, {
delayMs: 800 + Math.random() * 2000,
tag: 'controller-reply',

View File

@@ -51,6 +51,9 @@ export function useSessionState(engine: ReturnType<typeof useCommunicationsEngin
const backendSessionId = ref<string | null>(null)
// Last ATC utterance returned by the backend (pre-rendered, correct variables).
const lastControllerSay = ref<string | null>(null)
// When that utterance was scheduled — opens the readback window during which
// simulated background traffic must stay silent (useAiTraffic's gating chain).
const lastControllerSpeechAtMs = ref<number | null>(null)
// Authoritative expected pilot phrase from the backend — replaces local engine rendering.
const backendExpectedPhrase = ref<string | null>(null)
// Per-field readback diagnostic from the last transmission (STT debug panel).
@@ -160,6 +163,7 @@ export function useSessionState(engine: ReturnType<typeof useCommunicationsEngin
sessionStartingMessage,
backendSessionId,
lastControllerSay,
lastControllerSpeechAtMs,
backendExpectedPhrase,
lastReadbackReport,
lastReadbackTranscript,

View File

@@ -139,6 +139,7 @@
v-model:debug-mode="debugMode"
v-model:prerec-enabled="prerecEnabled"
v-model:prerec-seconds="prerecSeconds"
v-model:ai-traffic-enabled="aiTrafficEnabled"
@set-theme="setPmTheme"
/>
@@ -192,6 +193,7 @@ import FlightSourceStep from '~/components/live-atc/FlightSourceStep.vue'
import FlightSelectStep from '~/components/live-atc/FlightSelectStep.vue'
import ScenarioPickerStep from '~/components/live-atc/ScenarioPickerStep.vue'
import SessionCompleteStep from '~/components/live-atc/SessionCompleteStep.vue'
import { useAiTraffic } from '~/composables/useAiTraffic'
import { useLiveAtcSession } from '~/composables/useLiveAtcSession'
import { useSessionState } from '~/composables/useSessionState'
import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt'
@@ -212,6 +214,7 @@ const STORAGE_KEYS = {
vatsimId: 'pm_vatsim_id',
prerecEnabled: 'pm_prerec_enabled',
prerecSeconds: 'pm_prerec_seconds',
aiTrafficEnabled: 'pm_ai_traffic_enabled',
helpSeen: 'pm_help_seen',
helpLang: 'pm_help_lang',
} as const
@@ -281,6 +284,7 @@ const {
sessionStartingMessage,
backendSessionId,
lastControllerSay,
lastControllerSpeechAtMs,
backendExpectedPhrase,
lastReadbackReport,
lastReadbackTranscript,
@@ -308,6 +312,9 @@ const speechSpeed = ref(0.95)
const radioCheckLoading = ref(false)
const radioEffectsEnabled = ref(true)
const readbackEnabled = ref(false)
// Simulated background traffic. Off by default: it costs TTS per lively minute,
// so it stays an opt-in the user turns on once they want the busier frequency.
const aiTrafficEnabled = ref(false)
// ── Bug Report ───────────────────────────────────────────────────────────────
// Owned here rather than by the dialog: the HUD button starts the screenshot
@@ -370,6 +377,11 @@ onMounted(async () => {
if (storedPrerecEnabled !== null) {
prerecEnabled.value = storedPrerecEnabled === '1'
}
const storedAiTraffic = window.localStorage.getItem(STORAGE_KEYS.aiTrafficEnabled)
if (storedAiTraffic !== null) {
aiTrafficEnabled.value = storedAiTraffic === '1'
}
const storedPrerecSeconds = window.localStorage.getItem(STORAGE_KEYS.prerecSeconds)
if (storedPrerecSeconds !== null) {
const parsed = Number.parseFloat(storedPrerecSeconds)
@@ -433,6 +445,7 @@ const freq = useFrequencyPresets(engine, stopCurrentSpeech)
const {
frequencies,
airportName,
activeAirportIcao,
displayAirportFrequencies,
swapAnimation,
manualFreqActive,
@@ -470,6 +483,7 @@ const speech = useRadioSpeech(engine, freq, speechInterrupt, {
setLastTransmission,
handlePilotTransmission,
lastControllerSay,
lastControllerSpeechAtMs,
signalStrength,
speechSpeed,
radioCheckLoading,
@@ -541,6 +555,12 @@ watch(prerecEnabled, (val) => {
}
})
watch(aiTrafficEnabled, (val) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEYS.aiTrafficEnabled, val ? '1' : '0')
}
})
const {
bridgeToken,
bridgeConnected,
@@ -574,6 +594,7 @@ const session = useLiveAtcSession(engine, {
maybeShowFirstRunHelp,
})
const {
transmitInFlight,
loadFlightPlans,
startMonitoring,
startDemoFlight,
@@ -584,10 +605,43 @@ const {
restoreBugReportState,
} = session
// Simulated background traffic. A pure observer alongside the engine and the
// session — it reads state and writes only to the speech queue and the log, and
// never touches radioBackend. Constructed after `session` because it needs that
// composable's transmitInFlight for its gating chain.
const aiTraffic = useAiTraffic(engine, {
aiTrafficEnabled,
isRecording,
transmitInFlight,
backendSessionId,
backendExpectedPhrase,
lastControllerSpeechAtMs,
currentScreen,
freq,
speech,
})
// The single owner of the traffic lifecycle: it runs exactly while the toggle is
// on, a backend session exists and we're on the monitor screen. Every entry and
// exit the design lists — startMonitoring() succeeding, session_complete,
// backToSetup, toggling mid-session — moves one of these three, so they need no
// separate hooks. Toggling back on rebuilds from a fresh spawn-up.
watch(
[backendSessionId, currentScreen, aiTrafficEnabled],
([sessionId, screen, enabled]) => {
if (enabled && sessionId && screen === 'monitor') {
aiTraffic.start(sessionId, activeAirportIcao.value)
} else {
aiTraffic.stop()
}
},
)
onUnmounted(() => {
stopAtisLoop()
stopPrerecCapture()
cancelAirportDataRefresh()
aiTraffic.stop()
})
</script>