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,
|
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
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';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
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
|
// that asks the backend twice a second for a stream nobody started is work
|
||||||
// done for nothing.
|
// done for nothing.
|
||||||
const [tciAudio, setTciAudio] = useState<any>({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
|
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(() => {
|
useEffect(() => {
|
||||||
if (!tciAudio.running) return;
|
if (!tciAudio.running) return;
|
||||||
const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
|
const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
|
||||||
@@ -6577,6 +6579,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>}
|
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
|
||||||
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ const en: Dict = {
|
|||||||
'aud.noneDefault': '— none / system default —', 'aud.defaultTag': '(default)',
|
'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.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.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.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.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…',
|
'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.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.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.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.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.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…',
|
'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 RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function RecordTCIAudio(arg1:number):Promise<string>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|||||||
@@ -1750,6 +1750,10 @@ export function RecomputeAwardRefsForCode(arg1) {
|
|||||||
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
|
return window['go']['main']['App']['RecomputeAwardRefsForCode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecordTCIAudio(arg1) {
|
||||||
|
return window['go']['main']['App']['RecordTCIAudio'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,16 @@ func (t *TCI) handle(msg string) {
|
|||||||
switch strings.ToLower(name) {
|
switch strings.ToLower(name) {
|
||||||
case "device":
|
case "device":
|
||||||
t.device = strings.TrimSpace(args)
|
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":
|
case "ready", "start":
|
||||||
t.ready = true
|
t.ready = true
|
||||||
case "stop":
|
case "stop":
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ type tciAudio struct {
|
|||||||
// widthLogged keeps the one-line note about the sample width to once a
|
// widthLogged keeps the one-line note about the sample width to once a
|
||||||
// session — it is a fact about the radio, not an event.
|
// session — it is a fact about the radio, not an event.
|
||||||
widthLogged bool
|
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
|
// OnSamples receives decoded MONO samples (the two channels averaged) at
|
||||||
// the negotiated rate. Mono because everything downstream — the QSO
|
// 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))
|
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.
|
// StopTCIAudio closes the stream.
|
||||||
func (t *TCI) StopTCIAudio() error {
|
func (t *TCI) StopTCIAudio() error {
|
||||||
t.audio.mu.Lock()
|
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
|
// Stereo interleaved → mono. Both channels of a receiver carry the same
|
||||||
// audio, and everything downstream works on one.
|
// 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
|
var peak float64
|
||||||
sample := func(i int) float32 {
|
sample := func(i int) float32 {
|
||||||
if width == 2 {
|
if width == 2 {
|
||||||
@@ -234,8 +259,12 @@ func (t *TCI) handleBinary(data []byte) {
|
|||||||
}
|
}
|
||||||
return math.Float32frombits(le.Uint32(payload[i*4:]))
|
return math.Float32frombits(le.Uint32(payload[i*4:]))
|
||||||
}
|
}
|
||||||
for i := 0; i+1 < n; i += 2 {
|
for i := 0; i+chans-1 < n; i += chans {
|
||||||
v := (sample(i) + sample(i+1)) / 2
|
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 {
|
if a := math.Abs(float64(v)); a > peak {
|
||||||
peak = a
|
peak = a
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user