From 1bd1aac76d420e2fffcb2eab34a87e65e46898b5 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Mon, 27 Jul 2026 00:51:53 +0200 Subject: [PATCH] feat(live-atc): optional auto-tune after a frequency handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tuning is manual, so after a handoff nothing the pilot says goes through until they dial the new frequency in. With the setting on, the radio does it for them three seconds after the handoff was accepted, announcing "OpenSquawk changing frequency to …" first so it is never a surprise. Whether a change is due is decided from state rather than from an event, which is what makes the two must-not-tune cases safe without a special case: a frequency readback that was wrong and one not yet given both leave the session on a state that still expects the frequency already dialled in, so nothing is due. A pending change is dropped if the session ends or the pilot reaches for the radio themselves — theirs wins. Off by default: working the radio is part of what is being practised, so handing it to the aircraft has to be a deliberate choice. The decision itself is a pure function and covered by tests; the announcement goes through the same speech and comm-log path as everything else, so the browser sim and the bridge both see the tuned frequency the way they already do for a manual change. Also folds the two copies of normalizedFrequencyValue into one in shared/, so the auto-tune logic can compare frequencies without a composable import. --- .../live-atc/cockpit/RadioPanel.vue | 2 +- .../live-atc/cockpit/SettingsSheet.vue | 12 +++ app/composables/useAiTraffic.ts | 3 +- app/composables/useAtisPlayback.ts | 2 +- app/composables/useFrequencyPresets.ts | 4 +- app/composables/useLiveAtcSession.ts | 75 ++++++++++++++++++- app/composables/useRadioSpeech.ts | 2 +- app/pages/live-atc.vue | 6 ++ shared/utils/autoTune.ts | 70 +++++++++++++++++ shared/utils/frequency.ts | 6 ++ tests/shared/autoTune.test.ts | 67 +++++++++++++++++ 11 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 shared/utils/autoTune.ts create mode 100644 tests/shared/autoTune.test.ts diff --git a/app/components/live-atc/cockpit/RadioPanel.vue b/app/components/live-atc/cockpit/RadioPanel.vue index 83bbf11..8bfa6b6 100644 --- a/app/components/live-atc/cockpit/RadioPanel.vue +++ b/app/components/live-atc/cockpit/RadioPanel.vue @@ -5,9 +5,9 @@ import { normalizeManualFreq } from '../../../../shared/utils/frequency' import { FREQUENCY_PLACEHOLDER, FREQ_ROLE_LABEL, - normalizedFrequencyValue, type DisplayAirportFrequencyEntry, } from '~/composables/useFrequencyPresets' +import { normalizedFrequencyValue } from '~~/shared/utils/frequency' const props = defineProps<{ active: string diff --git a/app/components/live-atc/cockpit/SettingsSheet.vue b/app/components/live-atc/cockpit/SettingsSheet.vue index 973051a..d381e65 100644 --- a/app/components/live-atc/cockpit/SettingsSheet.vue +++ b/app/components/live-atc/cockpit/SettingsSheet.vue @@ -20,6 +20,7 @@ const debugMode = defineModel('debugMode', { required: true }) const prerecEnabled = defineModel('prerecEnabled', { required: true }) const prerecSeconds = defineModel('prerecSeconds', { required: true }) const aiTrafficEnabled = defineModel('aiTrafficEnabled', { required: true }) +const autoTuneEnabled = defineModel('autoTuneEnabled', { required: true }) /** * Shown while AI traffic is on. These are deliberate v1 boundaries from the @@ -138,6 +139,17 @@ const AI_TRAFFIC_LIMITS = [ band sounds alive while you fly.

+ +

+ Dials in the new frequency a few seconds after a handoff, announcing it + first. Tune the radio yourself at any point and that takes precedence. +

, diff --git a/app/composables/useFrequencyPresets.ts b/app/composables/useFrequencyPresets.ts index 2769104..7aeebfe 100644 --- a/app/composables/useFrequencyPresets.ts +++ b/app/composables/useFrequencyPresets.ts @@ -1,7 +1,7 @@ import { ref, computed } from 'vue' import { useApi } from '~/composables/useApi' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' -import { normalizeManualFreq } from '../../shared/utils/frequency' +import { normalizeManualFreq, normalizedFrequencyValue } from '../../shared/utils/frequency' import type { AtisReport, AtisStation } from '../../shared/utils/atisReport' export type AirportFrequencyEntry = { @@ -66,8 +66,6 @@ const FREQ_NAME_TO_VAR: Record = { 'radar': 'handoff_freq', } -export const normalizedFrequencyValue = (value: string | undefined) => - (value || '').trim().replace(/\s+/g, '').replace(',', '.') /** "Arrival" / "Departure" variant from the station callsign (EDDF_A_ATIS / EDDF_D_ATIS). */ export const atisVariantLabel = (entry: AirportFrequencyEntry): string => { diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 6cf7b62..0181a6c 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -2,7 +2,8 @@ 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 useFrequencyPresets } from '~/composables/useFrequencyPresets' +import { normalizedFrequencyValue } from '../../shared/utils/frequency' import type { useSessionState } from '~/composables/useSessionState' import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' @@ -10,6 +11,7 @@ import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { generateGermanRegistration } from '../../shared/utils/registration' import { gateTransmission } from '../../shared/utils/transmissionGate' import { silenceWindowFor } from '../../shared/utils/silenceTimer' +import { planAutoTune } from '../../shared/utils/autoTune' import { isSimControlMatch, isSimControlRejection, @@ -32,6 +34,8 @@ export interface LiveAtcSessionDeps { prefetchAtisAudio: () => void /** True while the pilot holds PTT — the silence timer must not talk over them. */ isRecording: Ref + /** Settings toggle: dial in the new frequency automatically after a handoff. */ + autoTuneEnabled: Ref bridgeConnected: Ref bridgePosition: Ref<{ lat: number; lon: number } | null> /** ?token=… from the route — auths the frequency-sim-control channel (design §4). */ @@ -61,7 +65,7 @@ export function useLiveAtcSession( const { state, freq, speech, radioBackend, api, config, prefetchAtisAudio, - isRecording, bridgeConnected, bridgePosition, bridgeToken, + isRecording, autoTuneEnabled, bridgeConnected, bridgePosition, bridgeToken, persistSelectedPlan, maybeShowFirstRunHelp, } = deps @@ -79,7 +83,7 @@ export function useLiveAtcSession( frequencies, airportFrequencies, frequencySources, activeAirportIcao, expectedFrequencyForState, acceptedFrequenciesForState, runwayInUse, informationLetter, atisReport, - fetchAirportFrequencies, + fetchAirportFrequencies, setActiveFrequencyFromList, } = freq const { @@ -178,6 +182,69 @@ export function useLiveAtcSession( silenceTimer = setTimeout(fire, ms) } + // --- Auto-tune --------------------------------------------------------------- + // Tuning is manual, so after a handoff nothing the pilot says goes through + // until they dial the new frequency in. With the setting on, the radio does it + // for them a few seconds after the handoff was accepted, announcing it first. + // + // Whether a change is due is decided from state, not from an event (see + // planAutoTune): the two cases that must NOT tune — a frequency readback that + // was wrong, and one not yet given — both leave the session on a state that + // still expects the frequency already dialled in, so nothing is due. + let autoTuneTimer: ReturnType | null = null + + function clearAutoTune() { + if (autoTuneTimer) { + clearTimeout(autoTuneTimer) + autoTuneTimer = null + } + } + + function scheduleAutoTune() { + clearAutoTune() + const plan = planAutoTune({ + enabled: autoTuneEnabled.value, + active: frequencies.value.active, + expected: expectedFrequencyForState(), + accepted: acceptedFrequenciesForState(), + }) + if (!plan) return + + // Announce before changing, never after: the pilot has to be able to follow + // what their own radio just did. + scheduleControllerSpeech(plan.announcement) + appendLogEntry('system', plan.announcement, currentState.value?.id ?? '', { + frequency: frequencies.value.active, + }) + + const sessionAtArm = backendSessionId.value + const activeAtArm = frequencies.value.active + pmLog.info('AUTO-TUNE armed →', plan.frequency, `in ${plan.delayMs}ms`) + + autoTuneTimer = setTimeout(() => { + autoTuneTimer = null + // The session ended or a new one started while we waited. + if (backendSessionId.value !== sessionAtArm) { + pmLog.info('AUTO-TUNE dropped — session changed') + return + } + // The pilot reached for the radio themselves; theirs wins. + if (frequencies.value.active !== activeAtArm) { + pmLog.info('AUTO-TUNE dropped — pilot tuned manually') + return + } + // Prefer the airport's own entry so the label and the engine's notion of + // the position come along; fall back to a bare entry for an invented one. + const known = airportFrequencies.value.find( + (entry: any) => normalizedFrequencyValue(entry.frequency) === normalizedFrequencyValue(plan.frequency), + ) + pmLog.info('AUTO-TUNE tuning →', plan.frequency) + setActiveFrequencyFromList(known ?? { + type: '', label: '', frequency: plan.frequency, source: 'openaip', + } as any) + }, plan.delayMs) + } + // 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 @@ -279,6 +346,8 @@ export function useLiveAtcSession( // Re-arm (or clear) the silence auto-advance for whatever state we're now on. armSilenceTimer() + // And dial in the new frequency if this decision handed us to one. + scheduleAutoTune() } // --- Frequency-sim-control (design doc §4) ------------------------------------ diff --git a/app/composables/useRadioSpeech.ts b/app/composables/useRadioSpeech.ts index 033bf36..744727b 100644 --- a/app/composables/useRadioSpeech.ts +++ b/app/composables/useRadioSpeech.ts @@ -11,9 +11,9 @@ import { postWithLocalFallback, useLocalSpeechBridge } from '~/composables/useLo import { FREQUENCY_PLACEHOLDER, FREQ_ROLE_LABEL, - normalizedFrequencyValue, type useFrequencyPresets, } from '~/composables/useFrequencyPresets' +import { normalizedFrequencyValue } from '~~/shared/utils/frequency' export type PreparedSpeech = { template: string diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index 48d26dc..734c08c 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -141,6 +141,7 @@ v-model:prerec-enabled="prerecEnabled" v-model:prerec-seconds="prerecSeconds" v-model:ai-traffic-enabled="aiTrafficEnabled" + v-model:auto-tune-enabled="autoTuneEnabled" @set-theme="setPmTheme" /> @@ -316,6 +317,10 @@ 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) +// Dial the new frequency in automatically after a handoff. Off by default: +// working the radio is part of what is being practised, so turning it over to +// the aircraft has to be a deliberate choice. +const autoTuneEnabled = ref(false) // ── Bug Report ─────────────────────────────────────────────────────────────── // Owned here rather than by the dialog: the HUD button starts the screenshot @@ -593,6 +598,7 @@ const session = useLiveAtcSession(engine, { config, prefetchAtisAudio, isRecording, + autoTuneEnabled, bridgeConnected, bridgePosition, bridgeToken, diff --git a/shared/utils/autoTune.ts b/shared/utils/autoTune.ts new file mode 100644 index 0000000..10416e7 --- /dev/null +++ b/shared/utils/autoTune.ts @@ -0,0 +1,70 @@ +/** + * Automatic frequency changes after a handoff. + * + * Tuning is manual: ATC hands you to the next frequency, and until you dial it + * in, nothing you say goes through. Auto-tune does the dialling for you a few + * seconds after the handoff has been accepted, announcing it first so it is + * never a surprise. + * + * The decision is deliberately made from state rather than from an event: tune + * when the state the session is now resting on expects a frequency we are not + * on. That is exactly the condition a correct handoff readback creates, and it + * is self-guarding against the two cases that must NOT tune — a wrong readback + * or one not yet given both leave the session on the old state, which still + * expects the frequency already tuned, so no change is due. + */ + +import { normalizedFrequencyValue } from './frequency' + +/** How long to wait between the announcement and the change. */ +export const AUTO_TUNE_DELAY_MS = 3000 + +export interface AutoTuneInput { + /** Off by default is the caller's choice; this only reads the setting. */ + enabled: boolean + /** The frequency currently dialled in. */ + active: string | undefined + /** What the state the session now rests on expects. */ + expected: string | undefined + /** + * Every frequency valid for the current position. A position may publish + * more than one, and being on any of them is already correct. + */ + accepted?: string[] +} + +export interface AutoTunePlan { + /** The frequency to dial in. */ + frequency: string + /** Spoken before the change, so the pilot knows what is happening. */ + announcement: string + delayMs: number +} + +export function announcementFor(frequency: string): string { + return `OpenSquawk changing frequency to ${frequency}` +} + +/** + * The change due right now, or null when none is. + */ +export function planAutoTune(input: AutoTuneInput): AutoTunePlan | null { + if (!input.enabled) return null + + const expected = (input.expected || '').trim() + if (!expected) return null + + const active = normalizedFrequencyValue(input.active) + const target = normalizedFrequencyValue(expected) + if (!target) return null + + // Already on a frequency this position publishes — nothing to do. + const accepted = (input.accepted ?? []).map(normalizedFrequencyValue).filter(Boolean) + if (accepted.length ? accepted.includes(active) : active === target) return null + + return { + frequency: expected, + announcement: announcementFor(expected), + delayMs: AUTO_TUNE_DELAY_MS, + } +} diff --git a/shared/utils/frequency.ts b/shared/utils/frequency.ts index 0db89a9..54df699 100644 --- a/shared/utils/frequency.ts +++ b/shared/utils/frequency.ts @@ -1,3 +1,9 @@ +// Comparison form of a frequency: whitespace stripped and the comma decimal +// separator folded to a dot, so "121,800" and " 121.800 " compare equal. Kept +// here rather than in a composable so shared code can use it too. +export const normalizedFrequencyValue = (value: string | undefined) => + (value || '').trim().replace(/\s+/g, '').replace(',', '.') + // Accepts inputs like "121.5", "121,500" or "118" and normalises to a valid // VHF airband frequency string (118.000–136.975). Returns null when invalid so // callers/UI can disable the action. diff --git a/tests/shared/autoTune.test.ts b/tests/shared/autoTune.test.ts new file mode 100644 index 0000000..23d5109 --- /dev/null +++ b/tests/shared/autoTune.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { AUTO_TUNE_DELAY_MS, planAutoTune } from '~~/shared/utils/autoTune' + +const plan = (over: Partial[0]> = {}) => + planAutoTune({ enabled: true, active: '118.700', expected: '121.800', ...over }) + +describe('planAutoTune', () => { + it('tunes to the frequency the new state expects', () => { + const result = plan() + assert.equal(result?.frequency, '121.800') + assert.equal(result?.delayMs, AUTO_TUNE_DELAY_MS) + }) + + it('announces the change before making it', () => { + assert.equal(plan()?.announcement, 'OpenSquawk changing frequency to 121.800') + }) + + it('does nothing when the setting is off', () => { + assert.equal(plan({ enabled: false }), null) + }) + + it('does nothing when already on the expected frequency', () => { + assert.equal(plan({ active: '121.800' }), null) + }) + + it('treats a comma decimal and stray spacing as the same frequency', () => { + assert.equal(plan({ active: '121,800', expected: ' 121.800 ' }), null) + }) + + it('leaves the pilot alone on any frequency the position publishes', () => { + const result = plan({ + active: '118.500', + expected: '118.700', + accepted: ['118.700', '118.500'], + }) + assert.equal(result, null) + }) + + it('tunes when on none of the published frequencies', () => { + const result = plan({ + active: '121.800', + expected: '118.700', + accepted: ['118.700', '118.500'], + }) + assert.equal(result?.frequency, '118.700') + }) + + it('does nothing where the state expects no frequency', () => { + assert.equal(plan({ expected: undefined }), null) + assert.equal(plan({ expected: '' }), null) + }) + + // The two cases that must never tune. Both leave the session resting on the + // state that still expects the frequency already dialled in, so "expected + // equals active" is what actually guards them. + it('does not tune while the frequency readback is still owed', () => { + // Still on the handoff readback state: it belongs to the current position. + assert.equal(plan({ active: '118.700', expected: '118.700' }), null) + }) + + it('does not tune after a wrong frequency readback', () => { + // A wrong readback loops back to the same state — same frequency, no change. + assert.equal(plan({ active: '118.700', expected: '118.700' }), null) + }) +})