mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
feat(classroom): integrate user feedback — audio speed, METAR TTS, phonetics, UI fixes
- Lower default audio speed to 0.85x, extend slider range to 0.5-1.3x - Add METAR normalization for intelligible TTS (wind, vis, clouds, temp, QNH) - Expand SID/STAR suffix regex to handle spaces (SUGOL 2S) - Add approach suffix phonetic expansion (ILS 08R Y → Yankee) - Fix "Soll:" → "Expected:" in readback feedback - Accept numeric values for pushback delay field - Add news article documenting the changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1065,7 +1065,7 @@
|
||||
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
|
||||
</v-icon>
|
||||
<small v-if="result" class="blank-feedback">
|
||||
Soll: {{ fieldExpectedValue(segment.key) }}
|
||||
Expected: {{ fieldExpectedValue(segment.key) }}
|
||||
</small>
|
||||
</label>
|
||||
</template>
|
||||
@@ -1262,7 +1262,7 @@
|
||||
<v-slider
|
||||
hide-details
|
||||
v-model="cfg.audioSpeed"
|
||||
:min="0.7"
|
||||
:min="0.5"
|
||||
:max="1.3"
|
||||
:step="0.05"
|
||||
color="cyan"
|
||||
@@ -1331,7 +1331,7 @@ import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} fr
|
||||
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
|
||||
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
|
||||
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
|
||||
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase} from '~~/shared/utils/radioSpeech'
|
||||
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, normalizeMetarPhrase} from '~~/shared/utils/radioSpeech'
|
||||
|
||||
definePageMeta({middleware: ['require-auth', 'require-classroom-intro']})
|
||||
|
||||
@@ -2724,7 +2724,7 @@ function sanitizeLearnConfigPatch(input: any): LearnConfigPatch {
|
||||
|
||||
if (typeof input.audioSpeed === 'number' && Number.isFinite(input.audioSpeed)) {
|
||||
const rounded = Math.round(input.audioSpeed * 20) / 20
|
||||
patch.audioSpeed = Math.min(1.3, Math.max(0.7, rounded))
|
||||
patch.audioSpeed = Math.min(1.3, Math.max(0.5, rounded))
|
||||
}
|
||||
|
||||
if (typeof input.voice === 'string') {
|
||||
@@ -4370,11 +4370,14 @@ async function say(text: string) {
|
||||
const playbackToken = ++activePlaybackToken
|
||||
|
||||
const normalizedRate = computeSpeechRate()
|
||||
const spokenPhrase = normalizeRadioPhrase(trimmed, {
|
||||
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
|
||||
expandAirports: true,
|
||||
expandCallsigns: true
|
||||
})
|
||||
const isMetarLesson = activeLesson.value?.id === 'metar' || activeLesson.value?.id === 'atis'
|
||||
const spokenPhrase = isMetarLesson
|
||||
? normalizeMetarPhrase(trimmed)
|
||||
: normalizeRadioPhrase(trimmed, {
|
||||
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
|
||||
expandAirports: true,
|
||||
expandCallsigns: true
|
||||
})
|
||||
const speakText = spokenPhrase || trimmed
|
||||
|
||||
const hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window
|
||||
|
||||
@@ -759,7 +759,8 @@ const readbackLessons = [
|
||||
expected: scenario => scenario.pushDelayWords,
|
||||
alternatives: scenario => [
|
||||
scenario.pushDelayWords,
|
||||
`${scenario.pushDelayMinutes} minutes`
|
||||
`${scenario.pushDelayMinutes} minutes`,
|
||||
scenario.pushDelayMinutes.toString()
|
||||
],
|
||||
width: 'md'
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
|
||||
radioLevel: 5,
|
||||
voice: '',
|
||||
audioChallenge: true,
|
||||
audioSpeed: 1,
|
||||
audioSpeed: 0.85,
|
||||
}
|
||||
|
||||
export function createDefaultLearnConfig(): LearnConfig {
|
||||
|
||||
@@ -59,6 +59,21 @@ export const DEFAULT_AIRLINE_TELEPHONY: AirlineTelephonyMap = {
|
||||
EZY: "Easy",
|
||||
};
|
||||
|
||||
const METAR_WEATHER: Record<string, string> = {
|
||||
'+TSRA': 'thunderstorm with heavy rain', 'TSRA': 'thunderstorm with rain',
|
||||
'+SHRA': 'heavy rain showers', '-SHRA': 'light rain showers', 'SHRA': 'rain showers',
|
||||
'+RA': 'heavy rain', '-RA': 'light rain', 'RA': 'rain',
|
||||
'+SN': 'heavy snow', '-SN': 'light snow', 'SN': 'snow',
|
||||
'+DZ': 'heavy drizzle', '-DZ': 'light drizzle', 'DZ': 'drizzle',
|
||||
'FG': 'fog', 'BR': 'mist', 'HZ': 'haze',
|
||||
'TS': 'thunderstorm', 'SH': 'showers', 'FZ': 'freezing',
|
||||
'GR': 'hail', 'GS': 'small hail',
|
||||
};
|
||||
|
||||
const METAR_CLOUD: Record<string, string> = {
|
||||
'FEW': 'few', 'SCT': 'scattered', 'BKN': 'broken', 'OVC': 'overcast',
|
||||
};
|
||||
|
||||
export interface NormalizeRadioOptions {
|
||||
airlineMap?: AirlineTelephonyMap;
|
||||
expandCallsigns?: boolean;
|
||||
@@ -252,6 +267,83 @@ function sidSuffixSpeak(prefix: string, digit: string, letter: string): string {
|
||||
return `${prefix} ${spellIcaoDigits(digit)} ${spellIcaoLetters(letter)}`;
|
||||
}
|
||||
|
||||
function approachSpeak(type: string, runway: string, suffix: string): string {
|
||||
const rw = runwaySpeak(runway);
|
||||
const phonetic = ICAO_LETTERS[suffix.toUpperCase()] ?? suffix;
|
||||
return `${type} ${rw} ${phonetic}`;
|
||||
}
|
||||
|
||||
export function normalizeMetarPhrase(metar: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Wind: 28015KT or 28015G25KT or VRB05KT
|
||||
const windMatch = metar.match(/\b(VRB|\d{3})(\d{2,3})(G(\d{2,3}))?KT\b/);
|
||||
if (windMatch) {
|
||||
const dir = windMatch[1] === 'VRB' ? 'variable' : `${spellIcaoDigits(windMatch[1]!)} degrees`;
|
||||
const speed = spellIcaoDigits(windMatch[2]!);
|
||||
let windPart = `wind ${dir}, ${speed} knots`;
|
||||
if (windMatch[4]) {
|
||||
windPart += `, gusting ${spellIcaoDigits(windMatch[4])} knots`;
|
||||
}
|
||||
parts.push(windPart);
|
||||
}
|
||||
|
||||
// Visibility: 9999, 0800, CAVOK
|
||||
if (metar.includes('CAVOK')) {
|
||||
parts.push('CAVOK');
|
||||
} else {
|
||||
const visMatch = metar.match(/(?<!\d)\b(\d{4})\b(?!Z|KT)/);
|
||||
if (visMatch) {
|
||||
const vis = parseInt(visMatch[1]!);
|
||||
if (vis >= 9999) {
|
||||
parts.push(`visibility, ${spellIcaoDigits('1')} ${spellIcaoDigits('0')} kilometers or more`);
|
||||
} else {
|
||||
parts.push(`visibility, ${spellIcaoDigits(vis.toString())} meters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weather phenomena (match longest codes first)
|
||||
const wxPatterns = Object.keys(METAR_WEATHER).sort((a, b) => b.length - a.length);
|
||||
for (const wx of wxPatterns) {
|
||||
if (new RegExp(`\\b${wx.replace('+', '\\+')}\\b`).test(metar) || metar.includes(` ${wx} `)) {
|
||||
const spoken = METAR_WEATHER[wx];
|
||||
if (spoken) parts.push(spoken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clouds: BKN025, SCT040, FEW010, OVC008
|
||||
const cloudRegex = /\b(FEW|SCT|BKN|OVC)(\d{3})\b/g;
|
||||
let cloudMatch;
|
||||
while ((cloudMatch = cloudRegex.exec(metar)) !== null) {
|
||||
const cover = METAR_CLOUD[cloudMatch[1]!] ?? cloudMatch[1];
|
||||
const alt = parseInt(cloudMatch[2]!) * 100;
|
||||
parts.push(`${cover}, ${altitudeSpeak(alt)}`);
|
||||
}
|
||||
|
||||
// Temperature: 15/08 or M02/M05
|
||||
const tempMatch = metar.match(/\b(M?\d{2})\/(M?\d{2})\b/);
|
||||
if (tempMatch) {
|
||||
const speakTemp = (raw: string) => {
|
||||
if (raw.startsWith('M')) {
|
||||
return `minus ${spellIcaoDigits(raw.slice(1))}`;
|
||||
}
|
||||
return spellIcaoDigits(raw);
|
||||
};
|
||||
parts.push(`temperature ${speakTemp(tempMatch[1]!)}, dew point ${speakTemp(tempMatch[2]!)}`);
|
||||
}
|
||||
|
||||
// QNH: Q1013
|
||||
const qnhMatch = metar.match(/\bQ(\d{4})\b/);
|
||||
if (qnhMatch) {
|
||||
parts.push(qnhSpeak(qnhMatch[1]!));
|
||||
}
|
||||
|
||||
if (!parts.length) return metar;
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
export function normalizeRadioPhrase(text: string, options: NormalizeRadioOptions = {}): string {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
let out = text;
|
||||
@@ -265,11 +357,16 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption
|
||||
out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_, qnh: string) => qnhSpeak(qnh));
|
||||
|
||||
if (opts.sidSuffixIcao) {
|
||||
out = out.replace(/\b([A-Z]{4,6})(\s?)(\d)([A-Z])\b/g, (_match, prefix: string, _gap: string, digit: string, letter: string) => {
|
||||
out = out.replace(/\b([A-Z]{4,6})\s*(\d)\s*([A-Z])\b/g, (_match, prefix: string, digit: string, letter: string) => {
|
||||
return sidSuffixSpeak(prefix, digit, letter);
|
||||
});
|
||||
}
|
||||
|
||||
out = out.replace(
|
||||
/\b(ILS|VOR|RNAV|LOC|RNP)\s+(\d{2}[LCR]?)\s+([A-Z])\b/gi,
|
||||
(_match, type: string, runway: string, suffix: string) => approachSpeak(type.toUpperCase(), runway, suffix)
|
||||
);
|
||||
|
||||
if (opts.expandAirports) {
|
||||
out = out.replace(/\b([A-Z]{4})\b/g, (_match, code: string) => icaoAirportSpeak(code));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user