GitHub - BrightSign-Playground/gowyoming

GitHub

7 min read Original article ↗

A pure-Go, cgo-free implementation of the Wyoming voice-assistant protocol: the wire framing, a typed event model, and both the client and server roles — plus a satellite composition for edge devices and example programs you can run today.

Disclaimer: This works for me — that's the entire guarantee. Built with AI in the loop, so check your own biases before you love it or hate it on principle. Use at your own risk, fork freely, and don't @ me when it explodes. (But do drop me a note if it helps — pay it forward.)

What problem this solves

Wyoming is the small peer-to-peer TCP protocol the Open Home Foundation (Home Assistant) uses to wire together voice-pipeline services: wake word, speech-to-text (ASR), text-to-speech (TTS), intent handling, and the microphone/speaker "satellite" edge role. It is newline-delimited JSON headers plus raw PCM audio on one socket — simple by design.

The only maintained reference library is Python. gowyoming gives Go programs a first-class way to speak Wyoming on both sides of every conversation, without reverse-engineering the wire format:

  • Client — connect to a Wyoming service, run the describe/info handshake, stream audio for ASR, receive audio from TTS.
  • Server — expose a Go ASR/TTS/wake engine as a Wyoming service.
  • Satellite — a mic + speaker edge endpoint (e.g. a small speaker like PineVoice) that a pipeline connects to and drives.

Prerequisites

  • Go 1.22 or newer (developed on 1.26).
  • No cgo, no external runtime dependencies.
  • The external-command audio backend optionally shells out to arecord/aplay (or any PCM-piping tool); not required for the file backend or the examples.

Quick start

Build everything and run the tests:

make build
make test        # go test -race ./...

Describe a live endpoint

wyoming-info connects to any Wyoming server and prints what it offers:

go run ./cmd/wyoming-info --uri tcp://127.0.0.1:10300

Capture what a device sends (record-server)

Point a real device (e.g. a PineVoice satellite) at this server; every utterance it streams is written to disk as a playable WAV, with a one-line summary so wrong gain or silence is obvious:

go run ./examples/record-server --uri tcp://0.0.0.0:10300 --out ./out
# utt-20260714-150405.wav  1.28s  16000Hz/16bit/1ch  peak=8123 rms=1580

Loopback testing framework

Two programs that together exercise the whole protocol path — handshake, run-pipeline, audio both directions — with no Python and no hardware:

make loopback     # runs the in-process integration test

# or as two real processes:
go run ./examples/loopback/satellite --uri tcp://127.0.0.1:10700 &
go run ./examples/loopback/server   --uri tcp://127.0.0.1:10700

Testing with a PineVoice

A PineVoice runs its factory firmware as a Wyoming satellite server on port 10700 — it listens, and a pipeline (Home Assistant, or a gowyoming driver) connects to it by IP. It does not connect out to an ASR endpoint, so you talk to it as a client. (This is why record-server, which listens, is not the tool for a PineVoice — see the note below.)

1. Find the device IP. Look in your router's client list, or rely on mDNS. The port is 10700.

2. Describe it — the simplest end-to-end check that gowyoming talks to the real hardware:

go run ./cmd/wyoming-info --uri tcp://<pinevoice-ip>:10700
# prints the satellite name and its advertised mic/snd formats

3. Capture its microphone to WAV with the driver-recorder. It connects, sends run-pipeline, and writes each utterance the device streams to a timestamped WAV in --out:

go run ./examples/satellite-record --uri tcp://<pinevoice-ip>:10700 --out ./out
# trigger the device (wake word or button); after it streams audio you'll see:
# utt-20260714-150405.wav  1.36s  16000Hz/16bit/1ch  34560 bytes

Then play a captured file back to confirm the device sent the audio you expect:

aplay ./out/utt-*.wav        # or open it in any audio player

Each utterance's summary prints peak and rms (out of a 32767 full scale), so you can see how hot the signal actually is.

When exactly the device streams depends on its firmware — many satellites stream only after local wake-word detection or a button press, so trigger it after the recorder connects. Stop the recorder with Ctrl-C; any in-progress utterance is still flushed to disk.

If the recording is too quiet

gowyoming records the device's PCM byte-for-byte, so a quiet file means the device is sending a low-level signal. In order of preference:

  1. Raise gain at the source. If the PineVoice exposes a microphone gain or volume-multiplier in its settings, increase that first — more input gain lifts the signal without amplifying the noise floor.
  2. Normalize on capture. Scale each utterance's peak up to near full scale:
    go run ./examples/satellite-record --uri tcp://<ip>:10700 --out ./out --normalize
  3. Apply a fixed digital gain when you want a specific multiplier:
    go run ./examples/satellite-record --uri tcp://<ip>:10700 --out ./out --gain 4

--gain/--normalize operate on 16-bit PCM and clip at full scale. They amplify noise along with speech, so they are a convenience for inspection, not a substitute for good input gain. The default (--gain 1.0, no normalize) captures the device's audio unchanged.

Note — record-server is the inverse topology. record-server listens and advertises ASR, for a client that connects out to an ASR endpoint and pushes audio. A PineVoice does not do that (it is itself a server), so use satellite-record for PineVoice. record-server remains useful for testing a client/mic service that streams to an ASR endpoint.

Configuration

The example binaries take flags, not config files:

Binary Flag Default Meaning
wyoming-info --uri tcp://127.0.0.1:10300 endpoint to describe
satellite-record --uri / --out / --gain / --normalize tcp://127.0.0.1:10700 / ./out / 1.0 / false satellite to connect to (e.g. a PineVoice) / WAV output dir / digital gain / peak-normalize
record-server --uri / --out tcp://0.0.0.0:10300 / ./out listen address / WAV output dir
loopback/satellite --uri / --audio tcp://0.0.0.0:10700 / fixture listen address / WAV to stream as mic
loopback/server --uri / --audio tcp://127.0.0.1:10700 / fixture satellite to drive / fixture to validate against

URIs are tcp://host:port or unix:///path/to/socket.

Connection topology

In the Home Assistant model the roles are inverted from intuition: the satellite runs the server and Home Assistant connects in and drives it.

flowchart LR
    subgraph Edge["Satellite host (edge)"]
        MIC[mic] --> SAT[Satellite<br/>listens :10700]
        SAT --> SND[speaker]
    end
    HA[Home Assistant<br/>pipeline driver] -->|connects in<br/>run-pipeline| SAT
    HA -->|client| ASR[ASR server]
    HA -->|client| TTS[TTS server]
Loading

Using the library

Client — describe and transcribe:

c, _ := client.Dial(ctx, "tcp://127.0.0.1:10300")
defer c.Close()
info, _ := c.Describe(ctx)
transcript, _ := c.Transcribe(ctx, nextChunk) // nextChunk yields wyoming.AudioChunk

Server — expose a Go ASR engine:

type myASR struct{}
func (myASR) Transcribe(ctx context.Context, audio <-chan wyoming.AudioChunk) (wyoming.Transcript, error) {
    // consume audio, return text
}
srv := server.New(server.NewASR(info, myASR{}))
srv.ListenAndServe(ctx, "tcp://0.0.0.0:10300")

Raw events (the hybrid model) — the canonical *wyoming.Event is always available; Decode gives a typed value when you want to switch:

switch e := must(wyoming.Decode(ev)).(type) {
case wyoming.Transcript: use(e.Text)
case wyoming.AudioChunk: play(e.Audio)
}

Project structure

wyoming/     core: framing (Event/Reader/Writer), typed events, Decode, Info
transport/   tcp:// and unix:// dial/listen (the only net-using package)
client/      client role: Dial, Describe, Synthesize, Transcribe
server/      server role: Handler, Server, NewASR/NewTTS domain helpers
satellite/   edge composition: mic Source + speaker Sink + pipeline glue
audio/       Source/Sink with WAV-file and external-command backends
cmd/         wyoming-info CLI
examples/    loopback framework, satellite-record (PineVoice capture), record-server, testdata

Design rationale lives in VISION.md; the decision log and API design are in docs/superpowers/specs.

Known limitations (v1)

  • No local wake word. satellite.Config.Wake is refused explicitly; wake is handled server-side. Local wake is a planned increment.
  • No TLS/auth. The protocol defines none; rely on network trust.
  • No version header. Real Wyoming peers include an optional version key in the event header; gowyoming omits it on write and ignores it on read. This does not affect interop (the field is optional for readers) but is noted for completeness.
  • No cgo audio backend. Real device I/O goes through the external-command backend (arecord/aplay) or your own Source/Sink.

License

MIT — see LICENSE.