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) + }) +})