Compare commits

..
Author SHA1 Message Date
rouggy f50bbc005c feat(tci): the QSO recorder can take its audio from the radio
Confirmed on a real SunSDR: the stream decodes and the test recording
plays back clean. So it can do the job a virtual audio cable was doing —
this wires it to the QSO recorder, which already accepts a pushed source
(the Icom network audio uses the same door).

The conversion lives here rather than in internal/cat: the radio's job is
to hand over what it sent, not to know that the recorder works in 16 kHz
mono. Three samples are AVERAGED rather than two of them dropped —
decimating by picking every third folds everything above 8 kHz back into
the voice band, and on a receiver that is hiss, which a QSO recording has
plenty of already.

Off by default, and applied the moment it is switched: it replaces a
sound card the operator has already wired up, and an option that needs a
restart to take effect reads as an option that does not work.
2026-08-25 20:14:45 +02:00
rouggy 6a6b7ad6c2 chore(tci): probe binary frames per stream type
The frame log had one budget for the whole session, and the first forty
receive-audio frames spend it in under two seconds. A transmit-chrono
frame — the thing the voice keyer will have to answer, and whose size and
cadence cannot be read off the documentation — only appears once the
operator keys the radio, by which time nothing would have been logged.

Counted per type now, so the first frames of each kind are recorded
whenever they turn up.
2026-08-25 08:29:24 +02:00
rouggy 01a23ccb77 feat(tci): read the radio's declared format, and record a test WAV
The SunSDR announces its own stream at connect —
audio_stream_sample_type:float32 and audio_stream_channels:2 — and both
were being logged as unhandled while the code worked the format out from
frame arithmetic. The declaration is better evidence and arrives before
the first frame; the arithmetic stays as the check on it. The channel
count now drives the mix-down instead of an assumed stereo.

Adds a ten-second test recording, written as a WAV beside the QSO
recordings. Counting frames proves a socket is delivering bytes; it says
nothing about whether those bytes are the receiver's audio, at the right
rate, in the right order. A stream decoded with the width wrong or the
samples misaligned counts exactly as well as a correct one and sounds
like a fan — so the test is a file the operator can play, the same way
the CW decoder was settled on the air rather than on a spectrogram.

The file is written at the rate the RADIO reported, not a constant: a
recording at the wrong rate plays at the wrong speed, which is the one
fault that would be blamed on the decoding.
2026-08-25 08:25:29 +02:00
rouggy 9b8168370f fix(tci): derive the sample width instead of trusting the format number
A real SunSDR answers format=3 where this expected 0 — and 0 was read
from the documentation, which is exactly the kind of detail a memory of a
document gets wrong. The stream was refused outright: 'format=3, expected
float32', zero frames, silence.

Swapping one magic number for another would only move the guess, so the
width is now MEASURED: the header says how many samples the payload
holds, and dividing gives the bytes per sample. Four is float32, two is
16-bit PCM scaled to the same -1..1 the rest of the audio path uses, and
anything else is reported rather than mangled. That stays true whatever
number the format field carries on the next firmware.
2026-08-24 22:36:21 +02:00
rouggy efa711af78 feat(tci): receive audio over the TCI WebSocket (experimental)
A SunSDR already carries its receive audio on the same WebSocket as its
commands, so a virtual audio cable and a second sound card are two pieces
of plumbing an operator installs for no reason. This is the receive half:
what the QSO recorder and the CW decoder need.

The reader now looks at the frame type. It used to ignore it and split
every frame on ';' -- harmless only for as long as no stream was ever
opened, since audio bytes would otherwise have been handed to the command
parser a hundred times a second.

NOTHING HERE IS CONFIRMED ON A RADIO. The header layout comes from the
TCI documentation, and the stream-type numbers are exactly the sort of
detail a document gets right and a memory of it does not -- so the first
forty frames of a session are logged verbatim, and a test bench in
Preferences > Audio reports the sample rate the radio chose, the frames
arriving and the peak level of the last second. 'The stream is open' and
'audio is arriving' are different claims and only the second is worth
anything to whoever tries this first.

Transmit (the voice keyer) is the other half and is deliberately absent:
it has to answer the radio's chrono packets at the right pace, and that
is worth doing once the format is settled on real hardware.
2026-08-24 20:34:06 +02:00
27 changed files with 971 additions and 309 deletions
+5 -1
View File
@@ -15262,7 +15262,11 @@ func (a *App) reloadCAT() {
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink)) a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
case "tci": case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any // Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server. // TCI-compatible server. The receive audio rides the same socket, so
// the QSO recorder can take it without a virtual cable — see
// app_tci_rec.go. Armed after the backend is up, since it is the
// backend that carries the stream.
defer a.startTCIRecording()
tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots) tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots)
// Clicking one of our spots on the ExpertSDR panorama fills the entry form. // Clicking one of our spots on the ExpertSDR panorama fills the entry form.
tb.OnSpotClick = func(call string, hz int64) { tb.OnSpotClick = func(call string, hz int64) {
-5
View File
@@ -80,11 +80,6 @@ func (a *App) SetKenwoodXIT(on bool) error {
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) }) return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) })
} }
// NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz.
func (a *App) NudgeKenwoodRIT(delta int) error {
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.NudgeKenwoodRIT(delta) })
}
// ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the // ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the
// radio's own RC does and what an operator means by "clear it". // radio's own RC does and what an operator means by "clear it".
func (a *App) ClearKenwoodRIT() error { func (a *App) ClearKenwoodRIT() error {
+53
View File
@@ -0,0 +1,53 @@
package main
// TCI receive audio — bindings.
//
// A SunSDR carries its receive audio on the same WebSocket as its commands, so
// OpsLog can take it directly instead of asking the operator to install a
// virtual audio cable and wire ExpertSDR's output into it. This is the first
// half: RECEIVE only, which is what the QSO recorder and the CW decoder need.
// Transmit audio (the voice keyer) is the other half and is not here yet — it
// has to answer the radio's chrono packets at the right pace, and that is worth
// doing once the receive side has proved the format on a real radio.
import (
"fmt"
"hamlog/internal/applog"
"hamlog/internal/cat"
)
// StartTCIAudio opens the receive-audio stream for one receiver.
//
// rate 0 means 48 kHz, which is what ExpertSDR streams by default and what the
// recorder wants anyway.
func (a *App) StartTCIAudio(rx, rate int) error {
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
applog.Printf("tci: opening the receive-audio stream (rx %d, %d Hz)", rx, rate)
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StartTCIAudio(rx, rate) })
}
// StopTCIAudio closes it.
func (a *App) StopTCIAudio() error {
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
applog.Printf("tci: closing the receive-audio stream")
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
}
// GetTCIAudioStatus reports what is arriving: the sample rate the radio chose,
// how much has come in, and the peak level of the last second.
//
// The level is the point. "The stream is open" and "audio is arriving" are
// different claims, and only the second one is worth anything to someone
// testing this on a radio for the first time.
func (a *App) GetTCIAudioStatus() cat.TCIAudioStatus {
if a.cat == nil {
return cat.TCIAudioStatus{}
}
st, _ := a.cat.TCIAudioState()
return st
}
+120
View File
@@ -0,0 +1,120 @@
package main
// Feeding the QSO recorder from the TCI stream.
//
// The recorder works in 16 kHz mono, which is what its files and its mixing are
// built around; TCI delivers 48 kHz stereo float32. The conversion is the whole
// of this file, and it happens here rather than in internal/cat because the
// radio's job is to hand over what it sent, not to know what the recorder wants.
//
// Confirmed on a SunSDR (ExpertSDR3 1.5): 2048 samples a frame, 8192 bytes,
// four bytes per sample — and a test recording that plays back clean.
import (
"encoding/binary"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cat"
)
// tciRecordSink pushes the receive stream into the QSO recorder.
//
// Installed whenever the TCI backend starts, and harmless when nothing is
// recording: PushRX drops what arrives unless a QSO is being captured, so the
// cost while idle is a decimation and a function call.
func (a *App) tciRecordSink(rate int, samples []float32) {
if a.qsoRec == nil || len(samples) == 0 {
return
}
a.qsoRec.PushRX(tciToRecorderPCM(rate, samples))
}
// tciToRecorderPCM converts the stream's mono float samples to the recorder's
// 16-bit PCM at its own rate.
//
// Averaging rather than picking every third sample: dropping samples aliases
// everything above 8 kHz back down into the voice band, and on a receiver that
// is hiss — the one thing a QSO recording has plenty of. A three-tap mean is a
// crude low-pass, but it is a low-pass, and it costs two additions.
func tciToRecorderPCM(rate int, samples []float32) []byte {
if rate <= 0 {
rate = 48000
}
step := rate / audio.RecorderSampleRate
if step < 1 {
step = 1
}
out := make([]byte, 0, (len(samples)/step)*2)
for i := 0; i+step <= len(samples); i += step {
var sum float32
for j := 0; j < step; j++ {
sum += samples[i+j]
}
v := sum / float32(step)
if v > 1 {
v = 1
}
if v < -1 {
v = -1
}
var b [2]byte
binary.LittleEndian.PutUint16(b[:], uint16(int16(v*32767)))
out = append(out, b[0], b[1])
}
return out
}
// startTCIRecording opens the receive stream and routes it to the recorder.
//
// Called when the TCI backend comes up, and only when the operator has asked
// for it: opening a 384 kB/s stream on a station that records nothing is work
// the radio does for nobody.
func (a *App) startTCIRecording() {
if a.cat == nil || a.settingOr(keyTCIRecAudio, "") != "1" {
return
}
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
if s, ok := t.(interface {
SetTCIAudioSink(func(int, []float32))
}); ok {
s.SetTCIAudioSink(a.tciRecordSink)
}
return t.StartTCIAudio(0, 48000)
})
if err != nil {
applog.Printf("tci: could not open the receive stream for recording: %v", err)
return
}
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
}
// keyTCIRecAudio turns it on. Off by default: it replaces whatever sound card
// the operator has already wired up, and a setting that changes where a
// recording comes from should be asked for rather than assumed.
const keyTCIRecAudio = "audio.tci_rx"
// GetTCIRecordAudio reports whether the recorder takes its audio from the radio.
func (a *App) GetTCIRecordAudio() bool { return a.settingOr(keyTCIRecAudio, "") == "1" }
// SetTCIRecordAudio turns it on or off, and applies it NOW rather than at the
// next restart: an option that needs the application relaunched to take effect
// reads as an option that does not work.
func (a *App) SetTCIRecordAudio(on bool) error {
a.setSetting(keyTCIRecAudio, boolStr(on))
if on {
a.startTCIRecording()
return nil
}
if a.cat == nil {
return nil
}
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
if s, ok := t.(interface {
SetTCIAudioSink(func(int, []float32))
}); ok {
s.SetTCIAudioSink(nil)
}
return t.StopTCIAudio()
})
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"encoding/binary"
"testing"
"hamlog/internal/audio"
)
// The stream is 48 kHz and the recorder works at 16 — three to one. A
// recording that keeps every sample plays back three times too fast, which is
// the fault that gets blamed on the decoding rather than on the rate.
func TestTheStreamIsResampledToTheRecorderRate(t *testing.T) {
const in = 48000
samples := make([]float32, in/10) // a tenth of a second
pcm := tciToRecorderPCM(in, samples)
want := (audio.RecorderSampleRate / 10) * 2 // 16-bit
if len(pcm) != want {
t.Fatalf("a tenth of a second produced %d bytes, want %d", len(pcm), want)
}
}
// Full scale must arrive as full scale: a conversion that quietly halves the
// level turns a recording into evidence of a fault that is not there.
func TestFullScaleSurvivesTheConversion(t *testing.T) {
samples := make([]float32, 12)
for i := range samples {
samples[i] = 1
}
pcm := tciToRecorderPCM(48000, samples)
if len(pcm) < 2 {
t.Fatal("no samples came out")
}
v := int16(binary.LittleEndian.Uint16(pcm[:2]))
if v < 32000 {
t.Fatalf("full scale came out at %d", v)
}
}
// A rate the recorder already works in is passed through rather than mangled by
// a division that would round to nothing.
func TestAStreamAtTheRecorderRateIsNotDecimated(t *testing.T) {
samples := make([]float32, 160)
if got, want := len(tciToRecorderPCM(audio.RecorderSampleRate, samples)), 160*2; got != want {
t.Fatalf("%d bytes, want %d", got, want)
}
}
+175
View File
@@ -0,0 +1,175 @@
package main
// Recording a few seconds of the TCI stream to a WAV file.
//
// Counting frames proves a socket is delivering bytes. It does not prove those
// bytes are the receiver's audio, at the right rate, in the right order — a
// stream decoded with the channels swapped, the width wrong or the samples
// misaligned counts exactly as well as a correct one and sounds like a fan.
//
// So the test is a file the operator can play. It is the same reason the CW
// decoder was validated on the air rather than on a spectrogram.
import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"sync"
"time"
"hamlog/internal/applog"
"hamlog/internal/cat"
)
// tciRec collects samples while a test recording is running.
type tciRec struct {
mu sync.Mutex
active bool
rate int
samples []float32
want int // how many samples to collect before stopping
}
var tciRecorder tciRec
// RecordTCIAudio captures seconds of the TCI receive stream and writes a WAV
// next to the QSO recordings. Returns the path.
//
// The stream has to be open already — this listens to what is arriving rather
// than opening anything, so a recording can never leave a stream running that
// the operator did not ask for.
func (a *App) RecordTCIAudio(seconds int) (string, error) {
if a.cat == nil {
return "", fmt.Errorf("CAT not initialized")
}
if seconds <= 0 || seconds > 60 {
seconds = 10
}
// Rate from the radio, not assumed: the file's header has to match what was
// actually streamed or the recording plays at the wrong speed, which is the
// one fault that would be blamed on the decoding.
st := a.GetTCIAudioStatus()
if !st.Running {
return "", fmt.Errorf("open the TCI audio stream first")
}
rate := st.SampleRate
if rate <= 0 {
rate = 48000
}
tciRecorder.mu.Lock()
if tciRecorder.active {
tciRecorder.mu.Unlock()
return "", fmt.Errorf("a test recording is already running")
}
tciRecorder.active = true
tciRecorder.rate = rate
tciRecorder.want = rate * seconds
tciRecorder.samples = make([]float32, 0, tciRecorder.want)
tciRecorder.mu.Unlock()
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
s, ok := t.(interface{ SetTCIAudioSink(func(int, []float32)) })
if !ok {
return fmt.Errorf("this backend has no audio sink")
}
s.SetTCIAudioSink(func(_ int, samples []float32) {
tciRecorder.mu.Lock()
defer tciRecorder.mu.Unlock()
if !tciRecorder.active {
return
}
tciRecorder.samples = append(tciRecorder.samples, samples...)
})
return nil
})
if err != nil {
tciRecorder.mu.Lock()
tciRecorder.active = false
tciRecorder.mu.Unlock()
return "", err
}
// Wait for the samples rather than for the clock: a stream that stalls
// halfway should produce a short file that says so, not a long one padded
// with silence that hides it.
deadline := time.Now().Add(time.Duration(seconds+5) * time.Second)
for {
tciRecorder.mu.Lock()
got := len(tciRecorder.samples)
want := tciRecorder.want
tciRecorder.mu.Unlock()
if got >= want || time.Now().After(deadline) {
break
}
time.Sleep(100 * time.Millisecond)
}
tciRecorder.mu.Lock()
tciRecorder.active = false
pcm := tciRecorder.samples
tciRecorder.samples = nil
tciRecorder.mu.Unlock()
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
if s, ok := t.(interface{ SetTCIAudioSink(func(int, []float32)) }); ok {
s.SetTCIAudioSink(nil)
}
return nil
})
if len(pcm) == 0 {
return "", fmt.Errorf("nothing arrived on the stream")
}
path := filepath.Join(a.qsoRecDir(), fmt.Sprintf("tci-test-%s.wav", time.Now().Format("20060102-150405")))
if err := writeMonoWAV(path, pcm, rate); err != nil {
return "", err
}
applog.Printf("tci: wrote %.1f s of receive audio to %s (%d Hz)", float64(len(pcm))/float64(rate), path, rate)
return path, nil
}
// writeMonoWAV writes float samples as 16-bit mono PCM.
//
// Its own writer rather than internal/audio's: that one is nailed to the voice
// keyer's rate, and a test recording written at the wrong rate would play back
// at the wrong speed — the one fault that looks exactly like a decoding error.
func writeMonoWAV(path string, samples []float32, rate int) error {
data := make([]byte, len(samples)*2)
for i, v := range samples {
s := int(math.Round(float64(v) * 32767))
if s > 32767 {
s = 32767
}
if s < -32768 {
s = -32768
}
binary.LittleEndian.PutUint16(data[i*2:], uint16(int16(s)))
}
var hdr [44]byte
copy(hdr[0:], "RIFF")
binary.LittleEndian.PutUint32(hdr[4:], uint32(36+len(data)))
copy(hdr[8:], "WAVEfmt ")
binary.LittleEndian.PutUint32(hdr[16:], 16) // PCM chunk size
binary.LittleEndian.PutUint16(hdr[20:], 1) // PCM
binary.LittleEndian.PutUint16(hdr[22:], 1) // mono
binary.LittleEndian.PutUint32(hdr[24:], uint32(rate))
binary.LittleEndian.PutUint32(hdr[28:], uint32(rate*2))
binary.LittleEndian.PutUint16(hdr[32:], 2) // block align
binary.LittleEndian.PutUint16(hdr[34:], 16) // bits
copy(hdr[36:], "data")
binary.LittleEndian.PutUint32(hdr[40:], uint32(len(data)))
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(hdr[:]); err != nil {
return err
}
_, err = f.Write(data)
return err
}
+2 -14
View File
@@ -2,20 +2,8 @@
{ {
"version": "0.26.13", "version": "0.26.13",
"date": "", "date": "",
"en": [ "en": [],
"Elecraft console: MOX unkeys again, and the power and SWR bars move while transmitting. The poll stops while the carrier is up — the rig refuses most questions then — and the panel was left believing the radio was still receiving, so pressing MOX a second time sent another transmit command instead of stopping.", "fr": []
"Elecraft console: RIT and XIT can be moved — ±10 and ±100 Hz buttons with the offset shown beside them. A lit RIT button says the feature is on and nothing about where it has put the receiver.",
"Elecraft console: a 4.0 kHz filter button, the K3 maximum and the one FT8 wants.",
"FT decodes: a station that is new on both the band and the mode now shows both badges. That status had no badge of its own, so those rows passed the BAND and MODE filters and then displayed no reason for being in the list — only their grid or prefix badge, which looked like the filter leaking.",
"Two copies of MSHV are now told apart. MSHV calls every instance 'MSHV', and the dial frequency was filed under that name alone — so a second copy on another band overwrote the first, and decodes made on 14.095 came out labelled 2190 m. Instances are identified by their sending socket as well as their name, and a second one is shown as 'MSHV #2'."
],
"fr": [
"Console Elecraft : MOX repasse bien en réception, et les barres de puissance et de ROS bougent pendant l'émission. L'interrogation s'arrête quand la porteuse est levée — la radio refuse alors la plupart des questions — et le panneau croyait donc la radio toujours en réception : un second appui sur MOX envoyait une nouvelle commande d'émission au lieu d'arrêter.",
"Console Elecraft : le RIT et le XIT se règlent — boutons ±10 et ±100 Hz avec le décalage affiché à côté. Un bouton RIT allumé dit que la fonction est active et rien sur l'endroit où elle a mis le récepteur.",
"Console Elecraft : un bouton de filtre à 4,0 kHz, le maximum du K3 et celui qu'il faut pour le FT8.",
"Décodes FT : une station nouvelle à la fois sur la bande et dans le mode affiche maintenant les deux badges. Ce statut n'avait pas de badge à lui : ces lignes passaient les filtres BANDE et MODE puis n'affichaient aucune raison d'être là — seulement leur badge de locator ou de préfixe, ce qui donnait l'impression d'un filtre qui fuyait.",
"Deux copies de MSHV sont maintenant distinguées. MSHV nomme chaque instance « MSHV », et la fréquence d'affichage était rangée sous ce seul nom : une seconde copie sur une autre bande écrasait la première, et des décodes faits sur 14.095 ressortaient étiquetés 2190 m. Les instances sont désormais identifiées par leur socket autant que par leur nom, et la seconde s'affiche « MSHV #2 »."
]
}, },
{ {
"version": "0.26.12", "version": "0.26.12",
+1 -1
View File
@@ -1 +1 @@
f9b41e192918fa2511f68cd1b361fcd3 704fe1bf370b669665df0606fae8a69d
+6 -22
View File
@@ -316,22 +316,6 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' }, 'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
}; };
// entityBadgesFor turns a status into the badges that describe it.
//
// A LIST, because "new-band-mode" is two facts at once — new on this band and
// new in this mode — and the map above has an entry for neither. It was read
// with a single lookup, which returned nothing for that status: those rows
// passed the BAND and MODE filters and then showed no reason for being there,
// which is precisely what was reported. catsOf has always split the status into
// its two categories; this is the same split, on the screen.
function entityBadgesFor(status: string): { label: string; cls: string }[] {
if (status === 'new-band-mode') {
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
}
const one = ENTITY_BADGE[status];
return one ? [one] : [];
}
// The orthogonal ones: a station already worked for its entity can still be a // The orthogonal ones: a station already worked for its entity can still be a
// new grid, a new prefix or a park never logged. // new grid, a new prefix or a park never logged.
// //
@@ -1020,10 +1004,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{g.decodes.map((d, i) => { {g.decodes.map((d, i) => {
const e = statusOf(d); const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : ''; const st = e?.status && e.status !== 'worked' ? e.status : '';
const entities = st ? entityBadgesFor(st) : []; const entity = st ? ENTITY_BADGE[st] : undefined;
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]); const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
const mine = !!me && d.call === me; const mine = !!me && d.call === me;
const hot = entities.length > 0 || extras.length > 0; const hot = !!entity || extras.length > 0;
// Someone answering us outranks everything else on the screen. // Someone answering us outranks everything else on the screen.
const replying = answersMe(d.msg); const replying = answersMe(d.msg);
// The station we are calling, so it can be picked out of a slot // The station we are calling, so it can be picked out of a slot
@@ -1112,11 +1096,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{t('dec.wkd')} {t('dec.wkd')}
</span> </span>
)} )}
{entities.map((b) => ( {entity && (
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}> <span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
{t(b.label)} {t(entity.label)}
</span> </span>
))} )}
{extras.map((b) => { {extras.map((b) => {
// A square WORKED but not yet confirmed is a different job // A square WORKED but not yet confirmed is a different job
// from one never worked: a QSL to chase, not a QSO to make. // from one never worked: a QSL to chase, not a QSO to make.
+4 -16
View File
@@ -4,7 +4,7 @@ import {
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU, GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt, SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna, SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT, SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU,
GetCATState, GetCATState,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -20,7 +20,7 @@ type KenwoodState = {
power_meter: number; swr: number; swr_raw: number; power_meter: number; swr: number; swr_raw: number;
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number; rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string; preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number; filter_hz: number; antenna: number; rit: boolean; xit: boolean; key_speed: number;
meters_provisional: boolean; meters_provisional: boolean;
}; };
@@ -29,17 +29,14 @@ const ZERO: KenwoodState = {
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0, s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0, rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
preamp: false, att: false, nb: false, nr: false, preamp: false, att: false, nb: false, nr: false,
filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0, filter_hz: 0, antenna: 0, rit: false, xit: false, key_speed: 0,
meters_provisional: true, meters_provisional: true,
}; };
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8 // Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8
// kHz; the rest of the range is reachable from the radio's own knob, and a // kHz; the rest of the range is reachable from the radio's own knob, and a
// panel offering thirty widths is slower to use than the knob it replaces. // panel offering thirty widths is slower to use than the knob it replaces.
// 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800];
// top of the FT8 sub-band, and the decodes that go missing are the ones
// nobody notices are missing.
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000];
// Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at // Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at
// raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the // raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the
@@ -284,15 +281,6 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} /> <Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} />
<Toggle label="XIT" on={view.xit} off={off} onClick={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)} /> <Toggle label="XIT" on={view.xit} off={off} onClick={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)} />
{/* The offset itself. A lit RIT button says the feature is on and
nothing about where it has put the receiver. */}
{[-100, -10, 10, 100].map((d) => (
<Toggle key={d} label={(d > 0 ? '+' : '') + d} on={false} off={off}
onClick={() => NudgeKenwoodRIT(d).catch(setErrMsg)} />
))}
<span className="text-[11px] font-mono tabular-nums text-muted-foreground w-16">
{view.rit_offset > 0 ? '+' : ''}{view.rit_offset || 0} Hz
</span>
<Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} /> <Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} />
{/* Antenna only when the radio answered AN — a K3 without the internal {/* Antenna only when the radio answered AN — a K3 without the internal
ATU has one socket and no switch to offer. */} ATU has one socket and no switch to offer. */}
+1 -1
View File
@@ -725,7 +725,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const next = { ...rstChase, offset_hz: v }; const next = { ...rstChase, offset_hz: v };
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {}); setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
}} }}
className="w-16 h-6 rounded border border-input bg-background px-1.5 text-[11px] font-mono" /> className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono" />
Hz Hz
</label> </label>
)} )}
+71 -1
View File
@@ -58,7 +58,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1943,6 +1943,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [spotTTL, setSpotTTL] = useState(0); const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0'); const [spotTTLText, setSpotTTLText] = useState('0');
const [spotMaxText, setSpotMaxText] = useState('1000'); const [spotMaxText, setSpotMaxText] = useState('1000');
// TCI receive-audio test bench. Polled only while the stream is open: a panel
// that asks the backend twice a second for a stream nobody started is work
// done for nothing.
const [tciAudio, setTciAudio] = useState<any>({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
const [tciRecBusy, setTciRecBusy] = useState(false);
// Whether the QSO recorder takes its audio from the radio's own stream.
const [tciRec, setTciRec] = useState(false);
useEffect(() => { GetTCIRecordAudio().then((v: boolean) => setTciRec(!!v)).catch(() => {}); }, []);
const [tciRecPath, setTciRecPath] = useState('');
useEffect(() => {
if (!tciAudio.running) return;
const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
return () => window.clearInterval(id);
}, [tciAudio.running]);
const [gridStat, setGridStat] = useState<any>(null); const [gridStat, setGridStat] = useState<any>(null);
const [pskrStatus, setPskrStatus] = useState<any>(null); const [pskrStatus, setPskrStatus] = useState<any>(null);
const saveBandOpen = async (next: any) => { const saveBandOpen = async (next: any) => {
@@ -6542,6 +6556,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '} <strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')} <strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
</p> </p>
{/* TCI receive audio EXPERIMENTAL, and the panel says so.
A SunSDR already carries its receive audio on the WebSocket that
carries its commands, so none of the devices above need to exist
for it: no virtual cable, no second sound card. This is the test
bench for that path it opens the stream and reports what really
arrives, because "the stream is open" and "audio is arriving" are
different claims and only the second one is worth anything. */}
<div className="rounded-md border border-border p-3 space-y-2">
<div className="text-xs font-medium">{t('aud.tciTitle')}</div>
<div className="flex items-center gap-3 flex-wrap">
<Button variant={tciAudio.running ? 'default' : 'outline'} size="sm" className="h-8"
onClick={() => {
const p = tciAudio.running ? StopTCIAudio() : StartTCIAudio(0, 48000);
p.then(() => GetTCIAudioStatus().then(setTciAudio))
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })));
}}>
{tciAudio.running ? t('aud.tciStop') : t('aud.tciStart')}
</Button>
{tciAudio.running && (
<span className="text-[11px] font-mono text-muted-foreground">
{tciAudio.sample_rate || 0} Hz · {tciAudio.frames || 0} frames ·{' '}
{tciAudio.peak_db > -90 ? tciAudio.peak_db.toFixed(1) + ' dBFS' : t('aud.tciSilent')}
</span>
)}
</div>
{/* The test that actually settles it. Frames arriving proves a
socket is delivering bytes; it says nothing about whether those
bytes are the receiver's audio, at the right rate, in the right
order. A file the operator can PLAY says all three at once. */}
{tciAudio.running && (
<div className="flex items-center gap-3 flex-wrap">
<Button variant="outline" size="sm" className="h-8" disabled={tciRecBusy}
onClick={() => {
setTciRecBusy(true); setTciRecPath('');
RecordTCIAudio(10)
.then((p: string) => setTciRecPath(p))
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
.finally(() => setTciRecBusy(false));
}}>
{tciRecBusy ? t('aud.tciRecBusy') : t('aud.tciRec')}
</Button>
{!!tciRecPath && <span className="text-[11px] font-mono text-muted-foreground truncate">{tciRecPath}</span>}
</div>
)}
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox className="mt-0.5" checked={tciRec}
onCheckedChange={(c) => { setTciRec(!!c); SetTCIRecordAudio(!!c).catch(() => {}); }} />
<span>
{t('aud.tciRecord')}
<span className="block text-muted-foreground">{t('aud.tciRecordHint')}</span>
</span>
</label>
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
variant={monitorOn ? 'default' : 'outline'} variant={monitorOn ? 'default' : 'outline'}
+2 -2
View File
@@ -494,7 +494,7 @@ const en: Dict = {
'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)', 'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)',
'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.', 'aud.fromRadioShort': 'From Radio', 'aud.toRadioShort': 'To Radio', 'aud.explainFrom': '= what you receive (used by the QSO recorder).', 'aud.explainTo': '= where voice-keyer messages are transmitted.',
'aud.monitorTitle': "Hear the rig's RX audio (From Radio) through your Listening device", 'aud.listenRadio': '▶ Listen to radio', 'aud.stopListening': '■ Stop listening', 'aud.monitorTitle': "Hear the rig's RX audio (From Radio) through your Listening device", 'aud.listenRadio': '▶ Listen to radio', 'aud.stopListening': '■ Stop listening',
'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).', 'aud.monitorOn': 'RX monitor running — From Radio → Listening device.', 'aud.tciTitle': 'SunSDR receive audio over TCI (experimental)', 'aud.tciStart': 'Open the stream', 'aud.tciStop': 'Close the stream', 'aud.tciRec': 'Record 10 s to listen', 'aud.tciRecBusy': 'Recording…', 'aud.tciSilent': 'silent', 'aud.tciRecord': 'Record QSOs from this stream', 'aud.tciRecordHint': 'The QSO recorder takes the receive audio from the radio instead of a sound card — no virtual cable, nothing to select above. Your microphone is still recorded from the device chosen there.', 'aud.tciHint': 'Takes the receive audio straight from the radio over TCI, with no virtual audio cable and no second sound card. Receive only for now — the voice keyer still uses the devices above. Requires the CAT backend to be TCI.', 'aud.monitorHint': 'Live-monitor the rig here (USB codec now; network audio later).',
'aud.txTitle': 'Key PTT and pipe your live mic into the rig (To Radio device)', 'aud.talkRadio': '🎙 Talk to radio (TX)', 'aud.stopTalk': '■ Stop talking (TX)', 'aud.txTitle': 'Key PTT and pipe your live mic into the rig (To Radio device)', 'aud.talkRadio': '🎙 Talk to radio (TX)', 'aud.stopTalk': '■ Stop talking (TX)',
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).', 'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…', 'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
@@ -962,7 +962,7 @@ const fr: Dict = {
'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)', 'aud.noneDefault': '— aucun / défaut système —', 'aud.defaultTag': '(défaut)',
'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.', 'aud.fromRadioShort': 'Depuis la radio', 'aud.toRadioShort': 'Vers la radio', 'aud.explainFrom': "= ce que vous recevez (utilisé par l'enregistreur de QSO).", 'aud.explainTo': '= où sont émis les messages du manipulateur vocal.',
'aud.monitorTitle': "Écouter l'audio RX du poste (Depuis la radio) sur votre périphérique d'écoute", 'aud.listenRadio': '▶ Écouter la radio', 'aud.stopListening': "■ Arrêter l'écoute", 'aud.monitorTitle': "Écouter l'audio RX du poste (Depuis la radio) sur votre périphérique d'écoute", 'aud.listenRadio': '▶ Écouter la radio', 'aud.stopListening': "■ Arrêter l'écoute",
'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).", 'aud.monitorOn': "Écoute RX active — Depuis la radio → périphérique d'écoute.", 'aud.tciTitle': 'Audio de réception SunSDR par TCI (expérimental)', 'aud.tciStart': 'Ouvrir le flux', 'aud.tciStop': 'Fermer le flux', 'aud.tciRec': 'Enregistrer 10 s pour écoute', 'aud.tciRecBusy': 'Enregistrement…', 'aud.tciSilent': 'silence', 'aud.tciRecord': 'Enregistrer les QSO depuis ce flux', 'aud.tciRecordHint': "L'enregistreur prend l'audio de réception sur la radio au lieu d'une carte son — aucun câble virtuel, rien à choisir au-dessus. Ton micro reste enregistré depuis le périphérique sélectionné là-haut.", 'aud.tciHint': "Prend l'audio de réception directement sur la radio via TCI, sans câble audio virtuel ni seconde carte son. Réception seulement pour l'instant — le manipulateur vocal utilise toujours les périphériques ci-dessus. Nécessite le CAT réglé sur TCI.", 'aud.monitorHint': "Écoute directe du poste (codec USB pour l'instant ; audio réseau plus tard).",
'aud.txTitle': 'Activer le PTT et envoyer votre micro vers le poste (périphérique « Vers la radio »)', 'aud.talkRadio': '🎙 Parler à la radio (TX)', 'aud.stopTalk': '■ Arrêter de parler (TX)', 'aud.txTitle': 'Activer le PTT et envoyer votre micro vers le poste (périphérique « Vers la radio »)', 'aud.talkRadio': '🎙 Parler à la radio (TX)', 'aud.stopTalk': '■ Arrêter de parler (TX)',
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).", 'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…', 'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.26.13'; export const APP_VERSION = '0.26.12';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+12 -2
View File
@@ -575,6 +575,10 @@ export function GetStationSettings():Promise<main.StationSettings>;
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>; export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTCIAudioStatus():Promise<cat.TCIAudioStatus>;
export function GetTCIRecordAudio():Promise<boolean>;
export function GetTelemetryEnabled():Promise<boolean>; export function GetTelemetryEnabled():Promise<boolean>;
export function GetTrackedAwards():Promise<Array<string>>; export function GetTrackedAwards():Promise<Array<string>>;
@@ -803,8 +807,6 @@ export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):
export function NetUpdateActive(arg1:qso.QSO):Promise<void>; export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function NudgeKenwoodRIT(arg1:number):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenAwardsFolder():Promise<void>; export function OpenAwardsFolder():Promise<void>;
@@ -905,6 +907,8 @@ export function RecomputeAllAwardRefs():Promise<number>;
export function RecomputeAwardRefsForCode(arg1:string):Promise<number>; export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RecordTCIAudio(arg1:number):Promise<string>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshKenwood():Promise<void>; export function RefreshKenwood():Promise<void>;
@@ -1157,6 +1161,8 @@ export function SetSpotMax(arg1:number):Promise<void>;
export function SetSpotTTLMinutes(arg1:number):Promise<void>; export function SetSpotTTLMinutes(arg1:number):Promise<void>;
export function SetTCIRecordAudio(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>; export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
@@ -1209,10 +1215,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>; export function StartCWDecoder():Promise<void>;
export function StartTCIAudio(arg1:number,arg2:number):Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>; export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>; export function StopCWDecoder():Promise<void>;
export function StopTCIAudio():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncFolderNow():Promise<number>; export function SyncFolderNow():Promise<number>;
+24 -4
View File
@@ -1090,6 +1090,14 @@ export function GetStationStatus() {
return window['go']['main']['App']['GetStationStatus'](); return window['go']['main']['App']['GetStationStatus']();
} }
export function GetTCIAudioStatus() {
return window['go']['main']['App']['GetTCIAudioStatus']();
}
export function GetTCIRecordAudio() {
return window['go']['main']['App']['GetTCIRecordAudio']();
}
export function GetTelemetryEnabled() { export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled'](); return window['go']['main']['App']['GetTelemetryEnabled']();
} }
@@ -1546,10 +1554,6 @@ export function NetUpdateActive(arg1) {
return window['go']['main']['App']['NetUpdateActive'](arg1); return window['go']['main']['App']['NetUpdateActive'](arg1);
} }
export function NudgeKenwoodRIT(arg1) {
return window['go']['main']['App']['NudgeKenwoodRIT'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
@@ -1750,6 +1754,10 @@ export function RecomputeAwardRefsForCode(arg1) {
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1); return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
} }
export function RecordTCIAudio(arg1) {
return window['go']['main']['App']['RecordTCIAudio'](arg1);
}
export function RefreshCtyDat() { export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
@@ -2254,6 +2262,10 @@ export function SetSpotTTLMinutes(arg1) {
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1); return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
} }
export function SetTCIRecordAudio(arg1) {
return window['go']['main']['App']['SetTCIRecordAudio'](arg1);
}
export function SetTelemetryEnabled(arg1) { export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1); return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
} }
@@ -2358,6 +2370,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder'](); return window['go']['main']['App']['StartCWDecoder']();
} }
export function StartTCIAudio(arg1, arg2) {
return window['go']['main']['App']['StartTCIAudio'](arg1, arg2);
}
export function StationSetRelay(arg1, arg2, arg3) { export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3); return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
} }
@@ -2366,6 +2382,10 @@ export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder'](); return window['go']['main']['App']['StopCWDecoder']();
} }
export function StopTCIAudio() {
return window['go']['main']['App']['StopTCIAudio']();
}
export function SwitchCATRig(arg1) { export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
+22 -2
View File
@@ -1094,7 +1094,6 @@ export namespace cat {
antenna: number; antenna: number;
rit: boolean; rit: boolean;
xit: boolean; xit: boolean;
rit_offset: number;
key_speed: number; key_speed: number;
meters_provisional: boolean; meters_provisional: boolean;
@@ -1130,7 +1129,6 @@ export namespace cat {
this.antenna = source["antenna"]; this.antenna = source["antenna"];
this.rit = source["rit"]; this.rit = source["rit"];
this.xit = source["xit"]; this.xit = source["xit"];
this.rit_offset = source["rit_offset"];
this.key_speed = source["key_speed"]; this.key_speed = source["key_speed"];
this.meters_provisional = source["meters_provisional"]; this.meters_provisional = source["meters_provisional"];
} }
@@ -1210,6 +1208,28 @@ export namespace cat {
this.fixed = source["fixed"]; this.fixed = source["fixed"];
} }
} }
export class TCIAudioStatus {
running: boolean;
sample_rate: number;
frames: number;
samples: number;
peak_db: number;
last_err?: string;
static createFrom(source: any = {}) {
return new TCIAudioStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.sample_rate = source["sample_rate"];
this.frames = source["frames"];
this.samples = source["samples"];
this.peak_db = source["peak_db"];
this.last_err = source["last_err"];
}
}
export class YaesuTXState { export class YaesuTXState {
available: boolean; available: boolean;
model?: string; model?: string;
+9
View File
@@ -0,0 +1,9 @@
package audio
// RecorderSampleRate is the rate the QSO recorder works in.
//
// Exported because a source that is NOT a sound card — the TCI receive stream,
// the Icom network audio — has to resample into it, and hard-coding 16000 at
// each of those call sites is how one of them ends up at the wrong speed after
// this constant is ever changed.
const RecorderSampleRate = sampleRate
+1
View File
@@ -14,6 +14,7 @@ import (
// any device regardless of its native mix format. // any device regardless of its native mix format.
const ( const (
sampleRate = 16000 sampleRate = 16000
channels = 1 channels = 1
bitsPerSample = 16 bitsPerSample = 16
blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2) blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)
+1 -22
View File
@@ -91,18 +91,11 @@ type Kenwood struct {
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go) keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
// Commands this rig answered "?;" to — asked once, then never again. // Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool unsupported map[string]bool
// noLatch suspends that memory while a refusal is expected to be about
// timing rather than capability (see ask).
noLatch bool
// Panel state — the K3/K4 control panel, see kenwood_panel.go. Read on the // Panel state — the K3/K4 control panel, see kenwood_panel.go. Read on the
// same serialised link as everything else, on a slow beat for the settings // same serialised link as everything else, on a slow beat for the settings
// and every poll for the meters. // and every poll for the meters.
panel KenwoodTXState panel KenwoodTXState
// The icon/status word, for working out which bit says "ATU in line" — see
// probeIcons. Kept so only CHANGES are logged.
lastIcons string
iconProbes int
panelCycle int panelCycle int
panelLoaded bool panelLoaded bool
metersLogged int metersLogged int
@@ -316,15 +309,6 @@ func (k *Kenwood) ReadState() (RigState, error) {
if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second { if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second {
s := k.lastState s := k.lastState
s.Connected = true s.Connected = true
// The panel still has to be told the radio is transmitting, and the
// transmit meters still have to be read — this early return used to skip
// both. The panel therefore showed MOX unlit while the rig was keyed, so
// pressing it again sent ANOTHER transmit command instead of unkeying,
// and the K3 stayed in TX. The power and SWR bars never moved either,
// for the same reason: the only moment they mean anything is the one
// moment they were not being read.
k.panel.Transmitting = true
k.readTXMeters()
return s, nil return s, nil
} }
raw, err := k.ask("IF;") raw, err := k.ask("IF;")
@@ -679,12 +663,7 @@ func (k *Kenwood) ask(cmd string) (string, error) {
// NOT "unsupported". Latching them off would blind the poll loop for // NOT "unsupported". Latching them off would blind the poll loop for
// good and read as "lost the rig". Only remember the OPTIONAL commands // good and read as "lost the rig". Only remember the OPTIONAL commands
// (FR/FT/…) so the poll loop stops paying a 600 ms timeout for those. // (FR/FT/…) so the poll loop stops paying a 600 ms timeout for those.
// While TRANSMITTING the rig refuses a great deal that it answers if want != "IF" && want != "ID" {
// perfectly well on receive, so a "?;" then says nothing about what
// the radio supports. Latching it would silence a meter for the rest
// of the session on the strength of one badly timed question — and
// the meters are read precisely while transmitting.
if want != "IF" && want != "ID" && !k.noLatch {
k.unsupported[want] = true k.unsupported[want] = true
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd) debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
} }
+3 -99
View File
@@ -68,14 +68,9 @@ type KenwoodTXState struct {
// offer a switch that goes nowhere. // offer a switch that goes nowhere.
Antenna int `json:"antenna"` Antenna int `json:"antenna"`
RIT bool `json:"rit"` RIT bool `json:"rit"`
XIT bool `json:"xit"` XIT bool `json:"xit"`
// RITOffset is the RIT/XIT offset in Hz — the number the buttons move and KeySpeed int `json:"key_speed"` // WPM
// the one an operator is actually reading when they look at RIT at all. A
// lit RIT button with no offset beside it says the feature is on and
// nothing about where it has put the receiver.
RITOffset int `json:"rit_offset"`
KeySpeed int `json:"key_speed"` // WPM
// MetersProvisional says the meter scaling has not been confirmed against a // MetersProvisional says the meter scaling has not been confirmed against a
// real radio. The panel says so rather than presenting a guess as a // real radio. The panel says so rather than presenting a guess as a
@@ -102,7 +97,6 @@ type KenwoodPanelController interface {
SetKenwoodAntenna(int) error SetKenwoodAntenna(int) error
SetKenwoodRIT(bool) error SetKenwoodRIT(bool) error
SetKenwoodXIT(bool) error SetKenwoodXIT(bool) error
NudgeKenwoodRIT(int) error
ClearKenwoodRIT() error ClearKenwoodRIT() error
SetKenwoodTX(bool) error SetKenwoodTX(bool) error
TuneKenwoodATU() error TuneKenwoodATU() error
@@ -228,16 +222,9 @@ func (k *Kenwood) readPanelSettings() {
if v, ok := k.askNum("XT;", "XT", 1); ok { if v, ok := k.askNum("XT;", "XT", 1); ok {
k.panel.XIT = v != 0 k.panel.XIT = v != 0
} }
// RO carries a sign, so it is read as a whole rather than through askNum.
if r, err := k.ask("RO;"); err == nil {
if hz, ok := parseKenwoodOffset(r); ok {
k.panel.RITOffset = hz
}
}
if v, ok := k.askNum("KS;", "KS", 3); ok { if v, ok := k.askNum("KS;", "KS", 3); ok {
k.panel.KeySpeed = v k.panel.KeySpeed = v
} }
k.probeIcons()
} }
// The full-scale value of the analogue controls differs between an Elecraft and // The full-scale value of the analogue controls differs between an Elecraft and
@@ -323,10 +310,6 @@ var kenwoodMeterProbes = []struct {
// readTXMeters reads the transmit meters. // readTXMeters reads the transmit meters.
func (k *Kenwood) readTXMeters() { func (k *Kenwood) readTXMeters() {
now := time.Now() now := time.Now()
// Refusals here are about WHEN the question was asked, not about what the
// radio can do: a K3 says "?;" to plenty while the carrier is up.
k.noLatch = true
defer func() { k.noLatch = false }()
if v, ok := k.askNum("BG;", "BG", 2); ok { if v, ok := k.askNum("BG;", "BG", 2); ok {
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now) k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
} }
@@ -413,31 +396,6 @@ func (k *Kenwood) SetKenwoodAFGain(p int) error {
return k.setPanel(fmt.Sprintf("AG%03d;", p*255/100)) return k.setPanel(fmt.Sprintf("AG%03d;", p*255/100))
} }
// probeIcons logs the K3's icon/status word whenever it changes.
//
// There is no command that asks "is the ATU in line": the reference says the
// front-panel switch functions show up as icon changes readable through IC (or
// DS), which means the answer is a bit in a word nobody here can name yet. So
// the word is logged when it changes, and an operator toggling the ATU while
// watching the log hands us the bit — after which the ATU button can light up
// honestly instead of guessing from what it last sent.
func (k *Kenwood) probeIcons() {
if k.iconProbes >= 40 {
return
}
r, err := k.ask("IC;")
if err != nil {
k.iconProbes = 40 // this rig has no IC; stop asking
return
}
if r == k.lastIcons {
return
}
k.lastIcons = r
k.iconProbes++
debugLog.Printf("kenwood: icon status changed → %s (toggle ATU/PRE/ATT while watching this line to name the bits)", r)
}
// SetKenwoodRFGain sets the RF gain, 0-100 of the rig's own scale. // SetKenwoodRFGain sets the RF gain, 0-100 of the rig's own scale.
func (k *Kenwood) SetKenwoodRFGain(p int) error { func (k *Kenwood) SetKenwoodRFGain(p int) error {
return k.setPanel(fmt.Sprintf("RG%03d;", clampPercentTo(p, k.rfGainFull()))) return k.setPanel(fmt.Sprintf("RG%03d;", clampPercentTo(p, k.rfGainFull())))
@@ -498,60 +456,6 @@ func (k *Kenwood) SetKenwoodAntenna(n int) error {
return k.setPanel(fmt.Sprintf("AN%d;", n)) return k.setPanel(fmt.Sprintf("AN%d;", n))
} }
// parseKenwoodOffset reads "RO+0100;" / "RO-0250;" into Hz.
func parseKenwoodOffset(frame string) (int, bool) {
body := strings.TrimSuffix(strings.TrimPrefix(frame, "RO"), ";")
if len(body) < 2 {
return 0, false
}
sign := 1
switch body[0] {
case '-':
sign = -1
body = body[1:]
case '+':
body = body[1:]
}
n, err := strconv.Atoi(strings.TrimSpace(body))
if err != nil {
return 0, false
}
return sign * n, true
}
// NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz.
//
// The offset is SET rather than stepped, because RO is where the radio keeps
// it and stepping commands differ across the family. The panel's own reading is
// the starting point, so two quick presses do not both start from the same
// stale value.
func (k *Kenwood) NudgeKenwoodRIT(delta int) error {
k.mu.Lock()
cur := k.panel.RITOffset
k.mu.Unlock()
next := cur + delta
// A pile-up is chased with a few hundred hertz of RIT; ±5 kHz is already
// past anything an offset is for, and past what the rig accepts.
if next > 5000 {
next = 5000
}
if next < -5000 {
next = -5000
}
sign := "+"
if next < 0 {
sign = "-"
}
mag := next
if mag < 0 {
mag = -mag
}
k.mu.Lock()
k.panel.RITOffset = next // optimistic, like the sliders
k.mu.Unlock()
return k.setPanel(fmt.Sprintf("RO%s%04d;", sign, mag))
}
func (k *Kenwood) SetKenwoodRIT(on bool) error { func (k *Kenwood) SetKenwoodRIT(on bool) error {
return k.setPanel(fmt.Sprintf("RT%d;", boolDigit(on))) return k.setPanel(fmt.Sprintf("RT%d;", boolDigit(on)))
} }
+24 -1
View File
@@ -34,6 +34,11 @@ type TCI struct {
OnSpotClick func(callsign string, freqHz int64) OnSpotClick func(callsign string, freqHz int64)
unhandledSeen map[string]bool // log each unknown TCI message type once unhandledSeen map[string]bool // log each unknown TCI message type once
// audio holds the receive-audio stream — see tci_audio.go. TCI carries it
// on this same WebSocket, which is what lets a SunSDR record and decode
// without a virtual audio cable in the way.
audio tciAudio
mu sync.Mutex // guards conn + writes + state mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn conn *websocket.Conn
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop) dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
@@ -354,10 +359,18 @@ func (t *TCI) send(cmd string) error {
// connection closes. // connection closes.
func (t *TCI) reader(conn *websocket.Conn) { func (t *TCI) reader(conn *websocket.Conn) {
for { for {
_, data, err := conn.ReadMessage() mt, data, err := conn.ReadMessage()
if err != nil { if err != nil {
break break
} }
// TEXT frames are commands, BINARY frames are streams. The type used to
// be ignored and every frame split on ';' — harmless only for as long as
// no stream was ever opened, since audio bytes would then have been fed
// to the command parser a hundred times a second.
if wsMessageIsBinary(mt) {
t.handleBinary(data)
continue
}
// A frame may carry several ";"-terminated commands. // A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") { for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd)) t.handle(strings.TrimSpace(cmd))
@@ -393,6 +406,16 @@ func (t *TCI) handle(msg string) {
switch strings.ToLower(name) { switch strings.ToLower(name) {
case "device": case "device":
t.device = strings.TrimSpace(args) t.device = strings.TrimSpace(args)
// The radio ANNOUNCES its audio format at connect —
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
// is better evidence than anything derived from a frame, and it arrives
// before the first frame does. Both were being logged as unhandled.
case "audio_stream_sample_type":
t.audio.declaredType = strings.TrimSpace(args)
case "audio_stream_channels":
if n, err := strconv.Atoi(strings.TrimSpace(args)); err == nil && n > 0 && n <= 8 {
t.audio.declaredChans = n
}
case "ready", "start": case "ready", "start":
t.ready = true t.ready = true
case "stop": case "stop":
+335
View File
@@ -0,0 +1,335 @@
//go:build windows
package cat
// TCI audio — receiving the radio's audio over the same WebSocket that carries
// the commands, so a SunSDR needs no virtual audio cable.
//
// TCI mixes two kinds of frame on one socket: TEXT frames are the commands
// ("trx:0,true;"), BINARY frames are streams. A binary frame is a fixed header
// followed by float32 samples:
//
// uint32 receiver which receiver the stream belongs to
// uint32 sampleRate Hz
// uint32 format 0 = float32
// uint32 codec 0 = uncompressed
// uint32 crc unused in practice
// uint32 length samples in the payload
// uint32 type which stream this is (see tciStream*)
// uint32 reserved[9]
// float32 payload[…] stereo, interleaved
//
// The stream is asked for with "audio_samplerate:" then "audio_start:<rx>;",
// and stopped with "audio_stop:<rx>;".
//
// NOTHING HERE IS CONFIRMED ON A RADIO YET. The layout above is read from the
// TCI documentation, and the stream-type numbers in particular are the sort of
// detail a document gets right and a memory of it does not — so every header is
// logged for the first few seconds of a session, and the numbers the radio
// actually sends will settle it. Same discipline as the Yaesu meters and the
// Flex spot feed: measure on the real thing, then write the constant down.
import (
"encoding/binary"
"fmt"
"math"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TCI stream types. RX audio is the one this file consumes; the others are
// named so a log line says what arrived rather than "type 3".
const (
tciStreamIQ = 0
tciStreamRXAudio = 1
tciStreamTXAudio = 2
tciStreamTXChrono = 3
)
// tciHeaderWords is the header length in uint32 words (7 named + 9 reserved).
const tciHeaderWords = 16
// tciHeaderBytes is the same in bytes.
const tciHeaderBytes = tciHeaderWords * 4
// tciAudioProbeMax bounds the header logging. Enough frames to see the shape
// and the rate; few enough that an evening of listening does not fill the log.
const tciAudioProbeMax = 40
// TCIAudioStatus is what the panel polls while testing the stream.
type TCIAudioStatus struct {
Running bool `json:"running"`
SampleRate int `json:"sample_rate"`
Frames int64 `json:"frames"` // binary frames accepted
Samples int64 `json:"samples"` // audio samples decoded
// PeakDB is the loudest sample of the last second, in dBFS: the one number
// that says "audio is really arriving" rather than "a socket is open".
PeakDB float64 `json:"peak_db"`
LastErr string `json:"last_err,omitempty"`
}
// tciAudio is the receive-side state, kept on the backend so it lives exactly
// as long as the connection does.
type tciAudio struct {
mu sync.Mutex
want bool // the host asked for audio
rx int // which receiver
rate int
frames int64
samples int64
peak float64
peakAt time.Time
probeByType map[int]int
lastErr string
// widthLogged keeps the one-line note about the sample width to once a
// session — it is a fact about the radio, not an event.
widthLogged bool
// What the radio SAID about its stream at connect (audio_stream_sample_type,
// audio_stream_channels). Its own declaration, and it arrives before the
// first frame — the frame arithmetic below stays as the check on it rather
// than as the only source.
declaredType string
declaredChans int
// OnSamples receives decoded MONO samples (the two channels averaged) at
// the negotiated rate. Mono because everything downstream — the QSO
// recorder, the CW decoder — works on one channel, and a receiver's two
// channels carry the same audio.
OnSamples func(rate int, samples []float32)
}
// StartTCIAudio asks the radio to stream receiver rx's audio.
func (t *TCI) StartTCIAudio(rx, rate int) error {
if rate <= 0 {
rate = 48000
}
t.audio.mu.Lock()
t.audio.want = true
t.audio.rx = rx
t.audio.rate = rate
t.audio.frames, t.audio.samples, t.audio.peak = 0, 0, 0
t.audio.lastErr = ""
t.audio.mu.Unlock()
// Sample rate first: the radio applies it to the stream it is about to
// open, and asking afterwards restarts the stream on some firmware.
if err := t.send(fmt.Sprintf("audio_samplerate:%d;", rate)); err != nil {
return err
}
return t.send(fmt.Sprintf("audio_start:%d;", rx))
}
// SetTCIAudioSink installs (or removes) the consumer of the decoded samples.
//
// One sink, not a list: today it is a test recording, tomorrow the QSO
// recorder, and two consumers of a live stream would need a policy about which
// one wins that nothing yet has an opinion about.
func (t *TCI) SetTCIAudioSink(fn func(rate int, samples []float32)) {
t.audio.mu.Lock()
t.audio.OnSamples = fn
t.audio.mu.Unlock()
}
// StopTCIAudio closes the stream.
func (t *TCI) StopTCIAudio() error {
t.audio.mu.Lock()
t.audio.want = false
rx := t.audio.rx
t.audio.mu.Unlock()
return t.send(fmt.Sprintf("audio_stop:%d;", rx))
}
// TCIAudioStatus reports what has arrived.
func (t *TCI) TCIAudioStatus() TCIAudioStatus {
t.audio.mu.Lock()
defer t.audio.mu.Unlock()
st := TCIAudioStatus{
Running: t.audio.want,
SampleRate: t.audio.rate,
Frames: t.audio.frames,
Samples: t.audio.samples,
LastErr: t.audio.lastErr,
}
// A peak older than a second is not a level, it is a memory. Reported as
// silence rather than left standing, so a stream that has stopped arriving
// looks stopped.
if time.Since(t.audio.peakAt) < time.Second && t.audio.peak > 0 {
st.PeakDB = 20 * math.Log10(t.audio.peak)
} else {
st.PeakDB = -99
}
return st
}
// handleBinary decodes one binary WebSocket frame.
//
// Called from the reader goroutine. Anything malformed is counted and dropped:
// a stream frame is not worth breaking the command connection over, and the
// command connection is what keeps the radio usable.
func (t *TCI) handleBinary(data []byte) {
if len(data) < tciHeaderBytes {
t.audioErr(fmt.Sprintf("binary frame of %d bytes is shorter than a header", len(data)))
return
}
le := binary.LittleEndian
receiver := int(le.Uint32(data[0:]))
rate := int(le.Uint32(data[4:]))
format := le.Uint32(data[8:])
codec := le.Uint32(data[12:])
length := int(le.Uint32(data[20:]))
stype := int(le.Uint32(data[24:]))
// Counted PER STREAM TYPE, not overall.
//
// A single counter was spent on the first forty receive-audio frames, which
// arrive twenty-four times a second — so a transmit-chrono or transmit-audio
// frame, the two this needs to see before the voice keyer can be written,
// would never have been logged at all. They only appear once the operator
// keys the radio, long after any global budget is gone.
t.audio.mu.Lock()
if t.audio.probeByType == nil {
t.audio.probeByType = map[int]int{}
}
probe := t.audio.probeByType[stype]
if probe < tciAudioProbeMax {
t.audio.probeByType[stype]++
}
t.audio.mu.Unlock()
if probe < tciAudioProbeMax {
debugLog.Printf("TCI: binary frame — rx=%d rate=%d format=%d codec=%d length=%d type=%d payload=%d bytes",
receiver, rate, format, codec, length, stype, len(data)-tciHeaderBytes)
}
if stype != tciStreamRXAudio {
// IQ, transmit audio, chrono. Nothing consumes them yet — but the chrono
// frames are what a voice keyer over TCI would have to answer, and their
// size and cadence cannot be guessed from the documentation. They are
// logged (per type, see above) and dropped.
return
}
if codec != 0 {
t.audioErr(fmt.Sprintf("stream is codec=%d, and nothing here decodes a compressed stream", codec))
return
}
// The FORMAT number is decided by measurement, not by the number itself.
//
// A real SunSDR answered format=3, where the code expected 0 — and 0 was a
// guess from reading the documentation, which is exactly the kind of detail
// a memory of a document gets wrong. Rather than swap one magic number for
// another, the sample width is derived from what arrived: the header says
// how many samples the payload holds, so the bytes per sample follow from
// dividing. That is true whatever number the format field carries, on this
// firmware and the next.
payload := data[tciHeaderBytes:]
if len(payload) == 0 || length <= 0 {
return
}
width := len(payload) / length
var n int
switch width {
case 4:
n = len(payload) / 4 // float32
case 2:
n = len(payload) / 2 // 16-bit PCM
default:
t.audioErr(fmt.Sprintf("frame carries %d bytes for %d samples (format=%d) — not a width this reads",
len(payload), length, format))
return
}
if n == 0 {
return
}
// Under the lock like the rest of the counters: the reader is the only
// writer today, but a fact about the radio that is read from another
// goroutine has no business being the one field left unguarded.
t.audio.mu.Lock()
first := !t.audio.widthLogged
t.audio.widthLogged = true
t.audio.mu.Unlock()
if first {
debugLog.Printf("TCI: audio is %d bytes per sample at %d Hz (format field says %d)", width, rate, format)
}
// Stereo interleaved → mono. Both channels of a receiver carry the same
// audio, and everything downstream works on one.
// How many channels are interleaved. The radio says so at connect; two is
// the fallback, which is what every SunSDR seen so far streams.
t.audio.mu.Lock()
chans := t.audio.declaredChans
t.audio.mu.Unlock()
if chans <= 0 {
chans = 2
}
mono := make([]float32, 0, n/chans+1)
var peak float64
sample := func(i int) float32 {
if width == 2 {
// 16-bit PCM, scaled to the same -1…1 the rest of the audio path
// works in, so a change of format cannot change what a level means.
return float32(int16(le.Uint16(payload[i*2:]))) / 32768
}
return math.Float32frombits(le.Uint32(payload[i*4:]))
}
for i := 0; i+chans-1 < n; i += chans {
var sum float32
for c := 0; c < chans; c++ {
sum += sample(i + c)
}
v := sum / float32(chans)
if a := math.Abs(float64(v)); a > peak {
peak = a
}
mono = append(mono, v)
}
t.audio.mu.Lock()
t.audio.frames++
t.audio.samples += int64(len(mono))
if rate > 0 {
t.audio.rate = rate
}
if peak > t.audio.peak || time.Since(t.audio.peakAt) > time.Second {
t.audio.peak = peak
t.audio.peakAt = time.Now()
}
cb := t.audio.OnSamples
t.audio.mu.Unlock()
if cb != nil {
cb(rate, mono)
}
}
// audioErr records a decoding complaint, once, so the panel can show it without
// the log filling with the same line at fifty frames a second.
func (t *TCI) audioErr(msg string) {
t.audio.mu.Lock()
first := t.audio.lastErr != msg
t.audio.lastErr = msg
t.audio.mu.Unlock()
if first {
debugLog.Printf("TCI: audio: %s", msg)
}
}
// resumeAudio re-opens the stream after a reconnect, if the host had asked for
// it. A dropped WebSocket takes the audio with it, and an operator who switched
// recording on does not expect to switch it on again.
func (t *TCI) resumeAudio() {
t.audio.mu.Lock()
want, rx, rate := t.audio.want, t.audio.rx, t.audio.rate
t.audio.mu.Unlock()
if !want {
return
}
if err := t.StartTCIAudio(rx, rate); err != nil {
debugLog.Printf("TCI: re-opening the audio stream failed: %v", err)
}
}
// wsMessageIsBinary keeps the type test in one place — the reader used to
// ignore the message type entirely and split every frame on ';', which would
// have fed audio bytes to the command parser the moment a stream was opened.
func wsMessageIsBinary(mt int) bool { return mt == websocket.BinaryMessage }
+39
View File
@@ -0,0 +1,39 @@
//go:build windows
package cat
import "fmt"
// TCIAudioController is the receive-audio capability of the TCI backend, kept
// as an interface for the same reason as the Flex and Yaesu ones: the host asks
// the manager, and a station running something else gets a clear "this backend
// does not do that" instead of a nil dereference.
type TCIAudioController interface {
StartTCIAudio(rx, rate int) error
StopTCIAudio() error
TCIAudioStatus() TCIAudioStatus
}
// TCIAudioState returns the stream's state, or (zero, false) when the active
// backend is not a TCI radio.
func (m *Manager) TCIAudioState() (TCIAudioStatus, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if tc, ok := b.(TCIAudioController); ok {
return tc.TCIAudioStatus(), true
}
return TCIAudioStatus{}, false
}
// TCIAudioDo dispatches an audio command onto the CAT goroutine, like every
// other backend-specific control.
func (m *Manager) TCIAudioDo(fn func(TCIAudioController) error) error {
return m.exec(func(b Backend) error {
tc, ok := b.(TCIAudioController)
if !ok {
return fmt.Errorf("active CAT backend is not a TCI radio")
}
return fn(tc)
})
}
@@ -1,52 +0,0 @@
package udp
import (
"net"
"testing"
)
// Two copies of MSHV call themselves "MSHV". WSJT-X refuses to start a second
// instance without --rig-name, so its ids differ; MSHV has no such rule, and
// everything a decode needs — the dial frequency, the T/R period, the mode —
// used to be filed under the id alone.
//
// The consequence was reported from a real station: the second copy, on LF,
// overwrote the dial of the first, and FT8 decodes made on 14.095 came out
// labelled 2190 m — which is 136 kHz plus an audio offset, to the hertz.
func TestSameProgramIDFromTwoAddressesAreTwoInstances(t *testing.T) {
s := &Server{}
a := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237}
b := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2238}
first := s.instanceLabel("MSHV", a)
second := s.instanceLabel("MSHV", b)
if first == second {
t.Fatalf("two applications on different sockets share the label %q", first)
}
if first != "MSHV" {
t.Fatalf("the first instance should keep the plain id, got %q", first)
}
// Stable: the same socket must keep its name for as long as it runs, or the
// decodes panel would grow a new column every time a packet arrived.
if again := s.instanceLabel("MSHV", a); again != first {
t.Fatalf("label changed for the same address: %q then %q", first, again)
}
if again := s.instanceLabel("MSHV", b); again != second {
t.Fatalf("label changed for the same address: %q then %q", second, again)
}
}
// Distinct ids stay distinct without decoration — a station running WSJT-X and
// MSHV should not see either renamed.
func TestDifferentProgramIDsAreLeftAlone(t *testing.T) {
s := &Server{}
a := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237}
b := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2238}
if got := s.instanceLabel("WSJT-X", a); got != "WSJT-X" {
t.Fatalf("WSJT-X was renamed to %q", got)
}
if got := s.instanceLabel("MSHV", b); got != "MSHV" {
t.Fatalf("MSHV was renamed to %q", got)
}
}
+12 -62
View File
@@ -212,21 +212,6 @@ type Server struct {
// lastFrom is the address each program's packets arrive from — where a Reply // lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply. // has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr lastFrom map[string]*net.UDPAddr
// instLabel names each running application, keyed by id AND sending address.
//
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
// does not: two copies both call themselves "MSHV", and everything below was
// keyed on that name alone — so the second copy's Status overwrote the
// first's dial frequency, and decodes from the 20 m instance were placed at
// the LF instance's frequency. Reported as FT8 decodes on 14.095 labelled
// 2190 m, which is exactly 136 kHz plus an audio offset.
//
// The label is what the rest of OpsLog sees: the plain id for the first
// instance, then "MSHV #2", "MSHV #3"… so the decodes panel can still tell
// them apart on screen.
instLabel map[string]string
// instSeq counts how many distinct instances have claimed each id.
instSeq map[string]int
// lastMode is the mode NAME from each program's last Status, used to resolve // lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker. // a Decode's one-character mode marker.
lastMode map[string]string lastMode map[string]string
@@ -389,40 +374,6 @@ func (s *Server) run() {
} }
} }
// instanceLabel returns the name for one running application, allocating it on
// first sight. Caller holds s.mu.
//
// The address is part of the identity because the id is not enough: two copies
// of MSHV send the same id from different sockets, and the two are different
// radios on different bands. The port is included — a program keeps its socket
// for as long as it runs, which is exactly the lifetime this has to be stable
// over.
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
if id == "" {
return ""
}
if remote == nil {
return id
}
key := id + "|" + remote.String()
if s.instLabel == nil {
s.instLabel = map[string]string{}
s.instSeq = map[string]int{}
}
if lbl, ok := s.instLabel[key]; ok {
return lbl
}
s.instSeq[id]++
lbl := id
if n := s.instSeq[id]; n > 1 {
lbl = fmt.Sprintf("%s #%d", id, n)
applog.Printf("udp: [%s] a second application calls itself %q (from %s) — it will be shown as %q",
s.cfg.Name, id, remote, lbl)
}
s.instLabel[key] = lbl
return lbl
}
// logBadPacket reports a datagram this listener could not parse, with enough of // logBadPacket reports a datagram this listener could not parse, with enough of
// it to identify the sender — then falls silent. // it to identify the sender — then falls silent.
// //
@@ -513,15 +464,14 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// to it. Per PROGRAM, not per listener: two receivers share one multicast // to it. Per PROGRAM, not per listener: two receivers share one multicast
// group, and a reply must reach the one that heard the station — and it // group, and a reply must reach the one that heard the station — and it
// must go to the sender's own address, never to the group. // must go to the sender's own address, never to the group.
s.mu.Lock() if w.ProgramID != "" && remote != nil {
inst := s.instanceLabel(w.ProgramID, remote) s.mu.Lock()
if inst != "" && remote != nil {
if s.lastFrom == nil { if s.lastFrom == nil {
s.lastFrom = map[string]*net.UDPAddr{} s.lastFrom = map[string]*net.UDPAddr{}
} }
s.lastFrom[inst] = remote s.lastFrom[w.ProgramID] = remote
s.mu.Unlock()
} }
s.mu.Unlock()
// Status carries the current dial frequency; remember it so Decode audio // Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter. // offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode { if w.FreqHz > 0 && !w.IsDecode {
@@ -529,7 +479,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.dialHz == nil { if s.dialHz == nil {
s.dialHz = map[string]int64{} s.dialHz = map[string]int64{}
} }
s.dialHz[inst] = w.FreqHz s.dialHz[w.ProgramID] = w.FreqHz
// The T/R period travels with Status, and a decode has to be told // The T/R period travels with Status, and a decode has to be told
// which slot it belongs to — so it is remembered per program the // which slot it belongs to — so it is remembered per program the
// same way the dial is. // same way the dial is.
@@ -537,7 +487,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.trPeriod == nil { if s.trPeriod == nil {
s.trPeriod = map[string]int{} s.trPeriod = map[string]int{}
} }
s.trPeriod[inst] = w.TRPeriod s.trPeriod[w.ProgramID] = w.TRPeriod
} }
// The mode NAME, which only Status carries: a Decode gives the // The mode NAME, which only Status carries: a Decode gives the
// one-character marker instead. See DecodeModeName. // one-character marker instead. See DecodeModeName.
@@ -545,7 +495,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.lastMode == nil { if s.lastMode == nil {
s.lastMode = map[string]string{} s.lastMode = map[string]string{}
} }
s.lastMode[inst] = w.Mode s.lastMode[w.ProgramID] = w.Mode
} }
s.mu.Unlock() s.mu.Unlock()
} }
@@ -557,13 +507,13 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.TxMessage = w.TxMessage ev.TxMessage = w.TxMessage
ev.Transmitting = w.Transmitting ev.Transmitting = w.Transmitting
ev.DECall = w.DECall ev.DECall = w.DECall
ev.ProgramID = inst ev.ProgramID = w.ProgramID
} }
if w.IsDecode { if w.IsDecode {
s.mu.Lock() s.mu.Lock()
dial := s.dialHz[inst] dial := s.dialHz[w.ProgramID]
tr := s.trPeriod[inst] tr := s.trPeriod[w.ProgramID]
statusMode := s.lastMode[inst] statusMode := s.lastMode[w.ProgramID]
s.mu.Unlock() s.mu.Unlock()
if dial <= 0 { if dial <= 0 {
// No Status from THIS instance yet. Guessing with another // No Status from THIS instance yet. Guessing with another
@@ -584,7 +534,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeTRPeriod = tr ev.DecodeTRPeriod = tr
ev.DecodeDial = dial ev.DecodeDial = dial
ev.DecodeOffAir = w.OffAir ev.DecodeOffAir = w.OffAir
ev.ProgramID = inst ev.ProgramID = w.ProgramID
ev.DecodeDT = w.DeltaTime ev.DecodeDT = w.DeltaTime
ev.DecodeAudioHz = w.DeltaFreqHz ev.DecodeAudioHz = w.DeltaFreqHz
ev.DecodeMs = w.DecodeMsSinceMidnight ev.DecodeMs = w.DecodeMsSinceMidnight
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.13" appVersion = "0.26.12"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.