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)