diff --git a/app.go b/app.go
index a12c976..1757ea4 100644
--- a/app.go
+++ b/app.go
@@ -8500,7 +8500,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
// reads as a device that does not work.
if s.FromRadio == audio.NetworkDeviceID {
a.startTCIRecording()
- } else if a.tciAudioAvailable() && a.settingOr(keyTCIRecAudio, "") != "1" {
+ } else if a.tciAudioAvailable() {
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
}
// Apply device/preroll/enable changes to the running recorder.
diff --git a/app_tci_audio.go b/app_tci_audio.go
deleted file mode 100644
index c65ea1a..0000000
--- a/app_tci_audio.go
+++ /dev/null
@@ -1,80 +0,0 @@
-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_rec.go b/app_tci_rec.go
index ced5a51..f932d82 100644
--- a/app_tci_rec.go
+++ b/app_tci_rec.go
@@ -68,18 +68,18 @@ func tciToRecorderPCM(rate int, samples []float32) []byte {
// 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.
+// for it by choosing the radio as their receive device: 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.
+ // One switch, and it is the one an operator is already looking at: the
+ // "From radio" device. There used to be a tick box here as well, from when
+ // this was an experiment with no device to choose — two controls for one
+ // question, and the second was where nobody would look.
cfg, _ := a.GetAudioSettings()
- if a.settingOr(keyTCIRecAudio, "") != "1" && cfg.FromRadio != audio.NetworkDeviceID {
+ if cfg.FromRadio != audio.NetworkDeviceID {
return
}
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
@@ -96,33 +96,3 @@ func (a *App) startTCIRecording() {
}
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_record.go b/app_tci_record.go
deleted file mode 100644
index fd47e2a..0000000
--- a/app_tci_record.go
+++ /dev/null
@@ -1,175 +0,0 @@
-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/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 13b7663..3533c67 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, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio, ProbeTCITransmit,
+ GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -2038,18 +2038,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// 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) => {
@@ -6762,78 +6754,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{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. */}
-
-
-
{t('aud.tciTXWarn')}
-
- {!!tciAudio.last_err &&
{tciAudio.last_err}
}
-
-
{t('aud.tciHint')}
-
+ {/* The radio is one of the devices above when it can carry its own
+ audio — see ListAudioInputDevices. What used to be here was a test
+ bench: open the stream, record ten seconds, key a tone. It settled
+ how TCI works and has no business in front of an operator now that
+ choosing the device is the whole of the setup. */}