voice-goio docs
Design Spec (historic)
Note. Historic: the original design spec. It is kept for its reasoning and the acceptance criteria the README refers to (§4, §9). The repository layout and the version plan below are outdated (packages such as
speaker, voice packs andpiper.Installcame later). The README and the CHANGELOG are current.
0. Goal
voice-goio is a standalone Go library providing voice input (pilot → ATC) and voice output (ATC → pilot) for an existing Go application that controls traffic in MSFS 2020/2024. The app imports it as a versioned module; the library knows nothing about MSFS or SimConnect.
Hard constraints:
- No third-party services. Everything runs on the user’s PC, offline. A test with the network adapter disabled must pass.
- No external Go dependencies.
go.modhas zerorequirelines. Stdlib only. Native platform APIs viasyscall(Windows) or subprocesses.CGO_ENABLED=0for every build. - English only, but controllers must have varied accents (US regional, UK regional, Australian/NZ/Indian/South African, and non-native: German, Dutch, Polish, Czech, French, Spanish, Italian).
- Runtime target: Windows 10/11 x64 (MSFS is Windows-only). Development happens on macOS first; Windows-only code is cross-compiled from macOS on every commit so it never rots.
- Quality bar: match or exceed BeyondATC’s free “Basic” voice tier (~100 local neural voices with accents).
1. Repository layout
Module path: github.com/mrlm-net/voice-goio. Package name: voicegoio (hyphens are not legal in Go identifiers; the import path keeps the hyphen).
voice-goio/
├── go.mod # zero requires
├── voicegoio.go # public API: interfaces + types (§2)
├── normalise/ # text → spoken form (icao|faa); telephony.csv via go:embed
├── tts/
│ └── piper/ # sidecar process pool, JSON-lines stdin, PCM stdout
├── stt/
│ ├── sapi/ # //go:build windows — SAPI5 via syscall COM vtables (§4.4)
│ └── fake/ # all platforms: stdin text / WAV+expected-tags harness (§4.5)
├── audio/
│ ├── radio/ # DSP: biquads, soft clip, noise, squelch, profiles (§4.6)
│ ├── out.go # Player interface + device enumeration types
│ ├── out_windows.go # //go:build windows — winmm.dll waveOut via syscall, device selectable
│ ├── out_darwin.go # //go:build darwin — afplay subprocess, default device only (dev)
│ └── out_other.go # //go:build !windows && !darwin — writes WAV to a file
├── voices/ # manifest (voices.json), downloader (net/http), audit runner
├── grammar/atc.grxml # SRGS grammar (§4.4)
├── data/telephony.csv # ICAO 3-letter → telephony designator
├── internal/wav/ # WAV reader/writer
├── internal/jsonl/ # JSON-lines encoder for the piper sidecar
├── cmd/voicecheck/ # synth + recognise regression CLI (§6)
├── testdata/ # ATC test phrases, expected normalisations, expected recognition tags
├── SPEC.md # this file
├── README.md
└── .github/workflows/ci.yml # §7
Rules:
- Windows-only files carry
//go:build windows; macOS-only//go:build darwin. Every platform-specific file has a counterpart sogo build ./...succeeds on darwin, windows and linux. - Windows-only implementations are created as compiling stubs first (return
voicegoio.ErrNotImplemented) so the app can depend on the module before the Windows work is done. - No cgo anywhere. If a task appears to need it, stop and report — do not add it.
2. Public API (voicegoio.go)
package voicegoio
var ErrNotImplemented = errors.New("voicegoio: not implemented on this platform")
type Phraseology string
const (
ICAO Phraseology = "icao"
FAA Phraseology = "faa"
)
// Transmission is what the app sends to be spoken. Text uses the app's normal tokens
// (BAW123, FL350, 127.45, RWY 27L); the library normalises it.
type Transmission struct {
Frequency string // "127.450"
ControllerID string // "LKPR_TWR"
Phraseology Phraseology
Text string
}
// Callsign describes an aircraft on frequency. Spoken is filled by normalise if empty.
type Callsign struct {
ICAO string // "BAW123", "OK-ABC"
Spoken string // "Speedbird one two three"
}
type Recognition struct {
Text string // raw recognised text
Confidence float32 // 0..1
Tags map[string]string // semantic properties: intent, callsign, value, ...
}
type VoiceProfile struct {
Model string // "en_GB-vctk-medium"
SpeakerID int
LengthScale float32 // 0.80–0.95
NoiseScale float32 // 0.5–0.7
NoiseW float32 // 0.6–0.9
Radio string // radio profile name: tower|ground|approach|center|atis
}
type TTS interface {
// Synthesize returns 16-bit mono PCM at SampleRate(profile) for one transmission.
Synthesize(ctx context.Context, profile VoiceProfile, spokenText string) ([]int16, error)
SampleRate(profile VoiceProfile) int
Close() error
}
type STT interface {
SetCallsigns(cs []Callsign) error // rebuild dynamic callsign rule; call on every roster change
Start() error // PTT pressed
Stop() error // PTT released; result arrives on Results()
Results() <-chan Recognition
Close() error
}
type PlaybackEvent struct {
Frequency string
ControllerID string
Started bool // true = started, false = finished
}
// Player owns the output device and per-frequency queues.
type Player interface {
Devices() ([]Device, error)
SetDevice(id string) error
Play(t Transmission, pcm []int16, sampleRate int) error // queued per frequency
Events() <-chan PlaybackEvent
Close() error
}
3. Integration contract (app ↔ voice-goio)
| Direction | What | Shape |
|---|---|---|
| App → TTS | one ATC transmission | Transmission — raw tokens; library normalises, synthesises, applies radio chain, queues playback |
| App → STT | who is on frequency | []Callsign; resend on every roster change |
| App → STT | PTT state | Start() on press, Stop() on release |
| STT → App | one recognition per PTT cycle | Recognition{Tags: {intent, callsign, value}} or intent=say_again |
| Player → App | playback events | Started/Finished per transmission so the app holds the next call until the frequency is clear |
The app never sees audio, PCM, grammars or voice files. Text in, tags out.
4. Components
4.1 Normaliser (normalise/) — build first
Converts ATC message text into spoken form. Pure Go, unit-tested against testdata/normalise/*.txt.
| Input | ICAO output | FAA output |
|---|---|---|
127.45 |
one two seven decimal four five | one two seven point four five |
digits 3, 5, 9, 0 |
tree, fife, niner, zero | three, five, niner, zero |
FL350 |
flight level tree fife zero | flight level three five zero |
5000 ft |
fife thousand | five thousand |
HDG 270 |
heading two seven zero | heading two seven zero |
RWY 27L |
runway two seven left | runway two seven left |
QNH 1013 |
Q N H one zero one tree | altimeter two niner niner two (inHg conversion) |
SQK 4321 |
squawk four tree two one | squawk four three two one |
TWY A3 |
taxiway alpha tree | taxiway alpha three |
BAW123 |
Speedbird one two tree | Speedbird one two three |
OK-ABC (GA) |
oscar kilo alpha bravo charlie | oscar kilo alpha bravo charlie |
- Telephony table
data/telephony.csv(BAW,Speedbird;CSA,CSA;TVS,Skytravel; …) embedded viago:embed. Fallback: spell letters with NATO alphabet. - Never emit bare digits or abbreviations — TTS only sees words.
- Exposes
SpokenCallsign(icao string) string, used by STT to build the grammar.
4.2 TTS sidecar (tts/piper/)
-
Binary:
piperfromgithub.com/rhasspy/piperreleases —piper_windows_amd64.zipandpiper_macos_aarch64.tar.gz; both shipespeak-ng-data/. Library locates it viaPiperPathoption; defaultbin/piper/piper[.exe]next to the executable. -
Voices:
.onnx+.onnx.jsonpairs fromhuggingface.co/rhasspy/piper-voices. Stored under a user data dir (%LOCALAPPDATA%/voice-goio/voiceson Windows,~/Library/Application Support/voice-goio/voiceson macOS). Downloaded on first run (§4.3); never bundled. -
One long-lived process per model file (not per speaker).
--json-inputletsspeaker_idchange per line for multi-speaker models. Pool size default 4, LRU eviction. -
Launch:
piper --model <voice>.onnx --config <voice>.onnx.json --output-raw --json-input --sentence_silence 0stdin: one JSON object per line
{"text": "...", "speaker_id": 17}stdout: raw 16-bit signed LE mono PCM; sample rate from.onnx.json→audio.sample_rate(22050 formedium). -
Verify against the shipped binary’s
--helpwhetherlength_scale/noise_scale/noise_ware accepted per JSON line. If not: one process per (model, scale-set). -
Utterance framing: piper writes all samples for a line then waits. Detect end-of-utterance by a sentinel line synthesised after the real one (a fixed short word) and cut at the known sentinel sample count, or fall back to
--output_dirmode (one WAV per utterance, read on file completion). Correctness first, then optimise. -
Timeout 5 s per utterance; on failure kill, restart, retry once.
4.3 Voice pool (voices/)
Initial pool, all medium:
| Model | Speakers | Accent coverage |
|---|---|---|
en_GB-vctk-medium |
109 | UK regional, Irish, Scottish, Welsh, Australian, NZ, Indian, South African, Canadian |
en_US-libritts_r-medium |
904 | US variety — curate ~30 by listening |
en_US-l2arctic-medium |
24 | non-native: Arabic, Mandarin, Hindi, Korean, Spanish, Vietnamese |
en_US-arctic-medium |
18 | clean US / Canadian / Scottish / Indian |
en_GB-aru-medium, en_GB-semaine-medium |
12, 4 | more UK |
en_US-ryan, en_US-joe, en_US-lessac, en_US-hfc_male, en_US-hfc_female |
1 each | US anchors for busy sectors |
en_GB-alan, en_GB-alba, en_GB-northern_english_male, en_GB-southern_english_female |
1 each | UK anchors |
de_DE-thorsten, de_DE-mls, nl_NL-mls, pl_PL-mls, fr_FR-mls, cs_CZ-jirka, it_IT-riccardo, es_ES-mls |
1–20 each | non-native controllers via the swap trick |
Swap trick: copy the model’s .onnx.json, set "espeak": {"voice": "en-us"}, pass the copy as --config. Phonemes missing from phoneme_id_map are dropped (piper warns). The audit runner synthesises testdata/audit/clearance.txt per voice; a human listens once and marks pass/fail in voices.json. Never feed English through a non-English phonemizer.
voices.json:
{
"models": [
{
"name": "en_GB-vctk-medium",
"onnx": "en/en_GB/vctk/medium/en_GB-vctk-medium.onnx",
"config": "en/en_GB/vctk/medium/en_GB-vctk-medium.onnx.json",
"sha256": "...",
"sample_rate": 22050,
"license": "CC-BY-4.0",
"espeak_override": null,
"speakers": [
{"id": 42, "label": "p225", "accent": "en-GB-scottish", "quality": 4, "pass": true}
]
}
],
"regions": {
"LK": ["en-CZ", "en-GB", "en-DE"],
"K": ["en-US"],
"EG": ["en-GB", "en-IE"]
}
}
- Downloader:
net/httpfrom HuggingFace, sha256 verify, resumable, progress callback. Base URL configurable (mirror / LAN cache). Assign(icaoPrefix, controllerKind) VoiceProfilepicks by region weighting, avoids reusing a speaker within one airport, and is deterministic given a session seed.- Licensing:
licenserecorded per model from its MODEL_CARD. Build tag/flagcommercial=truefilters to CC-BY / public-domain voices.
4.4 STT — SAPI5 grammar mode (stt/sapi/, Windows only)
Stdlib only: syscall.NewLazyDLL("ole32.dll") for CoInitializeEx/CoCreateInstance, then direct COM vtable calls with syscall.SyscallN. Do not use IDispatch/automation and do not implement a COM event sink.
- Engine:
CLSID_SpInprocRecognizer→ISpRecognizer. InProc, not Shared (no WSR UI, owns the mic). - Prerequisite: Windows speech pack for
en-US/en-GB. Detect viaISpRecognizer::GetRecognizer/ token enumeration; return a typed error with the Settings path if missing. - Audio input: enumerate
SpObjectTokenCategoryAudioInput,ISpRecognizer::SetInput(token). - Context:
ISpRecognizer::CreateRecoContext→ISpRecoContext. Notification:ISpRecoContext::SetNotifyWin32Event, then a goroutine loopsWaitForNotifyEvent(timeout)→GetEvents→ forSPEI_RECOGNITIONtakeISpRecoResult:GetTextfor text,GetPhrase→SPPHRASEfor rule name, confidence and semantic properties (pPropertieslinked list →Tags).SPEI_FALSE_RECOGNITION→intent=say_again. - Grammar:
ISpRecoContext::CreateGrammar→ISpRecoGrammar. Static rules fromgrammar/atc.grxmlviaLoadCmdFromFile(..., SPLO_DYNAMIC). Root ruletransmission=callsign+ (readback|request|report|checkin), semantic<tag>properties forintent,callsign,value. - Dynamic rule
callsign:GetRule("callsign", 0, SPRAF_TopLevel|SPRAF_Dynamic, TRUE),ClearRule, oneAddWordTransitionper spoken callsign (with the ICAO code as the property value),Commit(0). Rebuild on everySetCallsigns. Restricting to aircraft on frequency is the main accuracy lever. - Numbers sub-rule accepts ICAO and FAA forms (
three|tree,five|fife,nine|niner,point|decimal). - Coverage v1: check-in; readbacks (altitude, heading, speed, frequency, squawk, runway, taxi route, clearance); requests (climb/descent, direct, approach change, pushback, taxi, takeoff, landing); reports (ready, established, runway vacated, going around);
say again,standby,roger,wilco. - PTT:
Start()→SetRuleState("transmission", SPRS_ACTIVE)andISpRecoContext::Resume;Stop()→SetRuleState(..., SPRS_INACTIVE)and wait up to 800 ms for the final result, else emitsay_again. - Confidence threshold configurable (default 0.5); below →
say_again. - Known risk: this is the deprecated Windows Speech Recognition engine (deprecated Dec 2023, still shipped in Windows 11, no removal date). It stays isolated behind
voicegoio.STT.
4.5 STT fake / harness (stt/fake/, all platforms)
Used on macOS for development and everywhere for regression:
fake.FromStdin()— each line typed is treated as a recognised transmission; tags derived by matching against the same grammar rules re-implemented as a tiny Go parser overatc.grxml(or a simplified table), sointent/callsign/valueextraction can be unit-tested without an engine.fake.FromScript(path)—testdata/stt/*.jsonllines{"text": "...", "tags": {...}};Start()/Stop()pops the next line.- The Windows
sapibackend is tested against the same script viacmd/voicecheckplaying the WAVs into the recognizer (ISpRecognizer::SetInputwith aSpFileStreamtoken), so both backends share one expected-tags corpus.
4.6 Radio chain (audio/radio/)
Pure Go on []float32, applied per transmission, parameterised by profile:
| Stage | Default | Notes |
|---|---|---|
| Band-pass | 300–3400 Hz, 2× 2nd-order Butterworth biquads | core “radio” sound |
| Presence EQ | +3 dB peak ~2 kHz, Q 1.0 | intelligibility |
| Soft clip | tanh(x*drive), drive 1.5–3.0 |
compression / overmodulation |
| Noise floor | white or pink, −45 dBFS, under speech only | |
| Squelch | 60–100 ms static burst + click at start and end | |
| Dropouts | 0–2 per transmission, 20–40 ms, center only |
low probability |
| Gain | normalise to −6 dBFS peak | |
| Resample | to device rate (48 kHz), linear or windowed-sinc | last |
Profiles tower, ground, approach, center, atis in radio.json.
4.7 Playback (audio/out_*.go)
| Platform | Implementation | Device selection |
|---|---|---|
| Windows | winmm.dll: waveOutGetNumDevs/waveOutGetDevCapsW for enumeration, waveOutOpen(deviceID), waveOutPrepareHeader, waveOutWrite, waveOutUnprepareHeader, waveOutClose; double-buffered |
yes — this is why users can route ATC to a headset |
| macOS (dev) | write WAV to temp, exec.Command("afplay", path) |
no — SetDevice returns nil and is documented as a no-op |
| other | write WAV to VOICEGOIO_OUT_DIR |
n/a |
Queue semantics: one transmission at a time per frequency; other frequencies are silent (the app decides which frequency is monitored). Emits PlaybackEvent.
5. Build order
Steps 1–5 on macOS; 6–7 on Windows.
normalise+ tests.tts/piper: process pool, JSON input, PCM reader; one voice → WAV.audio/radio+audio/out_darwin.go; A/B WAVs fortowervscenter.stt/fake+ tag parser +testdata/sttcorpus.voices: manifest, downloader, audit runner, region weighting,Assign. Windows stubs forstt/sapiandout_windows.go. Tagv0.1.0.- Windows:
out_windows.go(winmm), thenstt/sapibring-up: recognizer → static grammar → PTT → dynamic callsigns.cmd/voicecheckagainst a real mic and the WAV corpus. Tagv0.2.0. - App integration (outside this repo): consume
v0.2.0, settings UI (mic, output device, PTT key, phraseology, voices dir).
6. cmd/voicecheck
voicecheck synth --voices voices.json --text testdata/audit/clearance.txt --out ./wav # every pass=true voice × every radio profile
voicecheck audit --voices voices.json --model de_DE-thorsten-medium # swap-trick audit for one model
voicecheck recog --backend sapi|fake --script testdata/stt/readbacks.jsonl # prints tags + confidence, exit 1 on mismatch
voicecheck devices # list input/output devices
7. CI (.github/workflows/ci.yml)
- Matrix:
macos-latest,windows-latest,ubuntu-latest. - Every job:
CGO_ENABLED=0 go vet ./... && go test ./..., plus cross buildsGOOS=windows GOARCH=amd64,GOOS=darwin GOARCH=arm64,GOOS=linux GOARCH=amd64withCGO_ENABLED=0. - Guard: fail if
go.modcontains anyrequireline orgo list -deps ./...shows a non-stdlib package. - Piper binary and voices are not fetched in CI; sidecar tests use a fake piper (
testdata/fakepiper.gobuilt at test time) that echoes a known PCM pattern.
8. Development workflow (macOS first, Windows later)
go mod init github.com/mrlm-net/voice-goio; commit layout with all stubs compiling on darwin, windows, linux.- Work through build order 1–5 on macOS. Run
GOOS=windows CGO_ENABLED=0 go build ./...locally before each push. - Tag
v0.1.0. In the app:go get github.com/mrlm-net/[email protected]. While both change at once usereplace github.com/mrlm-net/voice-goio => ../voice-goioin the app’sgo.modor ago.work; remove before release. - Private repo →
GOPRIVATE=github.com/mrlm-neton both machines. - On Windows: build order 6, tag
v0.2.0, bump in app. - Versioning: semver; API in
voicegoio.gois the compatibility surface. Breaking changes →v2module path.
9. Acceptance
- Network disabled: full flow works on Windows (PTT → recognition → ATC reply → audio).
go.modhas zero requires;CGO_ENABLED=0 go build ./...passes for darwin/arm64, windows/amd64, linux/amd64.- TTS: text → first audio sample < 300 ms with a warm process; ≥ 100
pass=truevoices; ≥ 10 distinct accents. - STT: ≥ 95 % correct intent + callsign on the scripted corpus (20 phrases × 3 speakers, 5 callsigns on frequency); off-grammar input yields
say_again. - Radio profiles audibly distinct; speech intelligible through
center. - Windows output device selection works; macOS documented as default-device only.
- Normaliser tests cover every row in §4.1.
10. Open decisions (owner: Martin)
- Distribution: personal vs. shipped → decides
commercial=truelicence filtering. - Later upgrade path: in-process
sherpa-onnx-c-api.dllloaded viasyscall.NewLazyDLL(still no cgo) for Kokoro voices and no process pool; macOS STT via a SwiftSFSpeechRecognizersidecar if ever needed.