fix: bug when autocall for cw keyer is on which was
autocalling no matter which macro now only on CQ fix: ESC stop transmission but also autocall
This commit is contained in:
+30
-16
@@ -598,7 +598,7 @@ export default function App() {
|
|||||||
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
||||||
const cwOn = cwEnabled && mode === 'CW';
|
const cwOn = cwEnabled && mode === 'CW';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-2000)));
|
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
|
||||||
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
||||||
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
||||||
return () => { offT?.(); offS?.(); offE?.(); };
|
return () => { offT?.(); offS?.(); offE?.(); };
|
||||||
@@ -1476,8 +1476,16 @@ export default function App() {
|
|||||||
function wkSendMacro(i: number) {
|
function wkSendMacro(i: number) {
|
||||||
const m = wkMacros[i];
|
const m = wkMacros[i];
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop
|
// Auto-call only loops CQ-type macros. Sending any other macro (e.g. a
|
||||||
else wkSend(m.text);
|
// report once someone answers) sends ONCE and cancels a running loop —
|
||||||
|
// otherwise a report would keep repeating.
|
||||||
|
const isCQ = (m.text || '').toUpperCase().includes('CQ');
|
||||||
|
if (wkAutoCallRef.current && isCQ) {
|
||||||
|
runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC
|
||||||
|
} else {
|
||||||
|
stopAutoCall();
|
||||||
|
wkSend(m.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wkSendMacroRef.current = wkSendMacro;
|
wkSendMacroRef.current = wkSendMacro;
|
||||||
function wkToggleAutoCall(on: boolean) {
|
function wkToggleAutoCall(on: boolean) {
|
||||||
@@ -2046,7 +2054,9 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keyerLive = wkActiveRef.current;
|
const keyerLive = wkActiveRef.current;
|
||||||
if (keyerLive) WinkeyerStop().catch(() => {});
|
// ESC aborts the current CW transmission AND the auto-call loop, so it
|
||||||
|
// won't resend after the gap — you must click a CQ macro to restart it.
|
||||||
|
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
|
||||||
if (!keyerLive || wkEscClearsRef.current) {
|
if (!keyerLive || wkEscClearsRef.current) {
|
||||||
resetEntry();
|
resetEntry();
|
||||||
callsignRef.current?.focus();
|
callsignRef.current?.focus();
|
||||||
@@ -3202,21 +3212,25 @@ export default function App() {
|
|||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0 overflow-x-auto whitespace-nowrap font-mono leading-5">
|
{/* Single-line rolling ticker — no scrollbar; newest text stays
|
||||||
|
pinned to the right, older text scrolls off the left. */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden font-mono leading-5">
|
||||||
{cwText.trim() === '' ? (
|
{cwText.trim() === '' ? (
|
||||||
<span className="text-muted-foreground italic">listening…</span>
|
<span className="text-muted-foreground italic">listening…</span>
|
||||||
) : (
|
) : (
|
||||||
cwText.trim().split(/\s+/).map((tok, i) => (
|
<div className="flex justify-end whitespace-nowrap">
|
||||||
<button
|
{cwText.trim().split(/\s+/).map((tok, i) => (
|
||||||
key={i}
|
<button
|
||||||
type="button"
|
key={i}
|
||||||
className="mr-1 rounded px-1 hover:bg-emerald-200/70"
|
type="button"
|
||||||
title="Use as callsign"
|
className="ml-1 shrink-0 rounded px-1 hover:bg-emerald-200/70"
|
||||||
onClick={() => onCallsignInput(tok, { force: true })}
|
title="Use as callsign"
|
||||||
>
|
onClick={() => onCallsignInput(tok, { force: true })}
|
||||||
{tok}
|
>
|
||||||
</button>
|
{tok}
|
||||||
))
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
|
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function WinkeyerPanel({
|
|||||||
someone answers. The seconds box is the gap AFTER the message. */}
|
someone answers. The seconds box is the gap AFTER the message. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||||
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
|
title="Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.">
|
||||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
Auto-call
|
Auto-call
|
||||||
@@ -193,7 +193,7 @@ export function WinkeyerPanel({
|
|||||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
</div>
|
</div>
|
||||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
{autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||||
|
|||||||
+110
-77
@@ -1,48 +1,64 @@
|
|||||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||||
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
||||||
// of Goertzel tone detectors (auto-picking the dominant pitch), an adaptive
|
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
||||||
// envelope/threshold to recover key-down/key-up, an adaptive dot-length (WPM)
|
// at other pitches is ignored), an SNR-based key-down/key-up detector measured
|
||||||
// estimate, and a timing state machine that maps marks/spaces to Morse and
|
// against the broadband noise floor (so QRN bursts that lift every bin are
|
||||||
// then to characters.
|
// rejected), an adaptive dot-length (WPM) estimate, and a timing state machine
|
||||||
|
// that maps marks/spaces to Morse and then to characters.
|
||||||
//
|
//
|
||||||
// It is deliberately self-contained and dependency-free so it can be unit
|
// It is deliberately self-contained and dependency-free so it can be unit
|
||||||
// tested with synthetic signals. Robustness on weak/QRM/QSB signals is limited
|
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
||||||
// (as with every audio CW decoder); it does well on clean signals.
|
// and very heavy QRM still degrade it; the lock + SNR gate trade a little
|
||||||
|
// sensitivity for far fewer false decodes.
|
||||||
package cwdecode
|
package cwdecode
|
||||||
|
|
||||||
import "math"
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||||
type Status struct {
|
type Status struct {
|
||||||
WPM int `json:"wpm"`
|
WPM int `json:"wpm"`
|
||||||
Pitch int `json:"pitch"` // Hz of the locked tone
|
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||||||
Level float64 `json:"level"` // 0..1 rough signal strength (SNR proxy)
|
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||||||
Active bool `json:"active"` // a tone is currently keyed down
|
Active bool `json:"active"` // a tone is currently keyed down
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||||||
type Decoder struct {
|
type Decoder struct {
|
||||||
fs int
|
fs int
|
||||||
hop int // samples between envelope updates
|
hop int // samples between updates
|
||||||
win int // Goertzel window length
|
win int // Goertzel window length
|
||||||
freqs []float64
|
freqs []float64
|
||||||
coeffs []float64 // precomputed 2*cos(w) per freq
|
coeffs []float64 // precomputed 2*cos(w) per freq
|
||||||
|
|
||||||
ring []float64 // last win samples
|
ring []float64 // last win samples
|
||||||
acc int // samples since last hop
|
acc int // samples since last hop
|
||||||
|
mags []float64 // per-bin magnitude this hop
|
||||||
|
nbuf []float64 // scratch for the noise percentile
|
||||||
|
|
||||||
// Adaptive envelope (relative, so absolute gain is irrelevant).
|
// Pitch lock + noise.
|
||||||
peak, floor float64
|
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||||
|
candIdx int // current argmax candidate while unlocked
|
||||||
|
candHops int // consecutive hops the candidate has been dominant
|
||||||
|
unlockHops int // consecutive low-SNR hops while locked
|
||||||
|
noise float64
|
||||||
|
relockHops int // quiet hops before the lock is released
|
||||||
|
onSNR float64 // SNR to call key-down / to acquire a lock
|
||||||
|
offSNR float64 // SNR below which it's key-up
|
||||||
|
|
||||||
|
// Keying / timing.
|
||||||
state bool // true = mark (key down)
|
state bool // true = mark (key down)
|
||||||
stateHops int
|
stateHops int
|
||||||
dotHops float64 // adaptive dot length, in hops
|
dotHops float64 // adaptive dot length, in hops
|
||||||
|
elem []byte // current "." / "-" run for the in-progress character
|
||||||
|
charEmitted bool
|
||||||
|
wordEmitted bool
|
||||||
|
|
||||||
elem []byte // current "." / "-" run for the in-progress character
|
lastPitch float64
|
||||||
charEmitted bool // current space already flushed a character
|
lastRMS float64
|
||||||
wordEmitted bool // current space already flushed a word gap
|
|
||||||
lastPitch float64
|
|
||||||
lastRMS float64 // 0..1 input level of the current window (for the UI meter)
|
|
||||||
|
|
||||||
statusEvery int
|
statusEvery int
|
||||||
sinceStatus int
|
sinceStatus int
|
||||||
@@ -71,23 +87,29 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
|||||||
}
|
}
|
||||||
d := &Decoder{
|
d := &Decoder{
|
||||||
fs: sampleRate,
|
fs: sampleRate,
|
||||||
hop: sampleRate / 250, // ~4 ms envelope resolution
|
hop: sampleRate / 250, // ~4 ms resolution
|
||||||
win: sampleRate / 62, // ~16 ms Goertzel window
|
win: sampleRate / 62, // ~16 ms Goertzel window
|
||||||
dotHops: 15, // ~20 WPM seed (15 hops * 4 ms = 60 ms)
|
dotHops: 15, // ~20 WPM seed
|
||||||
statusEvery: 25, // ~10 Hz status
|
onSNR: 4.0,
|
||||||
|
offSNR: 2.5,
|
||||||
|
lockIdx: -1,
|
||||||
|
candIdx: -1,
|
||||||
|
statusEvery: 25, // ~10 Hz
|
||||||
onChar: onChar,
|
onChar: onChar,
|
||||||
onStatus: onStatus,
|
onStatus: onStatus,
|
||||||
}
|
}
|
||||||
if d.hop < 1 {
|
if d.hop < 1 {
|
||||||
d.hop = 1
|
d.hop = 1
|
||||||
}
|
}
|
||||||
// Candidate CW tones: 250–1200 Hz every 25 Hz (wide enough for most rigs'
|
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
||||||
// audio offset). The dominant bin is the pitch (auto), and its magnitude
|
// Candidate CW tones: 250–1200 Hz every 25 Hz (covers most rigs' audio
|
||||||
// drives the envelope.
|
// offset). The locked bin is the pitch; only its magnitude is decoded.
|
||||||
for f := 250.0; f <= 1200.0; f += 25 {
|
for f := 250.0; f <= 1200.0; f += 25 {
|
||||||
d.freqs = append(d.freqs, f)
|
d.freqs = append(d.freqs, f)
|
||||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
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
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +117,7 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
|||||||
func (d *Decoder) Reset() {
|
func (d *Decoder) Reset() {
|
||||||
d.ring = d.ring[:0]
|
d.ring = d.ring[:0]
|
||||||
d.acc = 0
|
d.acc = 0
|
||||||
d.peak, d.floor = 0, 0
|
d.lockIdx, d.candIdx, d.candHops, d.unlockHops = -1, -1, 0, 0
|
||||||
d.state = false
|
d.state = false
|
||||||
d.stateHops = 0
|
d.stateHops = 0
|
||||||
d.dotHops = 15
|
d.dotHops = 15
|
||||||
@@ -113,18 +135,18 @@ func (d *Decoder) Process(samples []int16) {
|
|||||||
d.acc++
|
d.acc++
|
||||||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
if d.acc >= d.hop && len(d.ring) >= d.win {
|
||||||
d.acc = 0
|
d.acc = 0
|
||||||
mag, pitch := d.toneMag()
|
d.analyze()
|
||||||
d.step(mag, pitch)
|
d.step()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toneMag runs the Goertzel bank over the current window and returns the
|
// analyze runs the Goertzel bank over the current window, estimates the noise
|
||||||
// strongest bin's magnitude and its frequency (the auto-detected pitch).
|
// floor, and maintains the pitch lock.
|
||||||
func (d *Decoder) toneMag() (float64, float64) {
|
func (d *Decoder) analyze() {
|
||||||
best, bestF := 0.0, d.lastPitch
|
|
||||||
n := float64(len(d.ring))
|
n := float64(len(d.ring))
|
||||||
var sumSq float64
|
var sumSq float64
|
||||||
|
maxIdx, maxMag := 0, -1.0
|
||||||
for i, coeff := range d.coeffs {
|
for i, coeff := range d.coeffs {
|
||||||
var s1, s2 float64
|
var s1, s2 float64
|
||||||
for _, x := range d.ring {
|
for _, x := range d.ring {
|
||||||
@@ -132,57 +154,71 @@ func (d *Decoder) toneMag() (float64, float64) {
|
|||||||
s2 = s1
|
s2 = s1
|
||||||
s1 = s0
|
s1 = s0
|
||||||
}
|
}
|
||||||
power := s1*s1 + s2*s2 - coeff*s1*s2
|
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
||||||
if power > best {
|
d.mags[i] = m
|
||||||
best = power
|
if m > maxMag {
|
||||||
bestF = d.freqs[i]
|
maxMag = m
|
||||||
|
maxIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, x := range d.ring {
|
for _, x := range d.ring {
|
||||||
sumSq += x * x
|
sumSq += x * x
|
||||||
}
|
}
|
||||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4) // ×4 so quiet audio is visible
|
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
||||||
// Normalise by window length so the magnitude scale is rate-independent.
|
|
||||||
return math.Sqrt(math.Max(best, 0)) / n, bestF
|
// Noise floor = 40th percentile of the bins (robust to a few strong tones,
|
||||||
|
// so one or two QRM signals don't inflate it).
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
||||||
|
eps := d.noise + 1e-9
|
||||||
|
|
||||||
|
if d.lockIdx < 0 {
|
||||||
|
// Acquire: lock once the same bin has been dominant for a few hops and
|
||||||
|
// is clearly above the noise.
|
||||||
|
if maxIdx == d.candIdx {
|
||||||
|
d.candHops++
|
||||||
|
} else {
|
||||||
|
d.candIdx, d.candHops = maxIdx, 1
|
||||||
|
}
|
||||||
|
if d.candHops >= 4 && maxMag/eps > d.onSNR {
|
||||||
|
d.lockIdx, d.unlockHops = maxIdx, 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hold the lock through key-up gaps; release only after a long quiet so
|
||||||
|
// we can retune to a new signal/pitch.
|
||||||
|
if d.mags[d.lockIdx]/eps < d.offSNR {
|
||||||
|
d.unlockHops++
|
||||||
|
} else {
|
||||||
|
d.unlockHops = 0
|
||||||
|
}
|
||||||
|
if d.unlockHops > d.relockHops {
|
||||||
|
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.lockIdx >= 0 {
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
} else {
|
||||||
|
d.lastPitch = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// step advances the envelope follower + timing state machine by one hop.
|
// step advances the keying detector + timing state machine by one hop.
|
||||||
func (d *Decoder) step(mag, pitch float64) {
|
func (d *Decoder) step() {
|
||||||
// Envelope: fast attack / slow release for the peak, fast drop / slow rise
|
on := false
|
||||||
// for the noise floor. Tracks the signal even through QSB.
|
if d.lockIdx >= 0 {
|
||||||
if mag > d.peak {
|
snr := d.mags[d.lockIdx] / (d.noise + 1e-9)
|
||||||
d.peak += (mag - d.peak) * 0.4
|
|
||||||
} else {
|
|
||||||
d.peak += (mag - d.peak) * 0.02
|
|
||||||
}
|
|
||||||
if mag < d.floor {
|
|
||||||
d.floor += (mag - d.floor) * 0.4
|
|
||||||
} else {
|
|
||||||
d.floor += (mag - d.floor) * 0.01
|
|
||||||
}
|
|
||||||
|
|
||||||
span := d.peak - d.floor
|
|
||||||
// Hysteresis thresholds; require a minimum SNR span to call anything a tone.
|
|
||||||
on := d.state
|
|
||||||
if span > d.floor*0.3+1e-9 {
|
|
||||||
onTh := d.floor + 0.55*span
|
|
||||||
offTh := d.floor + 0.35*span
|
|
||||||
if d.state {
|
if d.state {
|
||||||
on = mag > offTh
|
on = snr > d.offSNR // hysteresis: stay keyed until it clearly drops
|
||||||
} else {
|
} else {
|
||||||
on = mag > onTh
|
on = snr > d.onSNR
|
||||||
}
|
}
|
||||||
if on {
|
|
||||||
d.lastPitch = pitch
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
on = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if on == d.state {
|
if on == d.state {
|
||||||
d.stateHops++
|
d.stateHops++
|
||||||
if !d.state {
|
if !d.state {
|
||||||
d.spaceProgress() // flush char/word as the gap grows
|
d.spaceProgress()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if d.state {
|
if d.state {
|
||||||
@@ -191,24 +227,21 @@ func (d *Decoder) step(mag, pitch float64) {
|
|||||||
d.state = on
|
d.state = on
|
||||||
d.stateHops = 1
|
d.stateHops = 1
|
||||||
if on {
|
if on {
|
||||||
// A new mark starts → the previous space is over; re-arm flushing.
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d.emitStatus(on)
|
d.emitStatus(on)
|
||||||
}
|
}
|
||||||
|
|
||||||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
||||||
// dot-length estimate.
|
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
||||||
|
// clicks/noise.
|
||||||
func (d *Decoder) endMark(hops int) {
|
func (d *Decoder) endMark(hops int) {
|
||||||
h := float64(hops)
|
h := float64(hops)
|
||||||
// Reject impulse noise far shorter than a dot.
|
|
||||||
if h < d.dotHops*0.35 {
|
if h < d.dotHops*0.35 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dash := h > d.dotHops*2
|
if h > d.dotHops*2 {
|
||||||
if dash {
|
|
||||||
d.elem = append(d.elem, '-')
|
d.elem = append(d.elem, '-')
|
||||||
d.adaptDot(h / 3)
|
d.adaptDot(h / 3)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ func charToMorse() map[byte]string {
|
|||||||
|
|
||||||
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
||||||
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
||||||
|
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||||
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
||||||
c2m := charToMorse()
|
c2m := charToMorse()
|
||||||
var out []int16
|
var out []int16
|
||||||
@@ -25,7 +29,7 @@ func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
|||||||
|
|
||||||
tone := func(n int) {
|
tone := func(n int) {
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
out = append(out, int16(9000*math.Sin(phase)))
|
out = append(out, int16(amp*math.Sin(phase)))
|
||||||
phase += dphi
|
phase += dphi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +83,41 @@ func TestDecodeCleanSignal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeWithQRM(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
||||||
|
// quieter, sending different text. The pitch lock should hold on the target.
|
||||||
|
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||||
|
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
||||||
|
mix := make([]int16, len(target))
|
||||||
|
for i := range target {
|
||||||
|
v := int(target[i])
|
||||||
|
if i < len(qrm) {
|
||||||
|
v += int(qrm[i])
|
||||||
|
}
|
||||||
|
if v > 32767 {
|
||||||
|
v = 32767
|
||||||
|
} else if v < -32768 {
|
||||||
|
v = -32768
|
||||||
|
}
|
||||||
|
mix[i] = int16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
for i := 0; i < len(mix); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(mix) {
|
||||||
|
end = len(mix)
|
||||||
|
}
|
||||||
|
d.Process(mix[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "PARIS") {
|
||||||
|
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
func TestDecodeNumbersAndProsign(t *testing.T) {
|
||||||
const fs = 16000
|
const fs = 16000
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|||||||
Reference in New Issue
Block a user