// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of // mono PCM samples into decoded text. // // This is the second generation of the decoder. The first one worked on clean // machine keying but fell apart on real signals; every stage below exists to // fix a specific failure of that version (and of naïve Goertzel decoders in // general): // // audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with // separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─ // DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─ // element / character / word segmentation ─ Morse table ─ text // // The fixes that matter, in order of impact: // // 1. Debounced transitions BOTH ways. The old decoder rejected too-short // marks but not too-short SPACES, so a one-hop fade inside a dah (QSB, // static crash) split it into two dits — the single biggest source of // garbage on real signals. Here a state flip must persist for a glitch // time (~0.3 dit) before it is committed; shorter flips are folded back // into the surrounding element. // // 2. Two-cluster timing. Dit and dah lengths are tracked as two separate // moving centres with the decision boundary at their geometric mean, // instead of one EMA "dot length" that both classifies marks and is // updated by that same classification (a feedback loop that spiralled to // "all dits at 60 WPM" the moment it started mis-classifying). The // inter-element gaps also feed the dit centre — spaces are timing // evidence too, and hand keying is often more regular in its gaps than // in its dits. // // 3. dB-domain envelope. Peak and noise floor are tracked in dB with // asymmetric attack/decay, the slicer runs at 55%/38% of the span with // hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from // keying at all. In the old linear-magnitude scheme weak signals lived // in the bottom few percent of the scale and QSB swallowed them. // // 4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong // tone doesn't bleed across the whole bank and corrupt both the noise // estimate and the lock choice. // // Kept from the first version because they were right: the pitch LOCK (decode // one tone, ignore QRM at other pitches), pitch targeting (follow the radio's // known CW pitch instead of searching — SetTarget), tiered acquisition // (strong signals lock on the first hop so their opening dit isn't eaten), // the floor frozen during key-down (a rising floor mid-dah fragments it), and // the end-of-over flush when the lock releases. // // Deliberately dependency-free and fed by plain []int16 so the whole pipeline // is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at // several speeds, added noise, QSB fading, QRM on a nearby pitch, and a // noise-only squelch test). // // Honest expectations: on clean or moderately noisy signals this decodes // solidly; very weak signals in heavy QRM remain hard for any envelope // decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic // sequence estimation on top. This stage is designed so such a layer could be // added later without touching the DSP. package cwdecode import ( "math" "sort" "sync/atomic" ) // Status is a periodic snapshot for the UI (pitch lock, speed, signal). type Status struct { WPM int `json:"wpm"` Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked) Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter Active bool `json:"active"` // a tone is currently keyed down } // Decoder consumes PCM and emits decoded characters via onChar (one or more // characters at a time, including " " for word gaps) and periodic onStatus. // Process must be called from a single goroutine; SetTarget is safe to call // concurrently. type Decoder struct { fs int hop int // samples between analyses (~6 ms) win int // Goertzel window length (~20 ms) hopMs float64 // hop duration in ms biasMs float64 // envelope widening caused by the analysis window (see below) window []float64 // Hamming window, len win ring []float64 // circular raw-sample buffer, len win rpos int // next write position in ring filled int // samples written so far (until >= win) acc int // samples since last analysis ws []float64 // scratch: windowed samples for this hop // Search bank (only run while unlocked / untargeted). freqs []float64 coeffs []float64 mags []float64 // dB per bin nbuf []float64 // scratch for the median // Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch // and ignore everything else (e.g. follow the radio's CW pitch). Set live // from another goroutine, so it's atomic. targetHz atomic.Int32 targetFor float64 // freq the cached target coeff was computed for targetCoeff float64 // Pitch lock. lockIdx int // bin index while auto-locked; -1 = unlocked candIdx int candHops int quietHops int // consecutive key-up hops while locked (drives release) bankTick int // hops since the bank last ran while locked betterHops int // evidence that a different bin is the real signal // Envelope (dB domain) on the locked/target tone. floorDB, peakDB float64 bankNoiseDB float64 // broadband reference: EMA of the bank median haveBankNoise bool envSeeded bool rawKey bool // slicer output this hop // Debounced mark/space state machine. key bool // committed state (true = mark) stableHops int // hops in the committed state pendHops int // consecutive hops the raw state has disagreed // Two-cluster element timing (ms). muDit, muDah float64 marksSeen int // Character assembly. Element DURATIONS are stored and only classified // into dits/dahs when the character is flushed: by then the character's // own marks are all known, and a bimodal batch carries its own dit/dah // boundary — so even the very first character of an over decodes // correctly at any speed, before the global clusters have converged. elemMs []float64 charEmitted bool wordEmitted bool textSince bool // something was decoded since lock (guards leading spaces) lastPitch float64 lastRMS float64 statusEvery int sinceStatus int onChar func(string) onStatus func(Status) } var morse = map[string]byte{ ".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F', "--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L', "--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R', "...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X', "-.--": 'Y', "--..": 'Z', "-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4', ".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9', ".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=', ".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@', } // Tunables (hops are ~6 ms). const ( minDitMs = 20.0 // 60 WPM ceiling maxDitMs = 240.0 // 5 WPM floor seedDit = 60.0 // 20 WPM seed before any marks are seen acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit) acqWeakDB = 8.5 // lock after sustained hops above this SNR // Successive analysis windows overlap ~70%, so consecutive hops are highly // correlated — a noise spike easily "persists" 2–3 hops. Requiring ~2 full // window lengths of persistence makes a false noise lock genuinely rare. acqWeakHops = 6 squelchDB = 6.0 // minimum peak-floor span to key at all // The span alone can't reject pure noise: a noise bin's dB level swings // ±5–6 dB, which the peak/floor trackers happily turn into a keyable // span. The second squelch is ABSOLUTE: the envelope peak must stand well // above the broadband noise reference (the bank median) — a keyed tone // does, noise never sustainably does. snrSquelchDB = 9.5 // The slicer's span is CAPPED: with a very quiet background (high SNR, or // digitally silent test signals) the raw floor sits so far below the peak // that the off-threshold lands in the analysis window's skirts — every // mark then stretches by almost a full window and every gap shrinks, // until character gaps fall below the segmentation threshold and letters // merge. Capping the usable span keeps the slicer crossing near the // signal edges regardless of how quiet the background is. spanCapDB = 30.0 onFrac = 0.55 // slicer thresholds as a fraction of the (capped) span… offFrac = 0.38 // …with hysteresis charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists) wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7) ) // New builds a decoder for the given sample rate. onChar receives decoded text // incrementally; onStatus receives ~10 snapshots/second. Either may be nil. func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder { if sampleRate <= 0 { sampleRate = 16000 } d := &Decoder{ fs: sampleRate, hop: sampleRate * 5 / 1000, // 5 ms — resolves dits up to ~50 WPM win: sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip) lockIdx: -1, candIdx: -1, muDit: seedDit, muDah: 3 * seedDit, onChar: onChar, onStatus: onStatus, } if d.hop < 1 { d.hop = 1 } if d.win < 4*d.hop { d.win = 4 * d.hop } d.hopMs = float64(d.hop) / float64(d.fs) * 1000 // Even with the span cap, the analysis window widens every mark and // narrows every gap by roughly half a window on each edge (the tone leaks // into windows that straddle an edge). Durations are de-biased by this // constant so the timing clusters and gap thresholds see true lengths. d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000 d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz d.ring = make([]float64, d.win) d.ws = make([]float64, d.win) // Hamming window: −43 dB sidelobes keep a strong tone from bleeding across // the bank (rectangular Goertzel leaks at −13 dB, enough to fool the lock). d.window = make([]float64, d.win) for i := range d.window { d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1)) } // Candidate CW tones: 400–1000 Hz every 30 Hz. Deliberately NOT lower: // low-frequency hum/rumble rises toward DC and would win the argmax. for f := 400.0; f <= 1000.0; f += 30 { d.freqs = append(d.freqs, f) d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs))) } d.mags = make([]float64, len(d.freqs)) d.nbuf = make([]float64, len(d.freqs)) return d } // SetTarget fixes the decode pitch to hz (an exact-frequency detector, not the // nearest search bin), or returns to auto-search when hz <= 0. Safe to call // concurrently. func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) } // Reset clears decode state (e.g. when the user re-arms the decoder). func (d *Decoder) Reset() { d.rpos, d.filled, d.acc = 0, 0, 0 d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0 d.envSeeded, d.rawKey, d.key = false, false, false d.haveBankNoise = false d.stableHops, d.pendHops = 0, 0 d.bankTick, d.betterHops = 0, 0 d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0 d.elemMs = d.elemMs[:0] d.charEmitted, d.wordEmitted, d.textSince = true, true, false } // Process feeds a block of mono samples through the decoder. func (d *Decoder) Process(samples []int16) { for _, s := range samples { d.ring[d.rpos] = float64(s) d.rpos++ if d.rpos == d.win { d.rpos = 0 } if d.filled < d.win { d.filled++ } d.acc++ if d.acc >= d.hop && d.filled >= d.win { d.acc = 0 d.hopStep() } } } // goertzelDB returns the tone power (dB, arbitrary reference) of the windowed // scratch buffer at the detector coefficient c. func (d *Decoder) goertzelDB(c float64) float64 { var s1, s2 float64 for _, x := range d.ws { s0 := x + c*s1 - s2 s2 = s1 s1 = s0 } p := s1*s1 + s2*s2 - c*s1*s2 if p < 1e-12 { p = 1e-12 } return 10 * math.Log10(p) } // hopStep runs one analysis hop: tone detection, envelope, slicer, timing. func (d *Decoder) hopStep() { // Materialize the window (oldest→newest; order is irrelevant for power) // and the RMS level for the UI meter. var sumSq float64 for i := 0; i < d.win; i++ { x := d.ring[(d.rpos+i)%d.win] d.ws[i] = x * d.window[i] sumSq += x * x } d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4) toneDB, haveTone := 0.0, false if th := float64(d.targetHz.Load()); th > 0 { // Fixed pitch: one exact-frequency detector, like a skimmer channel. if th != d.targetFor { d.targetFor = th d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs)) d.envSeeded = false // re-seed the envelope for the new channel } toneDB = d.goertzelDB(d.targetCoeff) d.lastPitch = th d.lockIdx = -1 // targeting supersedes the auto lock haveTone = true // Refresh the broadband noise reference for the absolute squelch. d.bankTick++ if d.bankTick >= 8 { d.bankTick = 0 for i, c := range d.coeffs { d.mags[i] = d.goertzelDB(c) } copy(d.nbuf, d.mags) sort.Float64s(d.nbuf) d.updateBankNoise(d.nbuf[len(d.nbuf)/2]) } } else if d.lockIdx >= 0 { // Auto-locked: only the locked bin is needed per hop. toneDB = d.goertzelDB(d.coeffs[d.lockIdx]) d.lastPitch = d.freqs[d.lockIdx] haveTone = true // Supervised re-lock: periodically sweep the whole bank anyway. If a // clearly stronger tone lives on a DIFFERENT pitch and keeps doing so, // the current lock is wrong (locked onto noise, or the operator moved) // — jump to the real signal instead of decoding garbage until the // quiet-release finally fires. d.bankTick++ if d.bankTick >= 8 { d.bankTick = 0 bestIdx, bestDB := -1, math.Inf(-1) for i, c := range d.coeffs { m := d.goertzelDB(c) d.mags[i] = m if i >= d.lockIdx-1 && i <= d.lockIdx+1 { continue } if m > bestDB { bestDB, bestIdx = m, i } } copy(d.nbuf, d.mags) sort.Float64s(d.nbuf) d.updateBankNoise(d.nbuf[len(d.nbuf)/2]) if bestIdx >= 0 && bestDB > toneDB+6 { d.betterHops += 2 } else if d.betterHops > 0 { d.betterHops-- } if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s d.flushPending() copy(d.nbuf, d.mags) sort.Float64s(d.nbuf) d.lockIdx = bestIdx // Seed the envelope honestly: floor from the bank median, NOT // peak−cap — fabricating a full span would let a noise re-lock // key freely until the trackers converged. d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB d.quietHops, d.bankTick, d.betterHops = 0, 0, 0 d.marksSeen = 0 d.elemMs = d.elemMs[:0] d.charEmitted, d.wordEmitted, d.textSince = true, true, false d.key, d.stableHops, d.pendHops = false, 0, 0 toneDB = d.goertzelDB(d.coeffs[d.lockIdx]) d.lastPitch = d.freqs[d.lockIdx] } } } else { // Unlocked: run the whole bank, estimate noise, hunt for a tone. maxIdx, maxDB := 0, math.Inf(-1) for i, c := range d.coeffs { m := d.goertzelDB(c) d.mags[i] = m if m > maxDB { maxDB, maxIdx = m, i } } copy(d.nbuf, d.mags) sort.Float64s(d.nbuf) noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones d.updateBankNoise(noise) snr := maxDB - noise near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1 if near { d.candHops++ } else { d.candIdx, d.candHops = maxIdx, 1 } // Tiered acquisition: a clearly strong tone locks on the FIRST hop (so // its opening dit isn't eaten), a marginal one must persist a few hops // (so we don't lock onto a noise spike). if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) { d.lockIdx = maxIdx d.floorDB, d.peakDB = noise, maxDB d.envSeeded = true d.quietHops = 0 d.bankTick, d.betterHops = 0, 0 d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed) d.elemMs = d.elemMs[:0] d.charEmitted, d.wordEmitted, d.textSince = true, true, false d.key, d.stableHops, d.pendHops = false, 0, 0 toneDB = maxDB d.lastPitch = d.freqs[maxIdx] haveTone = true } else { d.lastPitch = 0 } } if !haveTone { d.rawKey = false d.emitStatus() return } if !d.envSeeded { // First hop on a targeted channel: seed from the current reading. d.floorDB, d.peakDB = toneDB, toneDB+squelchDB d.envSeeded = true } // ---- Envelope: separate floor / peak trackers, dB domain. ---- // Floor drops fast, but only RISES while keyed up: a floor creeping up // under a long dah shrinks the span until the dah fragments into dits. if toneDB < d.floorDB { d.floorDB += (toneDB - d.floorDB) * 0.3 } else if !d.rawKey { d.floorDB += (toneDB - d.floorDB) * 0.015 } // Peak attacks fast and decays slowly toward current conditions (~1.5 s), // so QSB is followed without collapsing across ordinary word gaps. if toneDB > d.peakDB { d.peakDB += (toneDB - d.peakDB) * 0.4 } else { d.peakDB += (toneDB - d.peakDB) * 0.004 } if d.peakDB < d.floorDB { d.peakDB = d.floorDB } // ---- Slicer with hysteresis + SNR squelch. ---- if d.peakDB-d.floorDB < squelchDB || (d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) { d.rawKey = false } else { // Cap the usable span so the slicer crossings stay near the keying // edges even over a dead-quiet background (see spanCapDB). effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB) span := d.peakDB - effFloor if d.rawKey { d.rawKey = toneDB > effFloor+offFrac*span } else { d.rawKey = toneDB > effFloor+onFrac*span } } // ---- Auto-lock release after a long quiet spell. ---- if d.lockIdx >= 0 { if d.rawKey { d.quietHops = 0 } else { d.quietHops++ // Long enough to survive slow-speed word gaps (7 dits), short // enough to retune to a new signal within a few seconds. release := int(math.Max(2000, 12*d.muDit) / d.hopMs) if d.quietHops > release { d.flushPending() d.lockIdx, d.candIdx, d.candHops = -1, -1, 0 d.rawKey = false d.lastPitch = 0 } } } d.timing() d.emitStatus() } // updateBankNoise folds a fresh bank-median reading into the broadband noise // reference used by the absolute squelch. func (d *Decoder) updateBankNoise(medianDB float64) { if !d.haveBankNoise { d.bankNoiseDB, d.haveBankNoise = medianDB, true return } d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2 } // glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops // (6–19 ms) so it neither swallows fast dits nor passes static crashes. func (d *Decoder) glitchHops() int { g := int(math.Round(0.3 * d.muDit / d.hopMs)) if g < 1 { g = 1 } if g > 3 { g = 3 } return g } // timing runs the debounced mark/space state machine for one hop. func (d *Decoder) timing() { if d.rawKey == d.key { // Agreement folds any pending flip back into the committed state: // a sub-glitch dropout inside a dah (or spike inside a gap) vanishes. d.stableHops += 1 + d.pendHops d.pendHops = 0 } else { d.pendHops++ if d.pendHops > d.glitchHops() { seg := d.stableHops wasMark := d.key d.key = d.rawKey d.stableHops = d.pendHops d.pendHops = 0 if wasMark { d.endMark(seg) } else { d.endSpace(seg) } if d.key { d.charEmitted, d.wordEmitted = false, false } } } if !d.key { d.spaceProgress() } } // endMark stores a finished key-down run for the character batch. Cluster // updates deliberately do NOT happen here: attributing a mark to the dit or // dah centre with the still-converging global boundary poisons the clusters // (a dah-led character at an unexpected speed lands its dahs in the dit // centre, which then oscillates). Both classification AND cluster updates // happen per character in flushChar, where the batch's own contrast makes // the attribution reliable. func (d *Decoder) endMark(hops int) { ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening if ms < 0.5*minDitMs { return // debounce residue — not a credible element } d.elemMs = append(d.elemMs, ms) // Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of // a faster sender — jump the estimate straight there instead of easing in. if d.marksSeen == 0 && ms < d.muDit { d.muDit = math.Max(ms, minDitMs) } d.marksSeen++ if len(d.elemMs) > 8 { d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run } } // endSpace runs when a mark begins: the finished gap, if it was clearly an // inter-element one, is extra evidence for the dit length (gaps are often // steadier than dits in hand keying). func (d *Decoder) endSpace(hops int) { ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit { return } // Early on, gaps are the FASTEST way to find the true dit length (the very // first inter-element gap is one, whatever the first mark was); once the // clusters have settled they are just a gentle refinement. alpha := 0.08 if d.marksSeen < 8 { alpha = 0.35 } d.muDit += (ms - d.muDit) * alpha d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs) } // spaceProgress emits the pending character / word space LIVE once the current // gap crosses each boundary (instead of waiting for the next mark), so text // appears as it is sent. func (d *Decoder) spaceProgress() { gapMs := float64(d.stableHops)*d.hopMs + d.biasMs if !d.charEmitted && gapMs > charGapDits*d.muDit { d.flushChar() d.charEmitted = true } if !d.wordEmitted && gapMs > wordGapDits*d.muDit { if d.textSince && d.onChar != nil { d.onChar(" ") } d.wordEmitted = true } } // flushChar classifies the accumulated mark durations into dits/dahs and // emits the character. Classification happens HERE, not as marks arrive: a // character whose own marks are clearly bimodal carries its own dit/dah // boundary (their geometric mean), which decodes correctly even before the // global clusters have converged — the first character of an over included. // Unimodal characters (EEE, TTT, 555…) fall back to the global boundary. func (d *Decoder) flushChar() { if len(d.elemMs) == 0 { return } lo, hi := d.elemMs[0], d.elemMs[0] for _, v := range d.elemMs[1:] { lo = math.Min(lo, v) hi = math.Max(hi, v) } b := math.Sqrt(d.muDit * d.muDah) if hi >= 2*lo { b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast } alpha := 0.18 if d.marksSeen <= 8 { alpha = 0.45 // converge fast on a new sender } pat := make([]byte, len(d.elemMs)) for i, v := range d.elemMs { if v < b { pat[i] = '.' d.muDit += (v - d.muDit) * alpha } else { pat[i] = '-' d.muDah += (v - d.muDah) * alpha } } // Ratio guards: dah stays 2–4.8 dits; dit stays within speed limits. d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs) if d.muDah < 2.0*d.muDit { d.muDah = 2.0 * d.muDit } if d.muDah > 4.8*d.muDit { d.muDah = 4.8 * d.muDit } d.elemMs = d.elemMs[:0] if c, ok := morse[string(pat)]; ok { if d.onChar != nil { d.onChar(string(c)) } d.textSince = true } else if len(pat) <= 7 { // Morse-shaped but unknown → flag it; longer runs are noise, drop. if d.onChar != nil { d.onChar("?") } d.textSince = true } } // flushPending finishes the in-progress character and word at end-of-over // (lock release), so the last word isn't left hanging until the next signal. func (d *Decoder) flushPending() { if !d.charEmitted { d.flushChar() d.charEmitted = true } if !d.wordEmitted && d.textSince && d.onChar != nil { d.onChar(" ") d.wordEmitted = true } } func (d *Decoder) emitStatus() { d.sinceStatus++ if d.sinceStatus < d.statusEvery || d.onStatus == nil { return } d.sinceStatus = 0 wpm := 0 if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) { wpm = int(math.Round(1200 / d.muDit)) } d.onStatus(Status{ WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: d.rawKey, }) }