Help us shape the future of Go! Take the 2026 Go Developer Survey

murmur

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 10 Imported by: 0

README

murmur

Transfer data between nearby devices using sound.

Quick start

just install    # go install ./cmd/murmur
just lint       # golangci-lint run
murmur help

murmur bench sim            # the whole link through a simulated room, no hardware
murmur bench loopback       # the same through the audio device stack, no second laptop
Does it work here?

Check the software before pointing two laptops at each other:

murmur test                     # an encode/decode round trip inside this process

Every device murmur test knows carries the samples in-process, so it needs no sound server and no hardware - it proves the software works. To prove a room does, run the real pipeline from below between the two machines.

Profiles

Two profiles share one physical layer - the same 8 ms chirp preamble and the same 32-tone non-coherent alphabet - and differ only in symbol length, which sets both the band and the rate. There is no handshake to negotiate one, so the receiver must be told the same profile the sender used; on murmur decode that is one more --profile flag.

profile symbol band raw rate for
robust (default) 16 ms 1.2–3.1 kHz 313 bit/s a lively room: the long symbol is the reverb defence
fast 5 ms 1.2–7.4 kHz 1000 bit/s a quiet desk: about three times the rate, at the cost of the margin above 4 kHz that the equaliser-free detector cannot correct

murmur bench sim runs both through the simulated channel. Over 40 uncoded frames per preset, fast delivers 40/40 clean, 40/40 typical, 26/40 noisy and 9/40 hostile, against robust's 40/40, 40/40, 14/40 and 0/40, with no corrupt frame either way. A shorter burst spends its margin and crosses fewer dropouts; the simulator models the wide band as flat, so the real room may disagree.

Sending something

One-way: murmur encode and murmur decode. One burst after another, in the clear, with nothing sent back — not even an acknowledgement, so the sender never learns whether anyone heard it.

murmur decode is the inverse of murmur encode and never opens an audio device: raw 16-bit mono PCM at 48 kHz from standard input.

# on the receiver: capture the microphone and decode the resulting PCM
pw-record --raw --format=s16 --rate=48000 --channels=1 - | murmur decode

# on the receiver: decode a capture you already have
murmur decode < capture.raw

# on the sender, straight to a player
murmur encode key.txt | pw-play --raw --format=s16 --rate=48000 --channels=1 -
echo -n "correct horse" | murmur encode | pw-play --raw --format=s16 --rate=48000 --channels=1 -
printf '%s' "$TOKEN" | murmur encode --loudness whisper | pw-play --raw --format=s16 --rate=48000 --channels=1 -
printf '\x01' | murmur encode | pw-play --raw --format=s16 --rate=48000 --channels=1 -

# on the sender, to a file you play whenever you like
printf '\x01' | murmur encode --wav > key.wav
pw-play key.wav

What goes in. printf '%s' sends exactly the string; echo adds a trailing newline. With no file arguments murmur encode reads standard input, and a file argument reads that file; no name, mode or timestamp travels with the bytes. --wav needs a seekable output. --loudness is whisper (−30 dBFS), normal (−20, the default) or loud (−12, the hard ceiling). --profile picks the modulation and is robust (the default) or fast; both ends must name the same profile, because the receiver has to be listening for the right one. Every stream begins with 300 ms of silence so a freshly started player has time to wake.

What comes out. --one stops after the first payload. Bytes go to stdout and prose to stderr, so a redirect captures only the payload:

pw-record --raw --format=s16 --rate=48000 --channels=1 - | murmur decode > key.txt
murmur decode < capture.raw > go.mod           # a recording, decoded
pw-record --raw --format=s16 --rate=48000 --channels=1 - | murmur decode --one > byte.bin

Use as a Go library

The root package is the whole library: murmur.Payload in, murmur.Message out. murmur.Encode writes samples to any io.Writer and never plays them; murmur.Decode reads raw PCM from any io.Reader.

Receive. Feed a PCM stream to Decode and range over what arrives:

stream, err := murmur.Decode(os.Stdin) // `pw-record ... | murmur decode`
if err != nil {
	log.Fatal(err)
}
defer stream.Close()

for msg := range stream.Chan() {
	// The payload is the bytes themselves: write them wherever they go.
	os.Stdout.Write(msg.Data)
}
if err := stream.Err(); err != nil {
	log.Fatal(err)
}

Chan() closes at io.EOF or Close; only digest-checked payloads are delivered.

Send. Read the bytes, produce the sound, and write it wherever you like:

key, err := os.ReadFile("id_ed25519.pub")
if err != nil {
	log.Fatal(err)
}
err = murmur.Encode(os.Stdout, murmur.Payload{Data: key})
if err != nil {
	log.Fatal(err)
}

Raw 16-bit mono PCM at 48 kHz, or with a WAV header via WithWAV — which needs an io.WriteSeeker because the header states the length of what follows:

key, err := os.ReadFile("id_ed25519.pub")
if err != nil {
	log.Fatal(err)
}
f, err := os.Create("key.wav")
if err != nil {
	log.Fatal(err)
}
defer f.Close()
err = murmur.Encode(f, murmur.Payload{Data: key}, murmur.WithWAV())

Decode. The inverse, from any io.Reader: raw 16-bit mono PCM at 48 kHz, read until io.EOF, after which Chan closes:

stream, err := murmur.Decode(os.Stdin)
if err != nil {
	log.Fatal(err)
}
defer stream.Close()
for msg := range stream.Chan() {
	os.Stdout.Write(msg.Data)
}

Using PipeWire instead of stdin. Decode takes any io.Reader, so a program can put the microphone there itself instead of relying on a shell pipe. murmur has no sound-server code and no device for one: the seam is raw 16-bit mono PCM at 48 kHz, so the recorder is the caller's, and the easiest one to start is pw-record.

cmd := exec.Command("pw-record", "--raw", "--format=s16",
	"--rate=48000", "--channels=1", "-")
stdout, err := cmd.StdoutPipe()
if err != nil {
	log.Fatal(err)
}
if err := cmd.Start(); err != nil {
	log.Fatal(err)
}
defer cmd.Process.Kill()

stream, err := murmur.Decode(stdout) // as above: range over stream.Chan()

Name the input with --target=<node> to avoid the session manager's default, which on many laptops is an echo-cancelling virtual source:

cmd := exec.Command("pw-record", "--raw", "--format=s16",
	"--rate=48000", "--channels=1", "--target", node, "-")

Sending is the mirror image: hand the encoder's output to a player's standard input, or write a file with WithWAV and play it whenever you like. Anything that produces or consumes 48 kHz mono 16-bit PCM at a process boundary will do - pw-record and pw-play, another recorder, a saved capture, or a test harness.

Options. Decode and Encode share the options that matter to both; WithWAV applies only to Encode. A different modulation profile:

stream, err := murmur.Decode(os.Stdin, murmur.WithScheme(scheme))

Notes for problems that did not stop reception; the callback runs on the receiving goroutine, so keep it quick and do not block in it:

stream, err := murmur.Decode(os.Stdin, murmur.WithNotes(func(err error) {
	log.Printf("murmur: %v", err)
}))

Exported underneath for a different stack: pkg/modulation, pkg/link, pkg/mac, pkg/fec, pkg/audio.

Documentation

Overview

Package murmur is the library form of the murmur command: one-way acoustic transfer between two nearby machines.

The point of the library is the same as the point of the command line tool. Two laptops, a few metres apart, no cable, no network, no pairing ritual: one side calls Encode, the other feeds a PCM stream to Decode, and the payload crosses the room as sound. It is for keys, tokens and small configuration - a kilobyte takes about a minute.

There is one way to receive and one way to send, and neither speaks back:

// Receiver: decode whatever a recorder hands over. Opening the microphone is
// the recorder's job - `pw-record --raw --format=s16 --rate=48000 --channels=1
// - | murmur decode` - and everything here is a plain io.Reader of raw 16-bit
// mono PCM at 48 kHz.
stream, err := murmur.Decode(os.Stdin)
if err != nil {
	return err
}
defer stream.Close()
for msg := range stream.Chan() {
	os.Stdout.Write(msg.Data)
}

// Sender: produce the sound and play it wherever you like.
err := murmur.Encode(os.Stdout, murmur.Payload{Data: key})

Decode is strictly passive, and Encode only produces audio. That is what lets the sound be piped, saved, or replayed later, and it is why there is no acknowledgement anywhere in this package.

The pieces underneath are in pkg: the plain codec in pkg/link, the modulation in pkg/modulation, the wire format in pkg/mac, and the PCM seam - format, conversion, capture high-pass, and the in-process devices - in pkg/audio.

Index

Constants

View Source
const EncodeLeadInMS = 300

EncodeLeadInMS is the silence written before the first burst of every encoded stream.

It is not cosmetic. A player started fresh - `murmur encode | pw-play`, the documented way to use this - hands its first block to PipeWire while the sound card is still waking, and the card plays that block rotated. The rotation is not a click: on the machine this was written on the first block came back shifted by 828 ms, which is far longer than the 8 ms synchronisation chirp, so a burst that starts in that block is lost whole - preamble and all - and no receiver can find a preamble that was never played where it was listening. That is why a real transfer loses its first fragment while every later one arrives: by then the stream is warm. A lead-in several player blocks long means the wake lands on silence and the first burst plays intact.

Variables

This section is empty.

Functions

func Encode

func Encode(w io.Writer, p Payload, opts ...Option) error

Encode writes one payload to w as sound: the bytes of a complete plain message, ready to be played at a machine running 'pw-record ... | murmur decode'.

w, _ := os.Create("key.raw")
err := murmur.Encode(w, murmur.Payload{Data: key})

The output is raw 16-bit little-endian PCM at audio.DefaultSampleRate, which is what `pw-cat` and `aplay` take on standard input once they are told the format. WithWAV writes a header instead, so that the result is a file any player can open without being told anything. Nothing is transmitted here and nothing is listened for: this function produces audio, and the caller decides where to send it. That is what makes the plain path useful - the same bytes can be played once, played twice, or saved and played tomorrow.

Types

type Message

type Message struct {
	// Data is the payload, already checked against its digest.
	Data []byte
	// Time is when the message finished arriving.
	Time time.Time
}

Message is one payload that arrived.

type Option

type Option func(*options)

Option adjusts how a receiver or a sender opens the link.

func WithNotes

func WithNotes(f func(error)) Option

WithNotes receives problems that did not stop the receiver: a message that could not be reassembled, a burst that belonged to an encrypted session, a fragment that never arrived. They are for the user - the library still does no output of its own - and returning from the function must be quick, because it runs on the receiving goroutine.

func WithScheme

func WithScheme(s modulation.Scheme) Option

WithScheme overrides the modulation, which is how the command line applies its transmit level: the peak amplitude is a property of the scheme, so a loudness policy that did not reach the scheme would be a flag that changes nothing.

func WithWAV

func WithWAV() Option

WithWAV makes Encode write a RIFF/WAVE header instead of raw samples. It needs a seekable writer, and fails with audio.ErrNotSeekable if given a pipe.

type Payload

type Payload struct {
	// Data is the bytes.
	Data []byte
}

Payload is one thing to send.

type Stream

type Stream struct {
	// contains filtered or unexported fields
}

Stream is what Decode returns: a receiver reading samples from its source.

func Decode

func Decode(r io.Reader, opts ...Option) (*Stream, error)

Decode reads raw 16-bit mono PCM at 48 kHz from r and returns the messages it can decode. It is the inverse of Encode - sound in, bytes out - and it never opens an audio device, so it is the receiver for anything that can write PCM to a pipe or a file. Pair it with a recorder for a live microphone:

// pw-record --raw --format=s16 --rate=48000 --channels=1 - | murmur decode
stream, err := murmur.Decode(os.Stdin)

stream, err := murmur.Decode(os.Stdin)
if err != nil {
	return err
}
defer stream.Close()
for msg := range stream.Chan() {
	fmt.Println(len(msg.Data))
}

r is read until io.EOF, at which point Chan closes. The samples must already be 16-bit mono at 48 kHz; Decode does not resample, and a different rate fails silently rather than loudly.

func (*Stream) Chan

func (s *Stream) Chan() <-chan Message

Chan delivers arriving messages, oldest first. It is closed when the receiver stops, so `for msg := range s.Chan()` ends on Close or on a fatal error.

func (*Stream) Close

func (s *Stream) Close() error

Close stops receiving and releases the source. It is safe to call twice.

func (*Stream) Err

func (s *Stream) Err() error

Err returns why the receiver stopped, or nil if it was closed deliberately.

Directories

Path Synopsis
cmd
murmur command
Command murmur transfers data between nearby machines using sound.
Command murmur transfers data between nearby machines using sound.
internal
cli
Package cli implements the murmur command line.
Package cli implements the murmur command line.
pkg
audio
Package audio is the acoustic I/O seam for murmur.
Package audio is the acoustic I/O seam for murmur.
dsp
Package dsp holds the sample-level building blocks: transforms, filters, resampling, windows and detection.
Package dsp holds the sample-level building blocks: transforms, filters, resampling, windows and detection.
fec
Package fec provides the error-detection and error-correction primitives used inside a burst.
Package fec provides the error-detection and error-correction primitives used inside a burst.
link
Package link is murmur's one-way path: no handshake, no acknowledgement, no encryption.
Package link is murmur's one-way path: no handshake, no acknowledgement, no encryption.
mac
Package mac owns the bytes of a burst: the frame header, its encoding, and the integrity check that decides whether a burst is used or discarded.
Package mac owns the bytes of a burst: the frame header, its encoding, and the integrity check that decides whether a burst is used or discarded.
modulation
Package modulation turns frame bytes into a waveform, and a waveform back into frame bytes.
Package modulation turns frame bytes into a waveform, and a waveform back into frame bytes.
sim
Package sim models the acoustic channel for tests and for `murmur bench`.
Package sim models the acoustic channel for tests and for `murmur bench`.
transfer
Package transfer gives the acoustic link file semantics.
Package transfer gives the acoustic link file semantics.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL