mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 14:16:05 +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
|
||||
|
||||
59
content/news/classroom-feedback-update.md
Normal file
59
content/news/classroom-feedback-update.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "Classroom Update: Slower Audio, Smarter Phonetics, and METAR That Actually Makes Sense"
|
||||
date: "2025-02-14"
|
||||
summary: "We listened to your feedback and improved audio clarity, fixed phonetic edge cases, and made METAR lessons actually understandable."
|
||||
readingTime: "2 min read"
|
||||
---
|
||||
|
||||
Thank you to everyone who took the time to share feedback on the Classroom. Your reports were specific, honest, and incredibly useful. Here's what changed based on what you told us.
|
||||
|
||||
---
|
||||
|
||||
## Audio Speed: Slower by Default
|
||||
|
||||
The most common theme was clear: **ATC audio is too fast**, especially for new users and when dealing with complex phrases like SID/STAR names or weather reports.
|
||||
|
||||
- The **default speaking speed is now 0.85x** instead of 1.0x, so new users start at a more comfortable pace.
|
||||
- The **speed slider now goes down to 0.5x** (previously 0.7x), giving you more room to slow things down while you're learning.
|
||||
|
||||
You can still crank it up to 1.3x once you're ready for realistic traffic speed.
|
||||
|
||||
---
|
||||
|
||||
## METAR Lessons: No More Robot Gibberish
|
||||
|
||||
Several of you pointed out that METAR content was spoken literally -- raw codes like "BKN025" or "28015KT" made no sense when read aloud by the TTS engine.
|
||||
|
||||
The Classroom now **normalizes METAR phrases into proper spoken aviation English** before sending them to TTS:
|
||||
|
||||
- `28015KT` becomes *"wind two eight zero degrees, one five knots"*
|
||||
- `BKN025` becomes *"broken, two thousand five hundred feet"*
|
||||
- `15/08` becomes *"temperature one five, dew point zero eight"*
|
||||
- Natural pauses between each weather element
|
||||
|
||||
This applies to both the METAR decode and ATIS lessons.
|
||||
|
||||
---
|
||||
|
||||
## Phonetics: SID, STAR, and Approach Suffixes
|
||||
|
||||
Some of you noticed that procedure names like **SUGOL 2S** or approach types like **ILS 08R Y** weren't being phonetized correctly. The suffix letters (S, Y, Z) were spoken as regular letters instead of their ICAO equivalents.
|
||||
|
||||
Now:
|
||||
|
||||
- **SID/STAR suffixes** are correctly expanded: SUGOL 2S becomes *"SUGOL two Sierra"*
|
||||
- **Approach suffixes** are handled too: ILS 08R Y becomes *"ILS runway zero eight right Yankee"*
|
||||
- The system now handles spaces in SID/STAR names (e.g., `SUGOL 2S` vs `SUGOL2S`)
|
||||
|
||||
---
|
||||
|
||||
## Small Fixes
|
||||
|
||||
- The readback feedback label now says **"Expected:"** instead of the German "Soll:" -- sorry about that.
|
||||
- The **Pushback Delay** lesson now accepts both the word form ("five") and the numeral ("5") for the delay field.
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
We're continuing to improve the Classroom based on your input. If you run into anything else that feels off, let us know -- your feedback directly shapes what we build.
|
||||
@@ -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