feat(tci): read the radio's declared format, and record a test WAV
The SunSDR announces its own stream at connect — audio_stream_sample_type:float32 and audio_stream_channels:2 — and both were being logged as unhandled while the code worked the format out from frame arithmetic. The declaration is better evidence and arrives before the first frame; the arithmetic stays as the check on it. The channel count now drives the mix-down instead of an assumed stereo. Adds a ten-second test recording, written as a WAV beside the QSO recordings. Counting frames 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 stream decoded with 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, the same way the CW decoder was settled on the air rather than on a spectrogram. The file is written at the rate the RADIO reported, not a constant: a recording at the wrong rate plays at the wrong speed, which is the one fault that would be blamed on the decoding.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, StartTCIAudio, StopTCIAudio, GetTCIAudioStatus, RecordTCIAudio,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -1947,6 +1947,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// that asks the backend twice a second for a stream nobody started is work
|
||||
// done for nothing.
|
||||
const [tciAudio, setTciAudio] = useState<any>({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
|
||||
const [tciRecBusy, setTciRecBusy] = useState(false);
|
||||
const [tciRecPath, setTciRecPath] = useState('');
|
||||
useEffect(() => {
|
||||
if (!tciAudio.running) return;
|
||||
const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
|
||||
@@ -6577,6 +6579,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={tciRecBusy}
|
||||
onClick={() => {
|
||||
setTciRecBusy(true); setTciRecPath('');
|
||||
RecordTCIAudio(10)
|
||||
.then((p: string) => setTciRecPath(p))
|
||||
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
|
||||
.finally(() => setTciRecBusy(false));
|
||||
}}>
|
||||
{tciRecBusy ? t('aud.tciRecBusy') : t('aud.tciRec')}
|
||||
</Button>
|
||||
{!!tciRecPath && <span className="text-[11px] font-mono text-muted-foreground truncate">{tciRecPath}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
|
||||
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -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.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.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.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.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…',
|
||||
|
||||
Vendored
+2
@@ -905,6 +905,8 @@ export function RecomputeAllAwardRefs():Promise<number>;
|
||||
|
||||
export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
||||
|
||||
export function RecordTCIAudio(arg1:number):Promise<string>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshKenwood():Promise<void>;
|
||||
|
||||
@@ -1750,6 +1750,10 @@ export function RecomputeAwardRefsForCode(arg1) {
|
||||
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
|
||||
}
|
||||
|
||||
export function RecordTCIAudio(arg1) {
|
||||
return window['go']['main']['App']['RecordTCIAudio'](arg1);
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
|
||||
@@ -406,6 +406,16 @@ func (t *TCI) handle(msg string) {
|
||||
switch strings.ToLower(name) {
|
||||
case "device":
|
||||
t.device = strings.TrimSpace(args)
|
||||
// The radio ANNOUNCES its audio format at connect —
|
||||
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
|
||||
// is better evidence than anything derived from a frame, and it arrives
|
||||
// before the first frame does. Both were being logged as unhandled.
|
||||
case "audio_stream_sample_type":
|
||||
t.audio.declaredType = strings.TrimSpace(args)
|
||||
case "audio_stream_channels":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(args)); err == nil && n > 0 && n <= 8 {
|
||||
t.audio.declaredChans = n
|
||||
}
|
||||
case "ready", "start":
|
||||
t.ready = true
|
||||
case "stop":
|
||||
|
||||
@@ -86,6 +86,12 @@ type tciAudio struct {
|
||||
// widthLogged keeps the one-line note about the sample width to once a
|
||||
// session — it is a fact about the radio, not an event.
|
||||
widthLogged bool
|
||||
// What the radio SAID about its stream at connect (audio_stream_sample_type,
|
||||
// audio_stream_channels). Its own declaration, and it arrives before the
|
||||
// first frame — the frame arithmetic below stays as the check on it rather
|
||||
// than as the only source.
|
||||
declaredType string
|
||||
declaredChans int
|
||||
|
||||
// OnSamples receives decoded MONO samples (the two channels averaged) at
|
||||
// the negotiated rate. Mono because everything downstream — the QSO
|
||||
@@ -116,6 +122,17 @@ func (t *TCI) StartTCIAudio(rx, rate int) error {
|
||||
return t.send(fmt.Sprintf("audio_start:%d;", rx))
|
||||
}
|
||||
|
||||
// SetTCIAudioSink installs (or removes) the consumer of the decoded samples.
|
||||
//
|
||||
// One sink, not a list: today it is a test recording, tomorrow the QSO
|
||||
// recorder, and two consumers of a live stream would need a policy about which
|
||||
// one wins that nothing yet has an opinion about.
|
||||
func (t *TCI) SetTCIAudioSink(fn func(rate int, samples []float32)) {
|
||||
t.audio.mu.Lock()
|
||||
t.audio.OnSamples = fn
|
||||
t.audio.mu.Unlock()
|
||||
}
|
||||
|
||||
// StopTCIAudio closes the stream.
|
||||
func (t *TCI) StopTCIAudio() error {
|
||||
t.audio.mu.Lock()
|
||||
@@ -224,7 +241,15 @@ func (t *TCI) handleBinary(data []byte) {
|
||||
}
|
||||
// Stereo interleaved → mono. Both channels of a receiver carry the same
|
||||
// audio, and everything downstream works on one.
|
||||
mono := make([]float32, 0, n/2+1)
|
||||
// How many channels are interleaved. The radio says so at connect; two is
|
||||
// the fallback, which is what every SunSDR seen so far streams.
|
||||
t.audio.mu.Lock()
|
||||
chans := t.audio.declaredChans
|
||||
t.audio.mu.Unlock()
|
||||
if chans <= 0 {
|
||||
chans = 2
|
||||
}
|
||||
mono := make([]float32, 0, n/chans+1)
|
||||
var peak float64
|
||||
sample := func(i int) float32 {
|
||||
if width == 2 {
|
||||
@@ -234,8 +259,12 @@ func (t *TCI) handleBinary(data []byte) {
|
||||
}
|
||||
return math.Float32frombits(le.Uint32(payload[i*4:]))
|
||||
}
|
||||
for i := 0; i+1 < n; i += 2 {
|
||||
v := (sample(i) + sample(i+1)) / 2
|
||||
for i := 0; i+chans-1 < n; i += chans {
|
||||
var sum float32
|
||||
for c := 0; c < chans; c++ {
|
||||
sum += sample(i + c)
|
||||
}
|
||||
v := sum / float32(chans)
|
||||
if a := math.Abs(float64(v)); a > peak {
|
||||
peak = a
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user