mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
feat(live-atc): per-position controller personas with voice and pace
Each ATC position (session + airport + type + frequency) now gets a stable persona: a controller-pool voice and a base pace of 1.1-1.3x, jittered +/-0.05 per transmission. Anything spoken without an explicit voice — the controller reply path and AI-traffic ATC lines — uses the persona; pilot voices are untouched. A new session reshuffles the shift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
* `<sessionSeed>:<airport>:<positionType>[:<frequency>]` — 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user