readback 0.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/index.ts +45 -0
- package/package.json +48 -0
- package/src/altitude.ts +52 -0
- package/src/callsigns.ts +57 -0
- package/src/capture.ts +211 -0
- package/src/cleaner.ts +88 -0
- package/src/flightlevel.ts +35 -0
- package/src/heading.ts +39 -0
- package/src/keywords.ts +32 -0
- package/src/numbers.ts +17 -0
- package/src/phonetic.ts +49 -0
- package/src/runway.ts +37 -0
- package/src/speed.ts +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boris Diakur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {Command} from 'commander';
|
|
4
|
+
import {cleanTranscript} from '../src/cleaner.ts';
|
|
5
|
+
import {startCapture} from '../src/capture.ts';
|
|
6
|
+
import packageJson from "../package.json" with {type: "json"};
|
|
7
|
+
|
|
8
|
+
const PREFIX = '📻'
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program
|
|
12
|
+
.version(packageJson.version)
|
|
13
|
+
.description(`${PREFIX} ${packageJson.name}\n${packageJson.description}`)
|
|
14
|
+
.option('--raw', 'Disable all cleaning and formatting')
|
|
15
|
+
.option('--no-callsigns', 'Disable callsign detection and normalization')
|
|
16
|
+
.option('--no-phonetic', 'Disable phonetic formatting')
|
|
17
|
+
.option('--no-fl', 'Disable flight level abbreviation')
|
|
18
|
+
.option('--no-numbers', 'Disable number-word conversion')
|
|
19
|
+
.option('--no-runways', 'Disable runway formatting')
|
|
20
|
+
.option('--no-heading', 'Disable heading formatting')
|
|
21
|
+
.option('--no-speed', 'Disable speed formatting')
|
|
22
|
+
.option('--no-altitude', 'Disable altitude formatting')
|
|
23
|
+
.option('--no-keywords', 'Disable keyword highlighting')
|
|
24
|
+
.helpOption("-h, --help", "Display this help text")
|
|
25
|
+
.parse(process.argv);
|
|
26
|
+
|
|
27
|
+
const opts = program.opts();
|
|
28
|
+
|
|
29
|
+
startCapture(async (text: string) => {
|
|
30
|
+
const output = opts.raw
|
|
31
|
+
? text
|
|
32
|
+
: cleanTranscript(text, {
|
|
33
|
+
callsigns: opts.callsigns,
|
|
34
|
+
phonetic: opts.phonetic,
|
|
35
|
+
fl: opts.fl,
|
|
36
|
+
numbers: opts.numbers,
|
|
37
|
+
runways: opts.runways,
|
|
38
|
+
heading: opts.runways,
|
|
39
|
+
speed: opts.speed,
|
|
40
|
+
altitude: opts.altitude,
|
|
41
|
+
keywords: opts.keywords
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log('📻 ' + output);
|
|
45
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "readback",
|
|
3
|
+
"version": "0.0.0-alpha.0",
|
|
4
|
+
"description": "Transcribes ATC transmissions into readable text.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ATC",
|
|
7
|
+
"aviation"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/borisdiakur/readback#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/borisdiakur/readback/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/borisdiakur/readback.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Boris Diakur (https://borisdiakur.de)",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "index.js",
|
|
21
|
+
"bin": {
|
|
22
|
+
"readback": "./bin/index.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"src/",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"start": "node ./bin/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@ricky0123/vad-node": "^0.0.3",
|
|
35
|
+
"chalk": "^5.6.2",
|
|
36
|
+
"commander": "^14.0.3",
|
|
37
|
+
"native-recorder-nodejs": "^1.2.0",
|
|
38
|
+
"wav": "^1.0.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.1.0",
|
|
42
|
+
"@types/wav": "^1.0.4",
|
|
43
|
+
"cmake-js": "^8.0.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.18.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/altitude.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
|
|
4
|
+
export function normalizeAltitude(text: string): string {
|
|
5
|
+
return text.replace(
|
|
6
|
+
/\b(?:climb(?:\s+and\s+maintain)?|descend(?:\s+and\s+maintain)?|maintain|altitude)\s+([a-zA-Z0-9\s-]+)\b/gi,
|
|
7
|
+
(_, raw: string) => {
|
|
8
|
+
// Normalize number words first
|
|
9
|
+
const normalized = numberWordsToDigits(raw.toLowerCase()).replace(/-/g, ' ');
|
|
10
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
11
|
+
|
|
12
|
+
if (tokens.length === 0) return `altitude ${raw}`;
|
|
13
|
+
|
|
14
|
+
// Remove trailing "feet", "ft"
|
|
15
|
+
const cleanTokens = tokens.filter(t => !/^(feet|foot|ft)$/.test(t));
|
|
16
|
+
|
|
17
|
+
// Case 1: spoken digits ("1 8 0 0 0")
|
|
18
|
+
if (cleanTokens.every(t => /^\d$/.test(t)) && cleanTokens.length >= 2) {
|
|
19
|
+
const alt = cleanTokens.join('');
|
|
20
|
+
return chalk.yellow.bold(`${alt} ft`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Case 2: single number ("3000")
|
|
24
|
+
if (cleanTokens.length === 1 && /^\d{2,5}$/.test(cleanTokens[0])) {
|
|
25
|
+
return chalk.yellow.bold(`${cleanTokens[0]} ft`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Case 3: patterns like "3 1000" → 3000
|
|
29
|
+
if (
|
|
30
|
+
cleanTokens.length === 2 &&
|
|
31
|
+
/^\d+$/.test(cleanTokens[0]) &&
|
|
32
|
+
cleanTokens[1] === '1000'
|
|
33
|
+
) {
|
|
34
|
+
const alt = parseInt(cleanTokens[0], 10) * 1000;
|
|
35
|
+
return chalk.yellow.bold(`${alt} ft`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Case 4: patterns like "2 500" → 2500
|
|
39
|
+
if (
|
|
40
|
+
cleanTokens.length === 2 &&
|
|
41
|
+
/^\d+$/.test(cleanTokens[0]) &&
|
|
42
|
+
/^\d+$/.test(cleanTokens[1])
|
|
43
|
+
) {
|
|
44
|
+
const alt = parseInt(cleanTokens[0] + cleanTokens[1], 10);
|
|
45
|
+
return chalk.yellow.bold(`${alt} ft`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback
|
|
49
|
+
return `altitude ${raw}`;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/callsigns.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
import {phoneticToLetter} from './phonetic.ts';
|
|
4
|
+
|
|
5
|
+
// Spoken airline names
|
|
6
|
+
const telephonyNames = [
|
|
7
|
+
'american', 'delta', 'united', 'southwest', 'alaska', 'jetblue', 'spirit', 'frontier',
|
|
8
|
+
'lufthansa', 'swiss', 'austrian', 'eurowings', 'air berlin', 'air france', 'klm',
|
|
9
|
+
'british airways', 'iberia', 'vueling', 'ryanair', 'easyjet', 'wizz', 'sas', 'finnair',
|
|
10
|
+
'tap', 'lot', 'condor', 'tui', 'norwegian', 'icelandair', 'ice air', 'play', 'qatar',
|
|
11
|
+
'emirates', 'etihad', 'turkish', 'saudia', 'singapore', 'cathay', 'ana', 'jal', 'qantas'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Phonetic alphabet
|
|
15
|
+
const phonetic = [
|
|
16
|
+
'alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india',
|
|
17
|
+
'juliet', 'kilo', 'lima', 'mike', 'november', 'oscar', 'papa', 'quebec', 'romeo',
|
|
18
|
+
'sierra', 'tango', 'uniform', 'victor', 'whiskey', 'xray', 'x-ray', 'yankee', 'zulu'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function detectAndNormalizeCallsign(text: string): string | null {
|
|
22
|
+
const words = text.toLowerCase().split(/\s+/);
|
|
23
|
+
|
|
24
|
+
// 1. TELEPHONY MODE
|
|
25
|
+
const telephony = telephonyNames.find(name => {
|
|
26
|
+
const parts = name.split(' ');
|
|
27
|
+
return chalk.green.bold(parts.every((p, i) => words[i] === p));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (telephony) {
|
|
31
|
+
const telephonyParts = telephony.split(' ');
|
|
32
|
+
const rest = words.slice(telephonyParts.length);
|
|
33
|
+
|
|
34
|
+
const converted = rest
|
|
35
|
+
.map(w => phonetic.includes(w) ? phoneticToLetter(w) : numberWordsToDigits(w))
|
|
36
|
+
.join('');
|
|
37
|
+
|
|
38
|
+
const capitalized = telephony
|
|
39
|
+
.split(' ')
|
|
40
|
+
.map(w => w[0].toUpperCase() + w.slice(1))
|
|
41
|
+
.join(' ');
|
|
42
|
+
|
|
43
|
+
return chalk.green.bold(`${capitalized} ${converted}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. PURE PHONETIC MODE
|
|
47
|
+
if (phonetic.includes(words[0])) {
|
|
48
|
+
const letters = words
|
|
49
|
+
.map(w => phonetic.includes(w) ? phoneticToLetter(w) : numberWordsToDigits(w))
|
|
50
|
+
.join('')
|
|
51
|
+
.toUpperCase();
|
|
52
|
+
|
|
53
|
+
return chalk.green.bold(letters);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {AudioRecorder, SYSTEM_AUDIO_DEVICE_ID} from 'native-recorder-nodejs';
|
|
2
|
+
import {spawn} from 'child_process';
|
|
3
|
+
import {Writer} from 'wav';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import {NonRealTimeVAD} from '@ricky0123/vad-node';
|
|
6
|
+
|
|
7
|
+
const WHISPER_BIN = process.env.WHISPER_BIN || '../whisper.cpp/build/bin/whisper-cli';
|
|
8
|
+
const MODEL_PATH = process.env.MODEL_PATH || './ggml-whisper-atc.bin';
|
|
9
|
+
|
|
10
|
+
export async function startCapture(onTranscript: (text: string) => void) {
|
|
11
|
+
if (!fs.existsSync(MODEL_PATH)) {
|
|
12
|
+
console.error(`⚡️ Model not found at: ${MODEL_PATH}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let seqNum = 0;
|
|
17
|
+
const results = new Map<number, string>();
|
|
18
|
+
let nextToPrint = 0;
|
|
19
|
+
const queue: Array<{ data: Buffer; seq: number }> = [];
|
|
20
|
+
let activeCount = 0;
|
|
21
|
+
const MAX_CONCURRENT = 4;
|
|
22
|
+
|
|
23
|
+
const audioState = {
|
|
24
|
+
buffer: [] as Buffer[],
|
|
25
|
+
size: 0,
|
|
26
|
+
utterance: [] as Buffer[],
|
|
27
|
+
utteranceSize: 0
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const MIN_UTTERANCE_SIZE = 48000;
|
|
31
|
+
const WINDOW_SIZE = 96000;
|
|
32
|
+
|
|
33
|
+
const vad = await NonRealTimeVAD.new({
|
|
34
|
+
positiveSpeechThreshold: 0.3,
|
|
35
|
+
negativeSpeechThreshold: 0.15,
|
|
36
|
+
redemptionFrames: 10,
|
|
37
|
+
preSpeechPadFrames: 1
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const systemRecorder = new AudioRecorder();
|
|
41
|
+
const outputs = AudioRecorder.getDevices('output');
|
|
42
|
+
const systemAudio =
|
|
43
|
+
outputs.find(d => d.id === SYSTEM_AUDIO_DEVICE_ID) ||
|
|
44
|
+
outputs.find(d => d.isDefault);
|
|
45
|
+
|
|
46
|
+
if (!systemAudio) {
|
|
47
|
+
throw new Error('Missing audio devices');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const permissions = AudioRecorder.checkPermission();
|
|
51
|
+
if (!permissions.system) AudioRecorder.requestPermission('system');
|
|
52
|
+
if (!permissions.mic) AudioRecorder.requestPermission('mic');
|
|
53
|
+
|
|
54
|
+
systemRecorder.on('data', async (chunk: Buffer) => {
|
|
55
|
+
await processAudioChunk(chunk);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
systemRecorder.on('error', (err) => {
|
|
59
|
+
console.error('⚡️ System recorder error:', err);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
process.on('SIGINT', async () => {
|
|
63
|
+
console.log('\nStopping...');
|
|
64
|
+
await systemRecorder.stop();
|
|
65
|
+
process.exit(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await systemRecorder.start({
|
|
69
|
+
deviceType: 'output',
|
|
70
|
+
deviceId: systemAudio.id
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
console.info('🗼 Capturing system audio...');
|
|
74
|
+
|
|
75
|
+
// -----------------------------
|
|
76
|
+
// Internal helper functions
|
|
77
|
+
// -----------------------------
|
|
78
|
+
|
|
79
|
+
async function processAudioChunk(chunk: Buffer) {
|
|
80
|
+
const state = audioState;
|
|
81
|
+
state.buffer.push(chunk);
|
|
82
|
+
state.size += chunk.length;
|
|
83
|
+
|
|
84
|
+
while (state.size >= WINDOW_SIZE) {
|
|
85
|
+
const windowChunk = Buffer.concat(
|
|
86
|
+
state.buffer.splice(0, Math.ceil(WINDOW_SIZE / state.buffer[0].length))
|
|
87
|
+
);
|
|
88
|
+
state.size -= windowChunk.length;
|
|
89
|
+
|
|
90
|
+
const mono = stereoToMono(windowChunk);
|
|
91
|
+
const resampled = resample48to16(mono);
|
|
92
|
+
const float32 = bufferToFloat32(resampled);
|
|
93
|
+
|
|
94
|
+
let hasSpeech = false;
|
|
95
|
+
for await (const _ of vad.run(float32, 16000)) {
|
|
96
|
+
hasSpeech = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (hasSpeech) {
|
|
101
|
+
state.utterance.push(windowChunk);
|
|
102
|
+
state.utteranceSize += windowChunk.length;
|
|
103
|
+
} else if (state.utterance.length > 0) {
|
|
104
|
+
state.utterance.push(windowChunk);
|
|
105
|
+
state.utteranceSize += windowChunk.length;
|
|
106
|
+
|
|
107
|
+
if (state.utteranceSize >= MIN_UTTERANCE_SIZE) {
|
|
108
|
+
const utterance = Buffer.concat(state.utterance);
|
|
109
|
+
queue.push({data: utterance, seq: seqNum++});
|
|
110
|
+
processNext();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
state.utterance = [];
|
|
114
|
+
state.utteranceSize = 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function processNext() {
|
|
120
|
+
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
|
|
121
|
+
const item = queue.shift()!;
|
|
122
|
+
activeCount++;
|
|
123
|
+
transcribe(item.data, item.seq);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function transcribe(pcmData: Buffer, seq: number) {
|
|
128
|
+
const wavFile = `./tmp/temp-${seq}.wav`;
|
|
129
|
+
|
|
130
|
+
const writer = new Writer({
|
|
131
|
+
sampleRate: 48000,
|
|
132
|
+
channels: 2,
|
|
133
|
+
bitDepth: 16
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const wavStream = fs.createWriteStream(wavFile);
|
|
137
|
+
writer.pipe(wavStream);
|
|
138
|
+
writer.write(pcmData);
|
|
139
|
+
writer.end();
|
|
140
|
+
|
|
141
|
+
wavStream.on('finish', () => {
|
|
142
|
+
const whisper = spawn(WHISPER_BIN, [
|
|
143
|
+
'-m', MODEL_PATH,
|
|
144
|
+
'-f', wavFile,
|
|
145
|
+
'--best-of', '5',
|
|
146
|
+
'--prompt', 'Air traffic control radio communication',
|
|
147
|
+
'--no-timestamps',
|
|
148
|
+
'--language', 'en'
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
let output = '';
|
|
152
|
+
whisper.stdout.on('data', (data) => output += data);
|
|
153
|
+
whisper.stderr.on('data', () => {
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
whisper.on('close', () => {
|
|
157
|
+
results.set(seq, output.trim());
|
|
158
|
+
fs.unlink(wavFile, () => {
|
|
159
|
+
});
|
|
160
|
+
activeCount--;
|
|
161
|
+
processNext();
|
|
162
|
+
flushOrdered();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function flushOrdered() {
|
|
168
|
+
while (results.has(nextToPrint)) {
|
|
169
|
+
const text = results.get(nextToPrint)!;
|
|
170
|
+
results.delete(nextToPrint);
|
|
171
|
+
nextToPrint++;
|
|
172
|
+
|
|
173
|
+
// Send raw text to the CLI layer
|
|
174
|
+
onTranscript(text);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// -----------------------------
|
|
180
|
+
// Audio helpers
|
|
181
|
+
// -----------------------------
|
|
182
|
+
|
|
183
|
+
function stereoToMono(stereo: Buffer): Buffer {
|
|
184
|
+
const mono = Buffer.alloc(stereo.length / 2);
|
|
185
|
+
for (let i = 0; i < mono.length / 2; i++) {
|
|
186
|
+
const left = stereo.readInt16LE(i * 4);
|
|
187
|
+
const right = stereo.readInt16LE(i * 4 + 2);
|
|
188
|
+
mono.writeInt16LE(Math.round((left + right) / 2), i * 2);
|
|
189
|
+
}
|
|
190
|
+
return mono;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resample48to16(pcm48k: Buffer): Buffer {
|
|
194
|
+
const samples = pcm48k.length / 2;
|
|
195
|
+
const pcm16k = Buffer.alloc(Math.floor(samples / 3) * 2);
|
|
196
|
+
|
|
197
|
+
for (let i = 0; i < pcm16k.length / 2; i++) {
|
|
198
|
+
const val = pcm48k.readInt16LE(i * 3 * 2);
|
|
199
|
+
pcm16k.writeInt16LE(val, i * 2);
|
|
200
|
+
}
|
|
201
|
+
return pcm16k;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function bufferToFloat32(buffer: Buffer): Float32Array {
|
|
205
|
+
const float32 = new Float32Array(buffer.length / 2);
|
|
206
|
+
for (let i = 0; i < float32.length; i++) {
|
|
207
|
+
const int16 = buffer.readInt16LE(i * 2);
|
|
208
|
+
float32[i] = int16 / 32768.0;
|
|
209
|
+
}
|
|
210
|
+
return float32;
|
|
211
|
+
}
|
package/src/cleaner.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
import {abbreviateFlightLevel} from './flightlevel.ts';
|
|
4
|
+
import {formatPhoneticWord} from './phonetic.ts';
|
|
5
|
+
import {detectAndNormalizeCallsign} from './callsigns.ts';
|
|
6
|
+
import {normalizeRunways} from './runway.ts';
|
|
7
|
+
import {normalizeHeadings} from './heading.ts';
|
|
8
|
+
import {normalizeSpeed} from "./speed.ts";
|
|
9
|
+
import {normalizeAltitude} from "./altitude.ts";
|
|
10
|
+
import {highlightKeywords} from './keywords.ts';
|
|
11
|
+
|
|
12
|
+
export function cleanTranscript(
|
|
13
|
+
text: string,
|
|
14
|
+
opts: {
|
|
15
|
+
callsigns: boolean;
|
|
16
|
+
phonetic: boolean;
|
|
17
|
+
fl: boolean;
|
|
18
|
+
numbers: boolean;
|
|
19
|
+
runways: boolean;
|
|
20
|
+
heading: boolean;
|
|
21
|
+
speed: boolean;
|
|
22
|
+
altitude: boolean;
|
|
23
|
+
keywords: boolean;
|
|
24
|
+
}
|
|
25
|
+
) {
|
|
26
|
+
let out = text.trim();
|
|
27
|
+
|
|
28
|
+
// Normalize whitespace early
|
|
29
|
+
out = out.replace(/\s+/g, ' ');
|
|
30
|
+
|
|
31
|
+
let normalizedCallsign: string | null = null;
|
|
32
|
+
|
|
33
|
+
if (opts.callsigns) {
|
|
34
|
+
normalizedCallsign = detectAndNormalizeCallsign(out);
|
|
35
|
+
if (normalizedCallsign) {
|
|
36
|
+
// Highlight callsign
|
|
37
|
+
const highlighted = chalk.green.bold(normalizedCallsign);
|
|
38
|
+
|
|
39
|
+
// Replace only the callsign portion
|
|
40
|
+
// (We assume callsign is at the beginning of the utterance)
|
|
41
|
+
const firstWords = out.split(' ').slice(0, normalizedCallsign.split(' ').length).join(' ');
|
|
42
|
+
out = out.replace(firstWords, highlighted);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (opts.numbers) {
|
|
47
|
+
out = numberWordsToDigits(out);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (opts.fl) {
|
|
51
|
+
out = abbreviateFlightLevel(out);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (opts.runways) {
|
|
55
|
+
out = normalizeRunways(out);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (opts.heading) {
|
|
59
|
+
out = normalizeHeadings(out);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (opts.speed) {
|
|
63
|
+
out = normalizeSpeed(out);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (opts.altitude) {
|
|
67
|
+
out = normalizeAltitude(out);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (opts.keywords) {
|
|
71
|
+
out = highlightKeywords(out);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (opts.phonetic) {
|
|
75
|
+
out = out.replace(
|
|
76
|
+
/\b(alpha|bravo|charlie|delta|echo|foxtrot|golf|hotel|india|juliet|kilo|lima|mike|november|oscar|papa|quebec|romeo|sierra|tango|uniform|victor|whiskey|xray|x-ray|yankee|zulu)\b/gi,
|
|
77
|
+
(match) => {
|
|
78
|
+
// If this phonetic word is part of the callsign, skip formatting
|
|
79
|
+
if (normalizedCallsign && normalizedCallsign.toLowerCase().includes(match.toLowerCase())) {
|
|
80
|
+
return match;
|
|
81
|
+
}
|
|
82
|
+
return formatPhoneticWord(match);
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
|
|
4
|
+
export function abbreviateFlightLevel(text: string): string {
|
|
5
|
+
return text.replace(/\bflight level ([a-zA-Z0-9\s-]+)\b/gi, (_, rawPart: string) => {
|
|
6
|
+
// Normalize number words first
|
|
7
|
+
const normalized = numberWordsToDigits(rawPart.toLowerCase()).replace(/-/g, ' ');
|
|
8
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
9
|
+
|
|
10
|
+
let fl: number | null = null;
|
|
11
|
+
|
|
12
|
+
// Case 1: spoken digits: "1 8 0" → 180
|
|
13
|
+
if (tokens.length >= 2 && tokens.length <= 3 && tokens.every(t => /^\d$/.test(t))) {
|
|
14
|
+
fl = parseInt(tokens.join(''), 10);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Case 2: "180"
|
|
18
|
+
if (fl === null && tokens.length === 1 && /^\d{2,3}$/.test(tokens[0])) {
|
|
19
|
+
fl = parseInt(tokens[0], 10);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Case 3: "two hundred" → 200
|
|
23
|
+
if (fl === null && tokens.length === 2 && /^\d+$/.test(tokens[0]) && tokens[1] === '100') {
|
|
24
|
+
fl = parseInt(tokens[0], 10) * 100;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback
|
|
28
|
+
if (fl === null || isNaN(fl)) {
|
|
29
|
+
return `flight level ${rawPart}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Apply color
|
|
33
|
+
return chalk.yellow.bold(`FL${fl}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
package/src/heading.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
|
|
4
|
+
export function normalizeHeadings(text: string): string {
|
|
5
|
+
return text.replace(
|
|
6
|
+
/\b(?:turn\s+(?:left|right)\s+)?(?:fly\s+)?heading\s+([a-zA-Z0-9\s-]+?)(?:\s+degrees?)?\b/gi,
|
|
7
|
+
(_, raw: string) => {
|
|
8
|
+
const normalized = numberWordsToDigits(raw.toLowerCase()).replace(/-/g, ' ');
|
|
9
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
10
|
+
|
|
11
|
+
if (tokens.length === 0) return `heading ${raw}`;
|
|
12
|
+
|
|
13
|
+
// Case 1: spoken digits
|
|
14
|
+
if (tokens.every(t => /^\d$/.test(t)) && tokens.length <= 3) {
|
|
15
|
+
const hdg = tokens.join('');
|
|
16
|
+
return chalk.magenta.bold(`HDG ${padHeading(hdg)}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Case 2: single number
|
|
20
|
+
if (tokens.length === 1 && /^\d{1,3}$/.test(tokens[0])) {
|
|
21
|
+
return chalk.magenta.bold(`HDG ${padHeading(tokens[0])}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Case 3: "two hundred"
|
|
25
|
+
if (tokens.length === 2 && /^\d+$/.test(tokens[0]) && tokens[1] === '100') {
|
|
26
|
+
const hdg = parseInt(tokens[0], 10) * 100;
|
|
27
|
+
return chalk.magenta.bold(`HDG ${padHeading(hdg.toString())}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return `heading ${raw}`;
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function padHeading(hdg: string): string {
|
|
36
|
+
const n = parseInt(hdg, 10);
|
|
37
|
+
if (isNaN(n)) return hdg;
|
|
38
|
+
return n.toString().padStart(3, '0');
|
|
39
|
+
}
|
package/src/keywords.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const keywordList = [
|
|
4
|
+
// Clearances
|
|
5
|
+
'cleared', 'clearance',
|
|
6
|
+
|
|
7
|
+
// Vertical
|
|
8
|
+
'climb', 'descend', 'maintain', 'level', 'altitude',
|
|
9
|
+
|
|
10
|
+
// Lateral
|
|
11
|
+
'turn', 'heading', 'direct', 'proceed',
|
|
12
|
+
|
|
13
|
+
// Ground
|
|
14
|
+
'taxi', 'hold short', 'cross', 'line up', 'runway',
|
|
15
|
+
|
|
16
|
+
// Comms
|
|
17
|
+
'contact', 'monitor', 'switch', 'frequency',
|
|
18
|
+
|
|
19
|
+
// Misc
|
|
20
|
+
'squawk', 'ident', 'report', 'traffic'
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function highlightKeywords(text: string): string {
|
|
24
|
+
let out = text;
|
|
25
|
+
|
|
26
|
+
for (const kw of keywordList) {
|
|
27
|
+
const regex = new RegExp(`\\b${kw}\\b`, 'gi');
|
|
28
|
+
out = out.replace(regex, match => chalk.hex('#FF8C00').bold(match));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return out;
|
|
32
|
+
}
|
package/src/numbers.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const map: Record<string, number> = {
|
|
2
|
+
zero: 0, one: 1, two: 2, three: 3, four: 4,
|
|
3
|
+
five: 5, six: 6, seven: 7, eight: 8, nine: 9,
|
|
4
|
+
ten: 10, eleven: 11, twelve: 12, thirteen: 13,
|
|
5
|
+
fourteen: 14, fifteen: 15, sixteen: 16,
|
|
6
|
+
seventeen: 17, eighteen: 18, nineteen: 19,
|
|
7
|
+
twenty: 20, thirty: 30, forty: 40, fifty: 50,
|
|
8
|
+
sixty: 60, seventy: 70, eighty: 80, ninety: 90,
|
|
9
|
+
hundred: 100, thousand: 1000
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function numberWordsToDigits(text: string): string {
|
|
13
|
+
return text.replace(
|
|
14
|
+
/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand)\b/gi,
|
|
15
|
+
(m) => map[m.toLowerCase()].toString()
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/phonetic.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export function formatPhoneticWord(word: string): string {
|
|
4
|
+
const idx = word.search(/[a-zA-Z]/);
|
|
5
|
+
if (idx === -1) return chalk.cyan.dim(word);
|
|
6
|
+
|
|
7
|
+
const before = word.slice(0, idx);
|
|
8
|
+
const first = word[idx].toUpperCase();
|
|
9
|
+
const rest = word.slice(idx + 1).toLowerCase();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
before +
|
|
13
|
+
chalk.cyan.bold(first) +
|
|
14
|
+
chalk.cyan.dim(rest)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function phoneticToLetter(word: string): string {
|
|
19
|
+
const map: Record<string, string> = {
|
|
20
|
+
alpha: 'A',
|
|
21
|
+
bravo: 'B',
|
|
22
|
+
charlie: 'C',
|
|
23
|
+
delta: 'D',
|
|
24
|
+
echo: 'E',
|
|
25
|
+
foxtrot: 'F',
|
|
26
|
+
golf: 'G',
|
|
27
|
+
hotel: 'H',
|
|
28
|
+
india: 'I',
|
|
29
|
+
juliet: 'J',
|
|
30
|
+
kilo: 'K',
|
|
31
|
+
lima: 'L',
|
|
32
|
+
mike: 'M',
|
|
33
|
+
november: 'N',
|
|
34
|
+
oscar: 'O',
|
|
35
|
+
papa: 'P',
|
|
36
|
+
quebec: 'Q',
|
|
37
|
+
romeo: 'R',
|
|
38
|
+
sierra: 'S',
|
|
39
|
+
tango: 'T',
|
|
40
|
+
uniform: 'U',
|
|
41
|
+
victor: 'V',
|
|
42
|
+
whiskey: 'W',
|
|
43
|
+
'xray': 'X',
|
|
44
|
+
'x-ray': 'X',
|
|
45
|
+
yankee: 'Y',
|
|
46
|
+
zulu: 'Z'
|
|
47
|
+
};
|
|
48
|
+
return map[word.toLowerCase()] ?? word;
|
|
49
|
+
}
|
package/src/runway.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
|
|
4
|
+
const sideMap: Record<string, string> = {
|
|
5
|
+
left: 'L',
|
|
6
|
+
right: 'R',
|
|
7
|
+
center: 'C',
|
|
8
|
+
centre: 'C'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function normalizeRunways(text: string): string {
|
|
12
|
+
return text.replace(/\brunway ([a-zA-Z0-9\s-]+)\b/gi, (_, raw: string) => {
|
|
13
|
+
const normalized = numberWordsToDigits(raw.toLowerCase()).replace(/-/g, ' ');
|
|
14
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
15
|
+
|
|
16
|
+
if (tokens.length === 0) return `runway ${raw}`;
|
|
17
|
+
|
|
18
|
+
const numTokens: string[] = [];
|
|
19
|
+
const restTokens: string[] = [];
|
|
20
|
+
|
|
21
|
+
for (const t of tokens) {
|
|
22
|
+
if (/^\d+$/.test(t) && numTokens.length < 2) {
|
|
23
|
+
numTokens.push(t);
|
|
24
|
+
} else {
|
|
25
|
+
restTokens.push(t);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (numTokens.length === 0) return `runway ${raw}`;
|
|
30
|
+
|
|
31
|
+
const runwayNumber = numTokens.join('');
|
|
32
|
+
const sideToken = restTokens[0];
|
|
33
|
+
const side = sideToken ? sideMap[sideToken] ?? '' : '';
|
|
34
|
+
|
|
35
|
+
return chalk.blue.bold(`RWY ${runwayNumber}${side}`);
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/speed.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {numberWordsToDigits} from './numbers.ts';
|
|
3
|
+
|
|
4
|
+
export function normalizeSpeed(text: string): string {
|
|
5
|
+
return text.replace(
|
|
6
|
+
/\b(?:reduce|increase|maintain|adjust)?\s*speed\s+(?:to\s+)?([a-zA-Z0-9\s-]+?)(?:\s+knots?|\s+kts?|\s+kt)?\b/gi,
|
|
7
|
+
(_, raw: string) => {
|
|
8
|
+
// Normalize number words first
|
|
9
|
+
const normalized = numberWordsToDigits(raw.toLowerCase()).replace(/-/g, ' ');
|
|
10
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
11
|
+
|
|
12
|
+
if (tokens.length === 0) return `speed ${raw}`;
|
|
13
|
+
|
|
14
|
+
// Case 1: spoken digits ("2 5 0")
|
|
15
|
+
if (tokens.every(t => /^\d$/.test(t)) && tokens.length <= 3) {
|
|
16
|
+
const spd = tokens.join('');
|
|
17
|
+
return chalk.redBright.bold(`SPD ${spd}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Case 2: single number ("250")
|
|
21
|
+
if (tokens.length === 1 && /^\d{1,3}$/.test(tokens[0])) {
|
|
22
|
+
return chalk.redBright.bold(`SPD ${tokens[0]}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Case 3: "one hundred eighty" → 180
|
|
26
|
+
if (
|
|
27
|
+
tokens.length === 2 &&
|
|
28
|
+
/^\d+$/.test(tokens[0]) &&
|
|
29
|
+
tokens[1] === '100'
|
|
30
|
+
) {
|
|
31
|
+
const spd = parseInt(tokens[0], 10) * 100;
|
|
32
|
+
return chalk.redBright.bold(`SPD ${spd}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fallback
|
|
36
|
+
return `speed ${raw}`;
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|