diff --git a/app.go b/app.go
index 0f22ccd..a12c976 100644
--- a/app.go
+++ b/app.go
@@ -8350,8 +8350,49 @@ type AudioSettings struct {
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
// for the device dropdowns.
-func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() }
-func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() }
+// ListAudioInputDevices lists the microphones and line inputs, plus THE RADIO
+// when the CAT link carries its receive audio.
+//
+// Same reasoning as the output list: over TCI there is no sound device for
+// Windows to show, so without this the one correct answer to "where does the
+// received audio come from" could not be chosen at all.
+func (a *App) ListAudioInputDevices() ([]audio.Device, error) {
+ devs, err := audio.ListInputDevices()
+ if err != nil {
+ return devs, err
+ }
+ if a.tciAudioAvailable() {
+ devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (TCI network audio)"}}, devs...)
+ }
+ return devs, nil
+}
+
+// tciAudioAvailable says whether the active CAT backend is a radio that streams
+// its audio over the CAT link.
+func (a *App) tciAudioAvailable() bool {
+ if a.cat == nil {
+ return false
+ }
+ _, ok := a.cat.TCIAudioState()
+ return ok
+}
+
+// ListAudioOutputDevices lists the sound cards, plus THE RADIO ITSELF when the
+// CAT link can carry transmit audio.
+//
+// Offered only while it is actually available, and named as a radio rather than
+// as a protocol: an operator choosing where their voice goes is picking between
+// "my sound card" and "the radio", not between WASAPI and TCI.
+func (a *App) ListAudioOutputDevices() ([]audio.Device, error) {
+ devs, err := audio.ListOutputDevices()
+ if err != nil {
+ return devs, err
+ }
+ if audio.NetworkPlayerReady() {
+ devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (TCI network audio)"}}, devs...)
+ }
+ return devs, nil
+}
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) {
@@ -8453,6 +8494,15 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
return err
}
}
+ // Choosing the radio as the receive device opens its stream, and choosing
+ // anything else closes it. Done HERE rather than left to the next restart:
+ // a device chosen in a dropdown that only takes effect after a relaunch
+ // reads as a device that does not work.
+ if s.FromRadio == audio.NetworkDeviceID {
+ a.startTCIRecording()
+ } else if a.tciAudioAvailable() && a.settingOr(keyTCIRecAudio, "") != "1" {
+ _ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
+ }
// Apply device/preroll/enable changes to the running recorder.
a.startQSORecorderIfEnabled()
// And to a monitor ALREADY RUNNING: the operator is listening while they
@@ -8497,7 +8547,7 @@ func (a *App) startQSORecorderIfEnabled() {
// nothing right to point at. The stream is pushed into the recorder instead
// — same samples, no sound card in the middle, and no virtual cable to set up.
from := cfg.FromRadio
- a.qsoRecPushed = a.icomNetAudioActive()
+ a.qsoRecPushed = a.icomNetAudioActive() || cfg.FromRadio == audio.NetworkDeviceID
if a.qsoRecPushed {
from = audio.PushedSource
}
@@ -15178,6 +15228,12 @@ func (a *App) reloadCAT() {
} else {
a.catSig = sig
}
+ // Withdraw the radio as an audio output before deciding anything else. The
+ // TCI case below puts it back; every other backend, and a CAT link turned
+ // off entirely, leaves it withdrawn — a voice keyer that still lists a radio
+ // it can no longer reach would play a message to nowhere, and the operator
+ // hears their own PTT click and assumes it went out.
+ a.installTCITXPlayer(false)
if !s.Enabled {
a.cat.Stop()
return
@@ -15300,7 +15356,14 @@ func (a *App) reloadCAT() {
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
case "tci":
// 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()
+ // And the other direction: the voice keyer can send its messages over
+ // the same link — see app_tci_dvk.go.
+ defer a.installTCITXPlayer(true)
tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots)
// Clicking one of our spots on the ExpertSDR panorama fills the entry form.
tb.OnSpotClick = func(call string, hz int64) {
diff --git a/app_tci_audio.go b/app_tci_audio.go
new file mode 100644
index 0000000..c65ea1a
--- /dev/null
+++ b/app_tci_audio.go
@@ -0,0 +1,80 @@
+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
+}
+
+// ProbeTCITransmit keys the radio and pushes a tone over TCI, to find out
+// whether the transmit half of the stream works at all.
+//
+// A first real transmission proved that the radio sends nothing extra while the
+// OPERATOR keys it — 282 receive frames and no chrono over six seconds — so the
+// only way forward is to key it from here and watch. Everything interesting
+// lands in the log; see internal/cat/tci_tx_probe.go for what the two possible
+// answers mean.
+//
+// THIS TRANSMITS. The button that calls it says so, and the radio must be on a
+// dummy load.
+func (a *App) ProbeTCITransmit(seconds int) error {
+ if a.cat == nil {
+ return fmt.Errorf("CAT not initialized")
+ }
+ return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
+ p, ok := t.(interface {
+ ProbeTXStream(seconds int, toneHz float64) error
+ })
+ if !ok {
+ return fmt.Errorf("this CAT backend is not a TCI radio")
+ }
+ applog.Printf("tci: TX PROBE requested (%d s) — the radio should be on a dummy load", seconds)
+ return p.ProbeTXStream(seconds, 1000)
+ })
+}
diff --git a/app_tci_dvk.go b/app_tci_dvk.go
new file mode 100644
index 0000000..eab1075
--- /dev/null
+++ b/app_tci_dvk.go
@@ -0,0 +1,60 @@
+package main
+
+// The voice keyer, through the radio's own link.
+//
+// Selecting the radio as the "To radio" output makes the voice keyer hand its
+// messages to the CAT backend instead of a sound card. Everything around it is
+// unchanged — the same PTT before and after, the same gain, the same files —
+// which is the point: the audio takes a different road, not a different route.
+
+import (
+ "fmt"
+
+ "hamlog/internal/applog"
+ "hamlog/internal/audio"
+ "hamlog/internal/cat"
+)
+
+// tciTXPlayer hands one message to the radio.
+//
+// The controller is fetched on the CAT goroutine and the message is then played
+// OFF it. Playing on it would hold that goroutine for the length of the
+// message, and everything else about the rig — frequency, mode, PTT state —
+// goes through the same place: a ten-second call would freeze the display and
+// the antenna following for ten seconds. The TCI backend serialises its own
+// writes, so this is safe to call from here.
+func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
+ if a.cat == nil {
+ return fmt.Errorf("CAT not initialized")
+ }
+ type txPlayer interface {
+ PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
+ }
+ var player txPlayer
+ err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
+ p, ok := t.(txPlayer)
+ if !ok {
+ return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
+ }
+ player = p
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ return player.PlayTXAudio(pcm, rate, ch, bits, stop)
+}
+
+// installTCITXPlayer offers the radio as an audio output, or withdraws it.
+//
+// Withdrawing matters as much as offering: a radio that has gone away must stop
+// being a device the voice keyer will happily "play" to, or a message goes
+// nowhere and the operator hears their own PTT click and assumes it worked.
+func (a *App) installTCITXPlayer(on bool) {
+ if !on {
+ audio.SetNetworkPlayer(nil)
+ return
+ }
+ audio.SetNetworkPlayer(a.tciTXPlayer)
+ applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer")
+}
diff --git a/app_tci_rec.go b/app_tci_rec.go
new file mode 100644
index 0000000..ced5a51
--- /dev/null
+++ b/app_tci_rec.go
@@ -0,0 +1,128 @@
+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 {
+ return
+ }
+ // Two ways to ask for the same thing: the option in the TCI section, or
+ // simply choosing the radio as the "From radio" device. The second is where
+ // an operator looks first — it is the question they are already answering —
+ // so it has to work as well as the tick box.
+ cfg, _ := a.GetAudioSettings()
+ if a.settingOr(keyTCIRecAudio, "") != "1" && cfg.FromRadio != audio.NetworkDeviceID {
+ 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()
+ })
+}
diff --git a/app_tci_rec_test.go b/app_tci_rec_test.go
new file mode 100644
index 0000000..4e0c162
--- /dev/null
+++ b/app_tci_rec_test.go
@@ -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)
+ }
+}
diff --git a/app_tci_record.go b/app_tci_record.go
new file mode 100644
index 0000000..fd47e2a
--- /dev/null
+++ b/app_tci_record.go
@@ -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
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 693b40b..b826f3b 100644
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-f9b41e192918fa2511f68cd1b361fcd3
\ No newline at end of file
+704fe1bf370b669665df0606fae8a69d
\ No newline at end of file
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 069ca81..13b7663 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -58,7 +58,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
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, ProbeTCITransmit,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -2035,6 +2035,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0');
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({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
+ const [tciRecBusy, setTciRecBusy] = useState(false);
+ const [tciTXBusy, setTciTXBusy] = 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(null);
const [pskrStatus, setPskrStatus] = useState(null);
const saveBandOpen = async (next: any) => {
@@ -6746,6 +6761,79 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{t('aud.fromRadioShort')} {t('aud.explainFrom')}{' '}
{t('aud.toRadioShort')} {t('aud.explainTo')}
+
+ {/* 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. */}
+
+ {/* 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 && (
+
+
+ {!!tciRecPath && {tciRecPath}}
+
+ )}
+ {/* The transmit experiment. It KEYS THE RADIO, which is why it is
+ worded as a warning and not as another test button: a first pass
+ on real hardware showed the radio sends nothing extra while the
+ operator keys it by hand, so the only way to learn what it wants
+ is to key it from here and push a tone. */}
+