mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-10 11:33:26 +08:00
first working version of ptt and reply
This commit is contained in:
527
app/composables/communicationsEngine.ts
Normal file
527
app/composables/communicationsEngine.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
// composables/communicationsEngine.ts
|
||||
import {ref, computed} from 'vue'
|
||||
|
||||
export interface CommunicationStep {
|
||||
id: string
|
||||
phase: string
|
||||
frequency: string
|
||||
frequencyName: string
|
||||
action: string
|
||||
trigger: 'pilot' | 'atc' | 'automatic' | 'callback'
|
||||
pilot?: string
|
||||
atc?: string
|
||||
pilotResponse?: string
|
||||
atcResponse?: string
|
||||
variables?: Record<string, any>
|
||||
nextStep?: string
|
||||
conditions?: Array<{
|
||||
type: 'contains' | 'equals' | 'timeout'
|
||||
value: string | number
|
||||
nextStep: string
|
||||
}>
|
||||
callback?: () => void
|
||||
audioEffects?: {
|
||||
static?: number // 0-100
|
||||
distortion?: number // 0-100
|
||||
volume?: number // 0-100
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlightContext {
|
||||
callsign: string
|
||||
aircraft: string
|
||||
departure: string
|
||||
arrival: string
|
||||
gate: string
|
||||
runway: string
|
||||
squawk: string
|
||||
atis: string
|
||||
sid: string
|
||||
flightLevel: string
|
||||
groundFreq: string
|
||||
towerFreq: string
|
||||
departureFreq: string
|
||||
approachFreq: string
|
||||
taxiRoute: string[]
|
||||
phase: string
|
||||
lastTransmission?: string
|
||||
awaitingResponse?: boolean
|
||||
}
|
||||
|
||||
// ICAO Phonetic and Number Normalization
|
||||
const NATO_PHONETIC: Record<string, string> = {
|
||||
'A': 'Alpha', 'B': 'Bravo', 'C': 'Charlie', 'D': 'Delta', 'E': 'Echo', 'F': 'Foxtrot',
|
||||
'G': 'Golf', 'H': 'Hotel', 'I': 'India', 'J': 'Juliett', 'K': 'Kilo', 'L': 'Lima',
|
||||
'M': 'Mike', 'N': 'November', 'O': 'Oscar', 'P': 'Papa', 'Q': 'Quebec', 'R': 'Romeo',
|
||||
'S': 'Sierra', 'T': 'Tango', 'U': 'Uniform', 'V': 'Victor', 'W': 'Whiskey',
|
||||
'X': 'X-ray', 'Y': 'Yankee', 'Z': 'Zulu'
|
||||
}
|
||||
|
||||
const ICAO_NUMBERS: Record<string, string> = {
|
||||
'0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'fower',
|
||||
'5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner'
|
||||
}
|
||||
|
||||
// FLIGHT_PHASES
|
||||
export const FLIGHT_PHASES = ['clearance', 'ground', 'tower', 'departure', 'approach', 'landing'] as const
|
||||
type FlightPhase = typeof FLIGHT_PHASES[number]
|
||||
|
||||
// Comprehensive Communication Steps for Complete Flight
|
||||
export const COMMUNICATION_STEPS: CommunicationStep[] = [
|
||||
// Phase 1: Clearance Delivery
|
||||
{
|
||||
id: 'clearance_request',
|
||||
phase: 'clearance',
|
||||
frequency: '121.700',
|
||||
frequencyName: 'Clearance Delivery',
|
||||
action: 'Request IFR Clearance',
|
||||
trigger: 'pilot',
|
||||
pilot: '${airport} Delivery, good day, ${callsign} at stand ${gate}, with information ${atis}, requesting IFR clearance to ${arrival}',
|
||||
atc: '${callsign}, ${airport} Delivery, good day, cleared to ${arrival}, ${sid} departure, runway ${runway}, squawk ${squawk}, departure frequency ${departureFreq}',
|
||||
pilotResponse: 'Cleared to ${arrival}, ${sid} departure, runway ${runway}, squawk ${squawk}, departure ${departureFreq}, ${callsign}',
|
||||
atcResponse: '${callsign}, readback correct, report ready for startup',
|
||||
nextStep: 'startup_request',
|
||||
audioEffects: {static: 15, distortion: 5, volume: 85}
|
||||
},
|
||||
|
||||
// Phase 2: Startup
|
||||
{
|
||||
id: 'startup_request',
|
||||
phase: 'clearance',
|
||||
frequency: '121.700',
|
||||
frequencyName: 'Clearance Delivery',
|
||||
action: 'Ready for Startup',
|
||||
trigger: 'pilot',
|
||||
pilot: '${callsign} ready for startup',
|
||||
atc: '${callsign}, startup approved, for pushback contact Ground on ${groundFreq}',
|
||||
pilotResponse: 'Startup approved, Ground ${groundFreq}, ${callsign}',
|
||||
nextStep: 'pushback_request',
|
||||
audioEffects: {static: 12, distortion: 3, volume: 88}
|
||||
},
|
||||
|
||||
// Phase 3: Ground - Pushback
|
||||
{
|
||||
id: 'pushback_request',
|
||||
phase: 'ground',
|
||||
frequency: '121.900',
|
||||
frequencyName: 'Ground Control',
|
||||
action: 'Request Pushback',
|
||||
trigger: 'pilot',
|
||||
pilot: '${airport} Ground, good day, ${callsign} at stand ${gate}, requesting pushback',
|
||||
atc: '${callsign}, ${airport} Ground, good day, pushback approved, facing ${direction}',
|
||||
pilotResponse: 'Pushback approved, facing ${direction}, ${callsign}',
|
||||
atcResponse: '${callsign}, pushback complete, start engines when ready',
|
||||
nextStep: 'taxi_request',
|
||||
callback: () => console.log('Pushback phase completed'),
|
||||
audioEffects: {static: 18, distortion: 7, volume: 82}
|
||||
},
|
||||
|
||||
// Phase 4: Ground - Taxi
|
||||
{
|
||||
id: 'taxi_request',
|
||||
phase: 'ground',
|
||||
frequency: '121.900',
|
||||
frequencyName: 'Ground Control',
|
||||
action: 'Request Taxi',
|
||||
trigger: 'pilot',
|
||||
pilot: '${callsign} request taxi',
|
||||
atc: '${callsign}, taxi to runway ${runway} via ${taxiRoute}, hold short of runway ${runway}',
|
||||
pilotResponse: 'Taxi to runway ${runway} via ${taxiRoute}, hold short of runway ${runway}, ${callsign}',
|
||||
nextStep: 'tower_handoff',
|
||||
conditions: [
|
||||
{type: 'contains', value: 'give way', nextStep: 'give_way'}
|
||||
],
|
||||
audioEffects: {static: 20, distortion: 8, volume: 80}
|
||||
},
|
||||
|
||||
// Ground - Give Way (Optional)
|
||||
{
|
||||
id: 'give_way',
|
||||
phase: 'ground',
|
||||
frequency: '121.900',
|
||||
frequencyName: 'Ground Control',
|
||||
action: 'Traffic Instruction',
|
||||
trigger: 'atc',
|
||||
atc: '${callsign}, give way to the ${trafficType} from your ${direction}',
|
||||
pilotResponse: 'Give way to ${trafficType} from ${direction}, ${callsign}',
|
||||
nextStep: 'taxi_continue',
|
||||
audioEffects: {static: 22, distortion: 10, volume: 78}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'taxi_continue',
|
||||
phase: 'ground',
|
||||
frequency: '121.900',
|
||||
frequencyName: 'Ground Control',
|
||||
action: 'Continue Taxi',
|
||||
trigger: 'atc',
|
||||
atc: '${callsign}, continue taxi',
|
||||
pilotResponse: 'Continue taxi, ${callsign}',
|
||||
nextStep: 'tower_handoff',
|
||||
audioEffects: {static: 18, distortion: 6, volume: 85}
|
||||
},
|
||||
|
||||
// Phase 5: Tower Handoff
|
||||
{
|
||||
id: 'tower_handoff',
|
||||
phase: 'ground',
|
||||
frequency: '121.900',
|
||||
frequencyName: 'Ground Control',
|
||||
action: 'Tower Handoff',
|
||||
trigger: 'atc',
|
||||
atc: '${callsign}, at holding point contact Tower on ${towerFreq}',
|
||||
pilotResponse: 'Contact Tower ${towerFreq}, ${callsign}',
|
||||
nextStep: 'tower_checkin',
|
||||
callback: () => console.log('Switching to Tower frequency'),
|
||||
audioEffects: {static: 15, distortion: 5, volume: 88}
|
||||
},
|
||||
|
||||
// Phase 6: Tower - Check In
|
||||
{
|
||||
id: 'tower_checkin',
|
||||
phase: 'tower',
|
||||
frequency: '119.100',
|
||||
frequencyName: 'Tower',
|
||||
action: 'Tower Check-in',
|
||||
trigger: 'pilot',
|
||||
pilot: '${airport} Tower, good day, ${callsign} at holding point runway ${runway}, ready for departure',
|
||||
atc: '${callsign}, ${airport} Tower, good day, line up and wait runway ${runway}',
|
||||
pilotResponse: 'Line up and wait runway ${runway}, ${callsign}',
|
||||
nextStep: 'takeoff_clearance',
|
||||
audioEffects: {static: 10, distortion: 3, volume: 90}
|
||||
},
|
||||
|
||||
// Phase 7: Tower - Takeoff
|
||||
{
|
||||
id: 'takeoff_clearance',
|
||||
phase: 'tower',
|
||||
frequency: '119.100',
|
||||
frequencyName: 'Tower',
|
||||
action: 'Takeoff Clearance',
|
||||
trigger: 'atc',
|
||||
atc: '${callsign}, wind ${wind}, runway ${runway}, cleared for takeoff',
|
||||
pilotResponse: 'Cleared for takeoff runway ${runway}, ${callsign}',
|
||||
nextStep: 'departure_handoff',
|
||||
conditions: [
|
||||
{type: 'timeout', value: 30000, nextStep: 'departure_handoff'}
|
||||
],
|
||||
audioEffects: {static: 8, distortion: 2, volume: 92}
|
||||
},
|
||||
|
||||
// Phase 8: Departure Handoff
|
||||
{
|
||||
id: 'departure_handoff',
|
||||
phase: 'tower',
|
||||
frequency: '119.100',
|
||||
frequencyName: 'Tower',
|
||||
action: 'Departure Handoff',
|
||||
trigger: 'atc',
|
||||
atc: '${callsign}, contact ${airport} Departure ${departureFreq}',
|
||||
pilotResponse: 'Contact ${airport} Departure ${departureFreq}, ${callsign}',
|
||||
nextStep: 'departure_checkin',
|
||||
callback: () => console.log('Switching to Departure frequency'),
|
||||
audioEffects: {static: 12, distortion: 4, volume: 88}
|
||||
},
|
||||
|
||||
// Phase 9: Departure Control
|
||||
{
|
||||
id: 'departure_checkin',
|
||||
phase: 'departure',
|
||||
frequency: '123.800',
|
||||
frequencyName: 'Departure Control',
|
||||
action: 'Departure Check-in',
|
||||
trigger: 'pilot',
|
||||
pilot: '${airport} Departure, good day, ${callsign}, passing ${altitude} for ${flightLevel}, ${sid}',
|
||||
atc: '${callsign}, ${airport} Departure, radar contact, climb flight level ${flightLevel}',
|
||||
pilotResponse: 'Climb flight level ${flightLevel}, ${callsign}',
|
||||
nextStep: 'enroute_handoff',
|
||||
audioEffects: {static: 15, distortion: 6, volume: 85}
|
||||
},
|
||||
|
||||
// Phase 10: Enroute
|
||||
{
|
||||
id: 'enroute_handoff',
|
||||
phase: 'departure',
|
||||
frequency: '123.800',
|
||||
frequencyName: 'Departure Control',
|
||||
action: 'Enroute Handoff',
|
||||
trigger: 'atc',
|
||||
atc: '${callsign}, contact ${centerName} ${centerFreq}',
|
||||
pilotResponse: 'Contact ${centerName} ${centerFreq}, ${callsign}',
|
||||
nextStep: 'flight_complete',
|
||||
callback: () => console.log('Flight handed off to enroute control'),
|
||||
audioEffects: {static: 20, distortion: 8, volume: 82}
|
||||
}
|
||||
]
|
||||
|
||||
// Text Normalization for Proper ATC Pronunciation
|
||||
export function normalizeATCText(text: string, context: FlightContext): string {
|
||||
let normalized = text
|
||||
|
||||
// Replace variables with context values
|
||||
Object.keys(context).forEach(key => {
|
||||
const value = (context as any)[key]
|
||||
if (value) {
|
||||
normalized = normalized.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value)
|
||||
}
|
||||
})
|
||||
|
||||
// Normalize runway callouts (e.g., "25L" -> "two five left")
|
||||
normalized = normalized.replace(/runway\s+(\d{1,2})([LRC]?)/gi, (match, num, suffix) => {
|
||||
const normalizedNum = num.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
const normalizedSuffix = suffix === 'L' ? ' left' : suffix === 'R' ? ' right' : suffix === 'C' ? ' center' : ''
|
||||
return `runway ${normalizedNum}${normalizedSuffix}`
|
||||
})
|
||||
|
||||
// Normalize flight levels (e.g., "FL350" -> "flight level three five zero")
|
||||
normalized = normalized.replace(/FL(\d{3})/gi, (match, level) => {
|
||||
const normalizedLevel = level.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
return `flight level ${normalizedLevel}`
|
||||
})
|
||||
|
||||
// Normalize altitudes (e.g., "5000" -> "five thousand")
|
||||
normalized = normalized.replace(/\b(\d{1,5})\s*(?:feet?|ft)\b/gi, (match, alt) => {
|
||||
return normalizeAltitude(parseInt(alt))
|
||||
})
|
||||
|
||||
// Normalize frequencies (e.g., "121.9" -> "wun too wun decimal niner")
|
||||
normalized = normalized.replace(/(\d{3})\.(\d{1,3})/g, (match, before, after) => {
|
||||
const normalizedBefore = before.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
const normalizedAfter = after.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
return `${normalizedBefore} decimal ${normalizedAfter}`
|
||||
})
|
||||
|
||||
// Normalize squawk codes (e.g., "4567" -> "fower fife six seven")
|
||||
normalized = normalized.replace(/squawk\s+(\d{4})/gi, (match, code) => {
|
||||
const normalizedCode = code.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
return `squawk ${normalizedCode}`
|
||||
})
|
||||
|
||||
// Normalize headings (e.g., "270" -> "heading two seven zero")
|
||||
normalized = normalized.replace(/heading\s+(\d{3})/gi, (match, heading) => {
|
||||
const normalizedHeading = heading.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
return `heading ${normalizedHeading}`
|
||||
})
|
||||
|
||||
// Normalize taxi instructions (e.g., "via A, B, C" -> "via Alpha, Bravo, Charlie")
|
||||
normalized = normalized.replace(/via\s+([A-Z](?:\d+)?(?:\s*,\s*[A-Z](?:\d+)?)*)/gi, (match, route) => {
|
||||
const normalizedRoute = route.split(/\s*,\s*/).map((segment: string) => {
|
||||
return segment.replace(/([A-Z])(\d+)?/g, (segMatch, letter, number) => {
|
||||
const normalizedLetter = NATO_PHONETIC[letter] || letter
|
||||
const normalizedNumber = number ? number.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ') : ''
|
||||
return normalizedLetter + (normalizedNumber ? ' ' + normalizedNumber : '')
|
||||
})
|
||||
}).join(', ')
|
||||
return `via ${normalizedRoute}`
|
||||
})
|
||||
|
||||
// Normalize single taxiway references (e.g., "A5" -> "Alpha five")
|
||||
normalized = normalized.replace(/\b([A-Z])(\d+)\b/g, (match, letter, number) => {
|
||||
const normalizedLetter = NATO_PHONETIC[letter] || letter
|
||||
const normalizedNumber = number.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
return `${normalizedLetter} ${normalizedNumber}`
|
||||
})
|
||||
|
||||
// Normalize wind callouts (e.g., "280/15" -> "two eight zero at wun fife")
|
||||
normalized = normalized.replace(/(\d{3})\/(\d{1,2})/g, (match, direction, speed) => {
|
||||
const normalizedDirection = direction.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
const normalizedSpeed = speed.split('').map((digit: string) => ICAO_NUMBERS[digit] || digit).join(' ')
|
||||
return `${normalizedDirection} at ${normalizedSpeed}`
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeAltitude(altitude: number): string {
|
||||
if (altitude >= 1000) {
|
||||
const thousands = Math.floor(altitude / 1000)
|
||||
const remainder = altitude % 1000
|
||||
let result = `${ICAO_NUMBERS[thousands.toString()]} thousand`
|
||||
if (remainder > 0) {
|
||||
result += ` ${remainder}`
|
||||
}
|
||||
return result
|
||||
}
|
||||
return altitude.toString()
|
||||
}
|
||||
|
||||
// Communication Engine Composable
|
||||
export default function useCommunicationsEngine() {
|
||||
const currentStep = ref<CommunicationStep | null>(null)
|
||||
const flightContext = ref<FlightContext>({
|
||||
callsign: '',
|
||||
aircraft: '',
|
||||
departure: '',
|
||||
arrival: '',
|
||||
gate: '',
|
||||
runway: '',
|
||||
squawk: '',
|
||||
atis: '',
|
||||
sid: '',
|
||||
flightLevel: '',
|
||||
groundFreq: '121.900',
|
||||
towerFreq: '119.100',
|
||||
departureFreq: '123.800',
|
||||
approachFreq: '119.300',
|
||||
taxiRoute: [],
|
||||
phase: 'clearance'
|
||||
})
|
||||
|
||||
const communicationLog = ref<Array<{
|
||||
timestamp: Date
|
||||
frequency: string
|
||||
speaker: 'pilot' | 'atc'
|
||||
message: string
|
||||
normalized: string
|
||||
step: string
|
||||
}>>([])
|
||||
|
||||
const initializeFlight = (flightPlan: any) => {
|
||||
flightContext.value = {
|
||||
callsign: flightPlan.callsign,
|
||||
aircraft: flightPlan.aircraft?.split('/')[0] || 'Unknown',
|
||||
departure: flightPlan.dep,
|
||||
arrival: flightPlan.arr,
|
||||
gate: generateGate(flightPlan.dep),
|
||||
runway: generateRunway(flightPlan.dep),
|
||||
squawk: flightPlan.assignedsquawk || generateSquawk(),
|
||||
atis: generateATIS(),
|
||||
sid: generateSID(flightPlan.route),
|
||||
flightLevel: `FL${Math.floor(parseInt(flightPlan.altitude) / 100).toString().padStart(3, '0')}`,
|
||||
groundFreq: '121.900',
|
||||
towerFreq: '119.100',
|
||||
departureFreq: '123.800',
|
||||
approachFreq: '119.300',
|
||||
taxiRoute: generateTaxiRoute(),
|
||||
phase: 'clearance'
|
||||
}
|
||||
|
||||
currentStep.value = COMMUNICATION_STEPS[0]
|
||||
}
|
||||
|
||||
const processUserTransmission = (transcription: string): string | null => {
|
||||
if (!currentStep.value) return null
|
||||
|
||||
const normalized = normalizeATCText(transcription, flightContext.value)
|
||||
|
||||
// Log user transmission
|
||||
communicationLog.value.push({
|
||||
timestamp: new Date(),
|
||||
frequency: currentStep.value.frequency,
|
||||
speaker: 'pilot',
|
||||
message: transcription,
|
||||
normalized,
|
||||
step: currentStep.value.id
|
||||
})
|
||||
|
||||
// Generate ATC response
|
||||
const response = generateATCResponse(currentStep.value, transcription)
|
||||
|
||||
if (response) {
|
||||
const normalizedResponse = normalizeATCText(response, flightContext.value)
|
||||
|
||||
// Log ATC response
|
||||
communicationLog.value.push({
|
||||
timestamp: new Date(),
|
||||
frequency: currentStep.value.frequency,
|
||||
speaker: 'atc',
|
||||
message: response,
|
||||
normalized: normalizedResponse,
|
||||
step: currentStep.value.id
|
||||
})
|
||||
|
||||
// Move to next step if applicable
|
||||
if (currentStep.value.nextStep) {
|
||||
moveToStep(currentStep.value.nextStep)
|
||||
}
|
||||
|
||||
// Execute callback if present
|
||||
if (currentStep.value.callback) {
|
||||
currentStep.value.callback()
|
||||
}
|
||||
|
||||
return normalizedResponse
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const generateATCResponse = (step: CommunicationStep, pilotMessage: string): string | null => {
|
||||
// Check if this step expects a pilot transmission
|
||||
if (step.trigger !== 'pilot' && !step.atc) return null
|
||||
|
||||
// Return appropriate ATC response based on step
|
||||
if (step.atcResponse) {
|
||||
return step.atcResponse
|
||||
} else if (step.atc) {
|
||||
return step.atc
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const moveToStep = (stepId: string) => {
|
||||
const nextStep = COMMUNICATION_STEPS.find(s => s.id === stepId)
|
||||
if (nextStep) {
|
||||
currentStep.value = nextStep
|
||||
flightContext.value.phase = nextStep.phase
|
||||
|
||||
// Auto-trigger ATC communications
|
||||
if (nextStep.trigger === 'atc' && nextStep.atc) {
|
||||
setTimeout(() => {
|
||||
const normalizedMessage = normalizeATCText(nextStep.atc!, flightContext.value)
|
||||
|
||||
communicationLog.value.push({
|
||||
timestamp: new Date(),
|
||||
frequency: nextStep.frequency,
|
||||
speaker: 'atc',
|
||||
message: nextStep.atc!,
|
||||
normalized: normalizedMessage,
|
||||
step: nextStep.id
|
||||
})
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions for flight data generation
|
||||
const generateGate = (airport: string): string => {
|
||||
const gates = ['A12', 'B15', 'C23', 'D8', 'E41', 'F18', 'G7', 'H33']
|
||||
return gates[Math.floor(Math.random() * gates.length)]
|
||||
}
|
||||
|
||||
const generateRunway = (airport: string): string => {
|
||||
const runways = ['25L', '25R', '07L', '07R', '18', '36', '09', '27']
|
||||
return runways[Math.floor(Math.random() * runways.length)]
|
||||
}
|
||||
|
||||
const generateSquawk = (): string => {
|
||||
return Math.floor(Math.random() * 8000 + 1000).toString()
|
||||
}
|
||||
|
||||
const generateATIS = (): string => {
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
return letters[Math.floor(Math.random() * letters.length)]
|
||||
}
|
||||
|
||||
const generateSID = (route: string): string => {
|
||||
const sids = ['SULUS5S', 'TOBAK2E', 'MARUN7F', 'CINDY1A', 'HELEN4B']
|
||||
return sids[Math.floor(Math.random() * sids.length)]
|
||||
}
|
||||
|
||||
const generateTaxiRoute = (): string[] => {
|
||||
const taxiways = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']
|
||||
const count = Math.floor(Math.random() * 3) + 1
|
||||
return taxiways.slice(0, count)
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep: readonly(currentStep),
|
||||
flightContext: readonly(flightContext),
|
||||
communicationLog: readonly(communicationLog),
|
||||
initializeFlight,
|
||||
processUserTransmission,
|
||||
moveToStep,
|
||||
normalizeATCText
|
||||
}
|
||||
}
|
||||
346
app/composables/radioTtsNew.ts
Normal file
346
app/composables/radioTtsNew.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
// composables/radioTtsNew.ts
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface TTSOptions {
|
||||
level?: number
|
||||
voice?: string
|
||||
speed?: number
|
||||
moduleId?: string
|
||||
lessonId?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
moduleId: string
|
||||
lessonId: string
|
||||
phraseId?: string
|
||||
customVariables?: Record<string, string>
|
||||
type?: 'instruction' | 'clearance' | 'information' | 'request'
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface PTTOptions {
|
||||
expectedText: string
|
||||
moduleId: string
|
||||
lessonId: string
|
||||
format?: 'wav' | 'mp3' | 'ogg' | 'webm'
|
||||
}
|
||||
|
||||
interface AudioCache {
|
||||
[key: string]: {
|
||||
blob: Blob
|
||||
url: string
|
||||
timestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function useRadioTTS() {
|
||||
const isLoading = ref(false)
|
||||
const isRecording = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const audioCache = ref<AudioCache>({})
|
||||
|
||||
let currentAudio: HTMLAudioElement | null = null
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let recordedChunks: Blob[] = []
|
||||
|
||||
// Cache Management
|
||||
const cacheKey = (text: string, options: TTSOptions) =>
|
||||
`${text}-${options.level || 4}-${options.voice || 'alloy'}-${options.speed || 1.0}`
|
||||
|
||||
const getCachedAudio = (key: string) => {
|
||||
const cached = audioCache.value[key]
|
||||
if (cached && Date.now() - cached.timestamp < 3600000) { // 1 hour cache
|
||||
return cached
|
||||
}
|
||||
if (cached) {
|
||||
URL.revokeObjectURL(cached.url)
|
||||
delete audioCache.value[key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const setCachedAudio = (key: string, blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
audioCache.value[key] = {
|
||||
blob,
|
||||
url,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// Enhanced Server TTS with caching
|
||||
const speakServer = async (text: string, options: TTSOptions = {}) => {
|
||||
error.value = null
|
||||
|
||||
const key = cacheKey(text, options)
|
||||
const cached = getCachedAudio(key)
|
||||
|
||||
if (cached) {
|
||||
return playAudio(cached.url)
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/atc/say', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
text,
|
||||
level: options.level || 4,
|
||||
voice: options.voice || 'alloy',
|
||||
speed: options.speed || 1.0,
|
||||
moduleId: options.moduleId,
|
||||
lessonId: options.lessonId,
|
||||
tag: options.tag
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('TTS generation failed')
|
||||
}
|
||||
|
||||
// Convert base64 to blob and cache
|
||||
const audioData = atob(response.audio.base64)
|
||||
const audioArray = new Uint8Array(audioData.length)
|
||||
for (let i = 0; i < audioData.length; i++) {
|
||||
audioArray[i] = audioData.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([audioArray], { type: response.audio.mime })
|
||||
|
||||
const audioUrl = setCachedAudio(key, blob)
|
||||
await playAudio(audioUrl)
|
||||
|
||||
return response
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'TTS failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Browser TTS (fallback)
|
||||
const speakBrowser = (text: string, options: { voice?: string; rate?: number; pitch?: number } = {}) => {
|
||||
if (!window.speechSynthesis) {
|
||||
error.value = 'Browser TTS not supported'
|
||||
return
|
||||
}
|
||||
|
||||
stop() // Stop any current speech
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
|
||||
if (options.voice) {
|
||||
const voices = speechSynthesis.getVoices()
|
||||
const voice = voices.find(v => v.name.includes(options.voice!))
|
||||
if (voice) utterance.voice = voice
|
||||
}
|
||||
|
||||
utterance.rate = options.rate || 0.9
|
||||
utterance.pitch = options.pitch || 1.0
|
||||
utterance.volume = 1.0
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
error.value = `TTS error: ${event.error}`
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
// Generate ATC phrases
|
||||
const generatePhrase = async (options: GenerateOptions) => {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/atc/generate', {
|
||||
method: 'POST',
|
||||
body: options
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('Phrase generation failed')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Phrase generation failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// PTT (Push-to-Talk) Recording
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value) return
|
||||
|
||||
error.value = null
|
||||
recordedChunks = []
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
})
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
isRecording.value = true
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Recording failed'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = async (): Promise<Blob | null> => {
|
||||
if (!isRecording.value || !mediaRecorder) return null
|
||||
|
||||
return new Promise((resolve) => {
|
||||
mediaRecorder!.onstop = () => {
|
||||
const blob = new Blob(recordedChunks, { type: 'audio/webm' })
|
||||
|
||||
// Stop all tracks
|
||||
mediaRecorder!.stream.getTracks().forEach(track => track.stop())
|
||||
mediaRecorder = null
|
||||
isRecording.value = false
|
||||
|
||||
resolve(blob)
|
||||
}
|
||||
|
||||
mediaRecorder!.stop()
|
||||
})
|
||||
}
|
||||
|
||||
// Submit PTT for evaluation
|
||||
const submitPTT = async (audioBlob: Blob, options: PTTOptions) => {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Convert blob to base64
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)))
|
||||
|
||||
const response = await $fetch('/api/atc/ptt', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
audio: base64,
|
||||
expectedText: options.expectedText,
|
||||
moduleId: options.moduleId,
|
||||
lessonId: options.lessonId,
|
||||
format: options.format || 'webm'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('PTT evaluation failed')
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'PTT evaluation failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Audio playback
|
||||
const playAudio = async (audioUrl: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
stop() // Stop any current audio
|
||||
|
||||
currentAudio = new Audio(audioUrl)
|
||||
currentAudio.volume = 1.0
|
||||
|
||||
currentAudio.addEventListener('ended', () => resolve())
|
||||
currentAudio.addEventListener('error', (e) => {
|
||||
error.value = 'Audio playback failed'
|
||||
reject(e)
|
||||
})
|
||||
|
||||
currentAudio.play().catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
// Stop TTS
|
||||
if (window.speechSynthesis) {
|
||||
speechSynthesis.cancel()
|
||||
}
|
||||
|
||||
// Stop audio playback
|
||||
if (currentAudio) {
|
||||
currentAudio.pause()
|
||||
currentAudio.currentTime = 0
|
||||
currentAudio = null
|
||||
}
|
||||
|
||||
// Stop recording
|
||||
if (isRecording.value && mediaRecorder) {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
const cleanup = () => {
|
||||
stop()
|
||||
|
||||
// Clear cache URLs
|
||||
Object.values(audioCache.value).forEach(cached => {
|
||||
URL.revokeObjectURL(cached.url)
|
||||
})
|
||||
audioCache.value = {}
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading: readonly(isLoading),
|
||||
isRecording: readonly(isRecording),
|
||||
error: readonly(error),
|
||||
|
||||
// TTS Methods
|
||||
speakServer,
|
||||
speakBrowser,
|
||||
|
||||
// Phrase Generation
|
||||
generatePhrase,
|
||||
|
||||
// PTT Methods
|
||||
startRecording,
|
||||
stopRecording,
|
||||
submitPTT,
|
||||
|
||||
// Control
|
||||
stop,
|
||||
cleanup,
|
||||
|
||||
// Utilities
|
||||
playAudio
|
||||
}
|
||||
}
|
||||
1574
app/pages/pm.vue
1574
app/pages/pm.vue
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user