diff --git a/app/composables/useRadioSpeech.ts b/app/composables/useRadioSpeech.ts index 4ad395a..3ef34f2 100644 --- a/app/composables/useRadioSpeech.ts +++ b/app/composables/useRadioSpeech.ts @@ -4,6 +4,7 @@ import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite' import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite' import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects' +import { controllerPersonaFor, transmissionSpeed } from '../../shared/utils/voicePool' import { pmLog } from '../../shared/utils/pmLog' import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt' import { @@ -96,6 +97,26 @@ export function useRadioSpeech( const speechSpeedLabel = computed(() => `${speechSpeed.value.toFixed(2)}x`) + /** + * The persona of whoever staffs the currently tuned frequency. Keyed by + * session + airport + position + frequency: within a session a position keeps + * one consistent controller, a new session sounds like a different shift. + */ + const activeControllerPersona = () => { + const entry = tunedStationEntry() + const key = [ + engineSessionId.value || flags.value.session_id || 'default', + airportName.value || 'APT', + entry?.type || 'TWR', + entry?.frequency || frequencies.value.active || '', + ].join(':') + return controllerPersonaFor(key) + } + + /** Controller pace: user speed setting × persona base pace × per-call jitter. */ + const controllerSpeed = (baseSpeed: number) => + Math.max(0.5, Math.min(2.0, speechSpeed.value * transmissionSpeed(baseSpeed))) + const prepareSpeech = (tpl: string): PreparedSpeech => { const plain = renderATCMessage(tpl) const normalized = normalizeATCText(tpl, { ...vars.value, ...flags.value }) @@ -226,7 +247,9 @@ export function useRadioSpeech( const watchdog = setTimeout(() => abort.abort(), TTS_FETCH_TIMEOUT_MS) return (async () => { try { - const speed = options.speed ?? speechSpeed.value + // No explicit voice means the controller speaks — persona voice + pace. + const persona = options.voice ? null : activeControllerPersona() + const speed = options.speed ?? (persona ? controllerSpeed(persona.baseSpeed) : speechSpeed.value) const usesNormalized = options.useNormalizedForTTS !== false const response = await api.post('/api/atc/say', { text: usesNormalized ? prepared.normalized : prepared.plain, @@ -234,7 +257,7 @@ export function useRadioSpeech( // radiotelephony normalizer — the server must not normalize again. preNormalized: usesNormalized, level: signalStrength.value, - voice: options.voice || 'alloy', + voice: options.voice || persona!.voice, speed, moduleId: 'pilot-monitoring', lessonId: currentState.value?.id || 'general', @@ -306,15 +329,16 @@ export function useRadioSpeech( return Promise.resolve() } - const speed = options.speed ?? speechSpeed.value const lessonId = options.lessonId || currentState.value?.id || 'general' return enqueueSpeech(async () => { try { + const persona = options.voice ? null : activeControllerPersona() + const speed = options.speed ?? (persona ? controllerSpeed(persona.baseSpeed) : speechSpeed.value) const response = await api.post('/api/atc/say', { text: trimmed, level: signalStrength.value, - voice: options.voice || 'alloy', + voice: options.voice || persona!.voice, speed, moduleId: 'pilot-monitoring', lessonId, diff --git a/shared/utils/voicePool.ts b/shared/utils/voicePool.ts index e4f1dad..510ba99 100644 --- a/shared/utils/voicePool.ts +++ b/shared/utils/voicePool.ts @@ -66,3 +66,32 @@ export function pilotVoiceFor(callsign: string, reserved: readonly string[] = RE export function controllerVoiceFor(position: string): string { return voiceFromPool(position.toUpperCase(), CONTROLLER_VOICES, []) } + +export type ControllerPersona = { + voice: string + /** Controllers talk fast — base pace per position, 1.1–1.3. */ + baseSpeed: number +} + +/** + * The persona of an ATC position for one session. Key convention: + * `::[:]` — the session seed + * makes each session sound like a different shift while the position keeps + * one consistent controller within the session. + */ +export function controllerPersonaFor(positionKey: string): ControllerPersona { + const key = positionKey.toUpperCase() + const voice = voiceFromPool(key, CONTROLLER_VOICES, []) + // Independent hash stream for speed so voice and pace don't correlate. + const baseSpeed = 1.1 + (fnv1a(`speed:${key}`) % 21) / 100 + return { voice, baseSpeed: Math.round(baseSpeed * 100) / 100 } +} + +/** + * Per-transmission pace: the persona's base speed ±0.05 — real controllers + * don't hit the exact same tempo twice. + */ +export function transmissionSpeed(baseSpeed: number, rng: () => number = Math.random): number { + const jitter = (rng() * 2 - 1) * 0.05 + return Math.round((baseSpeed + jitter) * 100) / 100 +} diff --git a/tests/shared/voicePool.test.ts b/tests/shared/voicePool.test.ts index b1b8036..00d634f 100644 --- a/tests/shared/voicePool.test.ts +++ b/tests/shared/voicePool.test.ts @@ -5,9 +5,11 @@ import { CONTROLLER_VOICES, PILOT_VOICES, RESERVED_VOICES, + controllerPersonaFor, controllerVoiceFor, fnv1a, pilotVoiceFor, + transmissionSpeed, voiceFromPool, } from '~~/shared/utils/voicePool' @@ -73,6 +75,42 @@ describe('voicePool — assignment', () => { }) }) +describe('voicePool — controller personas', () => { + it('is deterministic for the same position key', () => { + const first = controllerPersonaFor('sess1:EDDF:TWR:118.775') + for (let i = 0; i < 10; i++) { + assert.deepEqual(controllerPersonaFor('sess1:EDDF:TWR:118.775'), first) + } + }) + + it('uses a controller-partition voice and a base speed in [1.1, 1.3]', () => { + for (const key of ['s:EDDF:TWR', 's:EDDF:GND', 's:EDDM:APP', 's:EDDB:DEL', 'x:EDDF:TWR']) { + const persona = controllerPersonaFor(key) + assert.ok(CONTROLLER_VOICES.includes(persona.voice), `${key} → ${persona.voice}`) + assert.ok(persona.baseSpeed >= 1.1 && persona.baseSpeed <= 1.3, `${key} → ${persona.baseSpeed}`) + } + }) + + it('gives different sessions a different shift of controllers', () => { + const keys = ['EDDF:DEL', 'EDDF:GND', 'EDDF:TWR', 'EDDF:APP'] + const shiftA = keys.map(k => controllerPersonaFor(`sessionA:${k}`)) + const shiftB = keys.map(k => controllerPersonaFor(`sessionB:${k}`)) + assert.notDeepEqual(shiftA, shiftB) + }) + + it('jitters the transmission speed within ±0.05 of base', () => { + for (let i = 0; i < 50; i++) { + const speed = transmissionSpeed(1.2) + assert.ok(speed >= 1.15 - 1e-9 && speed <= 1.25 + 1e-9, `jittered to ${speed}`) + } + }) + + it('transmission speeds actually vary', () => { + const speeds = new Set(Array.from({ length: 30 }, () => transmissionSpeed(1.2))) + assert.ok(speeds.size > 1, 'expected jitter, got a constant') + }) +}) + describe('voicePool — fnv1a', () => { it('matches the reference vectors', () => { assert.equal(fnv1a(''), 0x811c9dc5)