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. */} -
-
{t('aud.tciTitle')}
-
- - {tciAudio.running && ( - - {tciAudio.sample_rate || 0} Hz · {tciAudio.frames || 0} frames ·{' '} - {tciAudio.peak_db > -90 ? tciAudio.peak_db.toFixed(1) + ' dBFS' : t('aud.tciSilent')} - - )} -
- {/* 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. */}