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. */} +
+
{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')}

+