Documentation
¶
Overview ¶
Package modulation turns frame bytes into a waveform, and a waveform back into frame bytes.
This is the only package that knows what a frequency, a symbol, or a sample count is. Everything above it works in bytes (pkg/mac) and sequence numbers (pkg/link), and everything below it works in untyped PCM.
murmur has exactly one physical layer - non-coherent MFSK, in two profiles that differ only in symbol length - and this seam is kept because it makes that PHY testable against a simulated channel without any framing or link state, not because a second one is planned. The rules live in one place: a tone count, a symbol length and a band, and nothing above this package may know about any of them.
Index ¶
- Constants
- Variables
- func ChirpEndPhase(durSamples int, lowHz, highHz float64, sampleRate int) float64
- func Correlate(x, ref []float32) []float32
- func DBFSFromPeak(amp float64) float64
- func DefaultSync(sampleRate int) []float32
- func DetectFirstSync(x, ref []float32, threshold float64, coarseStep int) (int, float64, bool)
- func DetectSync(x, ref []float32, threshold float64, coarseStep int) (int, float64, bool)
- func PeakFromDBFS(db float64) float64
- func SyncChirp(durSamples int, lowHz, highHz float64, sampleRate int, kind dsp.WindowKind) []float32
- func ZadoffChu(n, root int) []complex128
- type Mfsk
- func (s *Mfsk) Capacity(sampleCount int) int
- func (s *Mfsk) Decode(samples []float32, maxBytes int) ([]byte, Stats, error)
- func (s *Mfsk) DecodeSoft(samples []float32, maxBytes int) (SoftDecoded, error)
- func (s *Mfsk) Encode(frame []byte) ([]float32, error)
- func (s *Mfsk) Profile() Profile
- func (s *Mfsk) SampleCount(frameLen int) int
- type MfskOptions
- type Profile
- type Scheme
- type SoftDecoded
- type SoftDecoder
- type Stats
Constants ¶
const ( // ProfileRobust keeps the 16 ms symbol: the reverb-resistant choice, and the // default, because a room is the variable murmur does not control. ProfileRobust = "robust" // ProfileFast spends the reverb margin on airtime. The alphabet is unchanged // at 32 tones, so the symbol shortens from 16 ms to 5 ms and the band widens // from 1.2-3.1 kHz to 1.2-7.4 kHz, which is about three times the raw rate. // It is what `--profile fast` selects, and both ends must agree on it: the // preamble is identical, so a receiver listening for the other profile hears // a burst it cannot read and discards it, exactly as it discards noise. ProfileFast = "fast" )
Profile names. These are wire values: they appear in capability exchange and are compared as strings, so they must not be renamed casually.
const ( // SyncDurationMS is deliberately shorter than a symbol. Sync must be found // before any data can be timed, so its length is pure overhead: it has to be // long enough for a reliable correlation peak and no longer. SyncDurationMS = 8 // SyncLowHz and SyncHighHz bracket the audible working band, // inset slightly so that the chirp's spectral skirts stay inside the band // where laptop speakers and microphones are both well behaved. SyncLowHz = 1400 SyncHighHz = 4800 // SyncCoarseStep is the initial search grid, in samples. // // It has to be fine, and the reason is not obvious from the outside. A chirp's // matched-filter output does not have a single broad peak: because a time shift // against a chirp is also a frequency shift, the correlation oscillates as the // lag moves, with a period set by the sweep's centre frequency. Measured on a // real room recording of a whisper-level burst (8 ms chirp, 1400-4800 Hz), // the normalized score against lag, from the peak outwards, was // // 0: 0.64 +2: 0.34 +4: -0.18 +6: -0.59 +8: -0.61 +14: 0.48 // -2: 0.53 -4: 0.11 -6: -0.34 -8: -0.53 -16: 0.32 // // so the usable peak is about two samples wide and everything past that is a // null. A 16-sample grid therefore samples the correlation function almost // randomly: it finds the peak when the true offset happens to land near a grid // point - which is what a clean file in a test always does, and what a // hand-crafted capture starting exactly at the burst does - and reports 0.29 // instead of 0.64 when it does not, dropping a perfectly good burst below // SyncThreshold. That single mistake is enough to make a link fail in every // real room while passing every test. See // // The cost of a fine grid is small: the correlation is 384 multiply-adds per // lag, the caller has just spent seconds of captured audio, and a step of 2 // costs a few tens of milliseconds over a capture window. SyncCoarseStep = 2 // SyncThreshold is the normalized correlation required to consider a // candidate. See DetectSync for why it is this low. SyncThreshold = 0.35 )
SyncDefaults are the v0 sync parameters. They live here rather than in each PHY so that both ends of a link cannot disagree about the sync signal by accident, which would look exactly like a broken transmitter.
const ErasureMargin = 3.0
ErasureMargin is the winning-tone margin below which the demodulator calls a symbol an erasure. It is the line between "weak but probably right" and "unreliable", and it is exported because the block code above needs the same number the PHY counted with: two thresholds that drift apart turn a soft decoder into a coin toss.
const ReedSolomonRateHalf = "rs1/2"
ReedSolomonRateHalf is the FEC name the robust profile advertises: the shipped link protects the frame with a rate-1/2 Reed-Solomon code. It lives in this package because the profile is where a peer would read it, even though the codec that applies it is in pkg/link.
const (
SchemeMFSK = "mfsk"
)
Scheme names.
Variables ¶
var ( // ErrNoSync means no burst was found in the samples. Callers treat this as // an erasure, not an error condition: silence is the normal state of an idle // acoustic channel. ErrNoSync = errors.New("modulation: no sync found") // ErrShortBurst means the samples are shorter than the declared symbol // structure, so the burst was truncated. The frame is unrecoverable. ErrShortBurst = errors.New("modulation: burst too short") // ErrBadArgs means the caller asked for something structurally impossible, // such as an empty frame or a payload larger than one burst. ErrBadArgs = errors.New("modulation: invalid arguments") )
Functions ¶
func ChirpEndPhase ¶
ChirpEndPhase returns the phase accumulated by the chirp generator at the end of a durSamples sweep.
Callers continuing a burst after the preamble need this to stay phase-continuous. It is recomputed rather than returned by SyncChirp so that the generator's signature does not carry a value only one PHY cares about.
func Correlate ¶
Correlate returns the normalized sliding correlation of x against ref.
The normalisation is what makes a threshold portable: the raw dot product grows with both signal amplitude and reference length, so an absolute threshold would have to be retuned for every profile, volume setting and device. Dividing by the local window energy and the reference energy gives a value in [-1, 1] that means the same thing everywhere, and it also makes sync immune to the automatic gain control that laptop microphone paths apply without asking.
Cost is O(len(x) * len(ref)); callers with long buffers should use DetectSync, which searches on a coarse grid and only correlates finely near the best candidate.
func DBFSFromPeak ¶
DBFSFromPeak converts a linear amplitude into dBFS.
func DefaultSync ¶
DefaultSync returns the v0 sync waveform for the given sample rate.
func DetectFirstSync ¶
DetectFirstSync finds the earliest offset at which ref correlates above threshold, refined to single-sample resolution.
It exists because of what a streaming receiver does with the answer. Bursts arrive in order and a capture window is usually longer than a burst, so the buffer handed to a decoder often holds two or three of them. DetectSync answers "where does the sync score best", which for such a buffer is the strongest burst and not necessarily the first, and a receiver that then advances past the burst it just decoded - which is how it avoids decoding one burst twice - discards every earlier burst in the same buffer without looking at it. From outside, that is a message arriving with its beginning missing.
Callers that are asking "is the sync in this recording at all" can keep using DetectSync; callers that consume bursts one after another want this one.
func DetectSync ¶
DetectSync finds the offset of ref within x, searching on a coarse grid of coarseStep samples and then refining to single-sample resolution around the best candidate.
Returns the offset, the normalized score at that offset, and whether the score met threshold. A sensible threshold for a tapered chirp is 0.35: high enough that noise does not reach it, low enough that a heavily reverberated direct path still does.
func PeakFromDBFS ¶
PeakFromDBFS converts a negative dBFS level into a linear amplitude.
func SyncChirp ¶
func SyncChirp(durSamples int, lowHz, highHz float64, sampleRate int, kind dsp.WindowKind) []float32
SyncChirp returns a tapered linear chirp sweeping from lowHz to highHz over durSamples samples.
The taper is not decoration. A rectangular-envelope chirp has autocorrelation sidelobes around -13 dB, which is high enough that a multipath reflection can outscore the direct path. Tapering trades a slightly wider main lobe for sidelobes well below that, which is what makes a fixed threshold usable in a reverberant room.
func ZadoffChu ¶
func ZadoffChu(n, root int) []complex128
ZadoffChu returns a length-n Zadoff-Chu sequence with the given root.
n may be odd or even; the two cases use different definitions, and using the even formula for an odd length (or vice versa) silently produces a sequence with poor autocorrelation, so the branch is explicit.
Types ¶
type Mfsk ¶
type Mfsk struct {
// contains filtered or unexported fields
}
Mfsk is the v0 scheme.
func NewMfsk ¶
func NewMfsk(opts MfskOptions) (*Mfsk, error)
NewMfsk validates options and precomputes the tone bank and sync waveform.
func (*Mfsk) Decode ¶
Decode implements Scheme. It is the hard-decision entry point; a caller that wants the per-byte confidence for a block code should use DecodeSoft.
func (*Mfsk) DecodeSoft ¶
func (s *Mfsk) DecodeSoft(samples []float32, maxBytes int) (SoftDecoded, error)
DecodeSoft demodulates a burst and also returns how much each recovered byte can be trusted. confidence[i] is the ratio of the winning tone's energy to the mean of the losing tones over the symbols that formed byte i: large is clean, near ErasureMargin is marginal. It is a margin, not a probability, and it is deliberately scale-invariant so that microphone gain does not move it.
The confidence exists for one reason: a block code that is told where the errors probably are can repair twice as many of them. See fec.SoftCodec.
func (*Mfsk) Encode ¶
Encode implements Scheme. The waveform is generated with one continuous phase accumulator for the whole burst. That matters: restarting the phase at each symbol boundary would put a step discontinuity into the signal at 125 Hz, spreading energy across the entire band and - worse for a tool with a non-intrusiveness requirement - turning a tone sequence into a series of audible clicks. Continuous phase costs nothing, since non-coherent detection does not care about phase at all.
func (*Mfsk) SampleCount ¶
SampleCount implements Scheme.
type MfskOptions ¶
type MfskOptions struct {
// Name identifies the profile; see ProfileByName.
Name string
// Tones is the alphabet size and must be a power of two, so that an integer
// number of bits maps to a tone with no wasted symbols.
Tones int
// FirstHz is the lowest tone. It must sit above the region where laptop
// speakers roll off.
FirstHz float64
// SpacingHz is the tone spacing. It is overridden to exactly sampleRate /
// SymbolSamples so that orthogonality holds exactly; the requested value
// only sets the symbol length.
SpacingHz float64
// SymbolSamples is the symbol window length. Payload timing derives entirely
// from it, so it is validated against the tone spacing rather than trusted.
SymbolSamples int
// EdgeRampSamples is the fade applied to both ends of a burst, in samples.
// requires at least 5 ms; clicks are perceived as far louder
// than their energy implies, which is precisely the kind of noise the
// non-intrusiveness requirement exists to avoid.
EdgeRampSamples int
// Amplitude is the linear peak. caps the default at -20 dBFS and
// this is enforced rather than advised.
Amplitude float32
// SampleRate is the device rate.
SampleRate int
}
MfskOptions configures the MFSK modem: multiple frequency-shift keying with non-coherent detection.
Chosen as the v0 PHY for reasons that are about debugging rather than performance, because the first version of an acoustic modem needs to be inspectable:
- Non-coherent detection removes carrier phase recovery from the critical path. There is no phase-locked loop to mistune, and no cycle slips to explain. The decoder measures energy at N known frequencies and picks the largest.
- A burst can be diagnosed by looking at a spectrogram. The data is literally visible.
- Tone spacing equal to 1/T makes the tones exactly orthogonal over the symbol window, so there is no inter-symbol or inter-tone leakage to compensate for, and no equaliser is required to get the first transfer working.
The cost is rate. A 100 MB file over sound is a category error, and the CLI says so rather than pretending otherwise.
func MfskOptionsFor ¶
func MfskOptionsFor(name string, sampleRate int) (MfskOptions, error)
MfskOptionsFor returns the named profile's modulation options.
It exists so a caller can change one field - the transmit level, which is the peak amplitude - without either rebuilding the profile's parameters by hand or reaching around the profile abstraction. A loudness flag that never reached the scheme would be a flag that changes nothing, which is the kind of thing users learn to distrust.
type Profile ¶
type Profile struct {
Name string
Scheme string
Band [2]float64 // Hz, inclusive
BitsPerSymbol int
SymbolRate float64 // symbols per second
RawBitRate float64 // bits per second, before framing and FEC
FEC string
Interleave int // interleaver depth in codewords
PeakDBFS float64 // transmit peak level, negative
Notes string
}
Profile is the machine-readable description of a scheme. It carries the FEC and interleaving settings too, even though those are applied above this package, because a profile is what the two peers negotiate: agreeing on a modulation without agreeing on a code rate would be an agreement to fail.
func ProfileByName ¶
ProfileByName looks up a profile at the given sample rate.
func Profiles ¶
Profiles lists the modulation profiles murmur ships: robust MFSK and its fast, wide-band sibling. They share one physical layer - same preamble, same 32-tone non-coherent alphabet - and differ only in how much reverb margin is spent on airtime. It is a list because the wire format names a profile: the two ends have no way to negotiate which one is in use on a one-way link, so each side is told, and a name is how the two commands are told the same thing.
type Scheme ¶
type Scheme interface {
// Profile describes the scheme for capability exchange and rate control.
Profile() Profile
// SampleCount is the burst length, in samples, for a frame of frameLen bytes.
SampleCount(frameLen int) int
// Capacity is the largest frame that fits in sampleCount samples.
Capacity(sampleCount int) int
// Encode renders a complete burst: preamble, sync, data, ready for the
// transmit chain. frame is the output of mac.EncodeFrame.
Encode(frame []byte) ([]float32, error)
// Decode searches samples for a burst and returns at most maxBytes of frame
// bytes. maxBytes exists because the receiver cannot know the frame length
// before it has decoded the header, so the caller passes its buffer size and
// trims afterwards.
Decode(samples []float32, maxBytes int) ([]byte, Stats, error)
}
Scheme is one modulation and coding scheme: a complete, self-contained way of getting frame bytes on and off the air.
type SoftDecoded ¶
type SoftDecoded struct {
// Data is the recovered bytes.
Data []byte
// Confidence is the per-byte margin described on SoftDecoder, one entry per
// byte of Data.
Confidence []float64
// Stats describes how the burst was received.
Stats Stats
}
SoftDecoded is one soft demodulation: the recovered bytes, how much each can be trusted, and how the burst was received.
type SoftDecoder ¶
type SoftDecoder interface {
Scheme
DecodeSoft(samples []float32, maxBytes int) (SoftDecoded, error)
}
SoftDecoder is a Scheme that reports how much each recovered byte can be trusted. The confidence is in the same units as ErasureMargin: a ratio of the winning tone's energy to the noise proxy, so larger is better and the value is unaffected by microphone gain or transmit level.
It is optional. A caller that does not find it uses Decode, which is exactly the same demodulation with the confidence thrown away.
type Stats ¶
type Stats struct {
// SyncOffset is where the preamble was found, in samples from the start of
// the supplied buffer.
SyncOffset int
// SyncScore is the normalized correlation at that offset. It is the single
// best indicator of whether the channel is clean or marginal.
SyncScore float64
// Symbols is how many data symbols were demodulated.
Symbols int
// Erasures is how many of those were too weak to trust. A burst with many
// erasures may still pass CRC, but its arrival means the rate should drop.
Erasures int
// SNRDB is an estimate of the per-symbol signal-to-noise ratio. It is an
// indicator, not a calibrated measurement: it compares the winning tone's
// magnitude against the mean of the rest, which is a useful proxy and not a
// substitute for instrumentation.
SNRDB float64
// PeakDBFS is the peak sample level of the received burst. Comparing this
// against what the sender intended is how microphone AGC is detected.
PeakDBFS float64
}
Stats describes how a burst was actually received.
Every field exists because the rate controller needs it to decide what to do next, and because a user whose transfer is crawling deserves to be told why rather than just shown a progress bar that is not moving.
func (Stats) ErasureRatio ¶
ErasureRatio is the fraction of symbols that were unusable.