From f50bbc005cb5c53b54eb58a146238f09462108e3 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 25 Aug 2026 20:14:45 +0200 Subject: [PATCH] feat(tci): the QSO recorder can take its audio from the radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 6 +- app_tci_rec.go | 120 ++++++++++++++++++++++ app_tci_rec_test.go | 47 +++++++++ frontend/src/components/SettingsModal.tsx | 13 ++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 ++ internal/audio/rate.go | 9 ++ internal/audio/wav.go | 1 + 9 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 app_tci_rec.go create mode 100644 app_tci_rec_test.go create mode 100644 internal/audio/rate.go diff --git a/app.go b/app.go index 79d069a..0f4b7ee 100644 --- a/app.go +++ b/app.go @@ -15262,7 +15262,11 @@ 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() 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_rec.go b/app_tci_rec.go new file mode 100644 index 0000000..905e587 --- /dev/null +++ b/app_tci_rec.go @@ -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() + }) +} 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/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 793f6b0..3e8ff26 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, + 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'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -1948,6 +1948,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // done for nothing. const [tciAudio, setTciAudio] = useState({ 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; @@ -6599,6 +6602,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} {!!tciAudio.last_err &&

{tciAudio.last_err}

} +

{t('aud.tciHint')}

diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index a67ea98..c9ce53c 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -494,7 +494,7 @@ const en: Dict = { '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.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.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.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.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.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…', @@ -962,7 +962,7 @@ const fr: Dict = { '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.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.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.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.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.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…', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 7b26378..b3b6e9e 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -577,6 +577,8 @@ export function GetStationStatus():Promise>; export function GetTCIAudioStatus():Promise; +export function GetTCIRecordAudio():Promise; + export function GetTelemetryEnabled():Promise; export function GetTrackedAwards():Promise>; @@ -1159,6 +1161,8 @@ export function SetSpotMax(arg1:number):Promise; export function SetSpotTTLMinutes(arg1:number):Promise; +export function SetTCIRecordAudio(arg1:boolean):Promise; + export function SetTelemetryEnabled(arg1:boolean):Promise; export function SetUIPref(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index b2dc34a..7fc25f9 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1094,6 +1094,10 @@ export function GetTCIAudioStatus() { return window['go']['main']['App']['GetTCIAudioStatus'](); } +export function GetTCIRecordAudio() { + return window['go']['main']['App']['GetTCIRecordAudio'](); +} + export function GetTelemetryEnabled() { return window['go']['main']['App']['GetTelemetryEnabled'](); } @@ -2258,6 +2262,10 @@ export function SetSpotTTLMinutes(arg1) { return window['go']['main']['App']['SetSpotTTLMinutes'](arg1); } +export function SetTCIRecordAudio(arg1) { + return window['go']['main']['App']['SetTCIRecordAudio'](arg1); +} + export function SetTelemetryEnabled(arg1) { return window['go']['main']['App']['SetTelemetryEnabled'](arg1); } diff --git a/internal/audio/rate.go b/internal/audio/rate.go new file mode 100644 index 0000000..2f2ab68 --- /dev/null +++ b/internal/audio/rate.go @@ -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 diff --git a/internal/audio/wav.go b/internal/audio/wav.go index 0a8953d..02e7368 100644 --- a/internal/audio/wav.go +++ b/internal/audio/wav.go @@ -14,6 +14,7 @@ import ( // any device regardless of its native mix format. const ( sampleRate = 16000 + channels = 1 bitsPerSample = 16 blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)