mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
feat(stt): seed Whisper prompt with expected readback + per-field debug UI
Whisper prompt seeding (per request):
- ptt.post.ts builds the prompt as generic ICAO bias + this state's expected
readback appended LAST (survives the 224-token truncation), in both raw token
form and spoken ICAO form via new radioSpeech.speakToken().
- pm.vue passes the expected phrase + active variable values; classroom.vue
passes the lesson's expected field values.
Per-field readback debug:
- sttMatch.matchTranscriptionToFields returns fields[] (matched/missing + which
view matched) plus normalized/denormalized transcription views.
- useRadioBackend types readback_report on the transmit response.
- pm.vue renders a "Readback check" panel in the right log rail; classroom.vue
renders per-field rows under the STT panel.
Radio-pronunciation fixes (radioSpeech.ts):
- callsign expander handles multi-letter suffixes (DLH6RK -> Lufthansa six Romeo
Kilo).
- toRadioSpeech now expands airports (EDDC -> Echo Delta Delta Charlie).
- bare altitudes >=1000 in a clearance context are spoken ("climb initially
5000" -> "climb initially five thousand feet"); speeds/headings untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,62 @@ import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
DEFAULT_AIRLINE_TELEPHONY,
|
||||
normalizeAtisForSpeech,
|
||||
normalizeMetarPhrase,
|
||||
normalizeRadioPhrase,
|
||||
speakToken,
|
||||
spellIcaoDigits,
|
||||
spellIcaoLetters,
|
||||
toIcaoPhonetic,
|
||||
} from '~~/shared/utils/radioSpeech'
|
||||
|
||||
describe('normalizeRadioPhrase — PM radio pronunciation', () => {
|
||||
const opts = {
|
||||
expandCallsigns: true,
|
||||
expandAirports: true,
|
||||
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
|
||||
sidSuffixIcao: true,
|
||||
}
|
||||
|
||||
it('expands a callsign with a multi-letter suffix (DLH6RK)', () => {
|
||||
assert.equal(normalizeRadioPhrase('DLH6RK', opts), 'Lufthansa six Romeo Kilo')
|
||||
})
|
||||
|
||||
it('spells a 4-letter ICAO airport code', () => {
|
||||
assert.equal(normalizeRadioPhrase('EDDC', opts), spellIcaoLetters('EDDC'))
|
||||
})
|
||||
|
||||
it('speaks a bare altitude in a clearance context', () => {
|
||||
assert.match(normalizeRadioPhrase('climb initially 5000', opts), /thousand feet$/)
|
||||
})
|
||||
|
||||
it('leaves sub-1000 numbers (speeds/headings) untouched', () => {
|
||||
assert.equal(normalizeRadioPhrase('maintain 250 knots', opts), 'maintain 250 knots')
|
||||
})
|
||||
})
|
||||
|
||||
describe('speakToken', () => {
|
||||
it('speaks a runway with its side', () => {
|
||||
assert.equal(speakToken('25R'), `${spellIcaoDigits('25')} right`)
|
||||
})
|
||||
it('speaks a frequency with decimal', () => {
|
||||
assert.equal(speakToken('121.800'), `${spellIcaoDigits('121')} decimal ${spellIcaoDigits('800')}`)
|
||||
})
|
||||
it('speaks a flight level', () => {
|
||||
assert.equal(speakToken('FL150'), `flight level ${spellIcaoDigits('150')}`)
|
||||
})
|
||||
it('spells a pure number digit by digit', () => {
|
||||
assert.equal(speakToken('2341'), spellIcaoDigits('2341'))
|
||||
})
|
||||
it('spells an alphanumeric identifier phonetically', () => {
|
||||
assert.equal(speakToken('BIBAX1N'), toIcaoPhonetic('BIBAX1N'))
|
||||
})
|
||||
it('returns empty for plain words (caller keeps raw)', () => {
|
||||
assert.equal(speakToken('west'), '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('radioSpeech', () => {
|
||||
it('spells ICAO digits and letters', () => {
|
||||
assert.equal(spellIcaoDigits('120'), 'wun too zero')
|
||||
|
||||
@@ -120,6 +120,31 @@ describe('fuzzyContains', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchTranscriptionToFields — per-field report', () => {
|
||||
it('reports matched and missing fields with the view that matched', () => {
|
||||
const result = matchTranscriptionToFields('runway two five right squawk seven five zero zero', [
|
||||
{ key: 'runway', expected: '25R' },
|
||||
{ key: 'squawk', expected: '7500' },
|
||||
{ key: 'altitude', expected: '5000' },
|
||||
])
|
||||
const byKey = Object.fromEntries(result.fields.map(f => [f.key, f]))
|
||||
assert.equal(byKey.runway!.matched, true)
|
||||
assert.equal(byKey.runway!.view, 'spoken') // folded from "two five right"
|
||||
assert.equal(byKey.squawk!.matched, true)
|
||||
assert.equal(byKey.altitude!.matched, false) // never spoken
|
||||
assert.equal(byKey.altitude!.matchedVia, null)
|
||||
assert.match(result.denormalized, /25r/)
|
||||
})
|
||||
|
||||
it('preserves original field order in the report', () => {
|
||||
const result = matchTranscriptionToFields('nothing here', [
|
||||
{ key: 'a', expected: '111' },
|
||||
{ key: 'b', expected: '222' },
|
||||
])
|
||||
assert.deepEqual(result.fields.map(f => f.key), ['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('looksLikeCallsignKey', () => {
|
||||
it('detects common callsign field keys', () => {
|
||||
assert.equal(looksLikeCallsignKey('callsign'), true)
|
||||
|
||||
Reference in New Issue
Block a user