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