feat(tci): receive audio over the TCI WebSocket (experimental)
A SunSDR already carries its receive audio on the same WebSocket as its commands, so a virtual audio cable and a second sound card are two pieces of plumbing an operator installs for no reason. This is the receive half: what the QSO recorder and the CW decoder need. The reader now looks at the frame type. It used to ignore it and split every frame on ';' -- harmless only for as long as no stream was ever opened, since audio bytes would otherwise have been handed to the command parser a hundred times a second. NOTHING HERE IS CONFIRMED ON A RADIO. The header layout comes from the TCI documentation, and the stream-type numbers are exactly the sort of detail a document gets right and a memory of it does not -- so the first forty frames of a session are logged verbatim, and a test bench in Preferences > Audio reports the sample rate the radio chose, the frames arriving and the peak level of the last second. 'The stream is open' and 'audio is arriving' are different claims and only the second is worth anything to whoever tries this first. Transmit (the voice keyer) is the other half and is deliberately absent: it has to answer the radio's chrono packets at the right pace, and that is worth doing once the format is settled on real hardware.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
f9b41e192918fa2511f68cd1b361fcd3
|
||||
704fe1bf370b669665df0606fae8a69d
|
||||
@@ -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,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -1943,6 +1943,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
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<any>({ running: false, sample_rate: 0, frames: 0, peak_db: -99 });
|
||||
useEffect(() => {
|
||||
if (!tciAudio.running) return;
|
||||
const id = window.setInterval(() => { GetTCIAudioStatus().then(setTciAudio).catch(() => {}); }, 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [tciAudio.running]);
|
||||
const [gridStat, setGridStat] = useState<any>(null);
|
||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||
const saveBandOpen = async (next: any) => {
|
||||
@@ -6542,6 +6551,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||||
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
|
||||
</p>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<div className="text-xs font-medium">{t('aud.tciTitle')}</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button variant={tciAudio.running ? 'default' : 'outline'} size="sm" className="h-8"
|
||||
onClick={() => {
|
||||
const p = tciAudio.running ? StopTCIAudio() : StartTCIAudio(0, 48000);
|
||||
p.then(() => GetTCIAudioStatus().then(setTciAudio))
|
||||
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })));
|
||||
}}>
|
||||
{tciAudio.running ? t('aud.tciStop') : t('aud.tciStart')}
|
||||
</Button>
|
||||
{tciAudio.running && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
{tciAudio.sample_rate || 0} Hz · {tciAudio.frames || 0} frames ·{' '}
|
||||
{tciAudio.peak_db > -90 ? tciAudio.peak_db.toFixed(1) + ' dBFS' : t('aud.tciSilent')}
|
||||
</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>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={monitorOn ? 'default' : 'outline'}
|
||||
|
||||
@@ -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.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.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.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.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
+6
@@ -575,6 +575,8 @@ export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTCIAudioStatus():Promise<cat.TCIAudioStatus>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetTrackedAwards():Promise<Array<string>>;
|
||||
@@ -1207,10 +1209,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StartTCIAudio(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function StopTCIAudio():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncFolderNow():Promise<number>;
|
||||
|
||||
@@ -1090,6 +1090,10 @@ export function GetStationStatus() {
|
||||
return window['go']['main']['App']['GetStationStatus']();
|
||||
}
|
||||
|
||||
export function GetTCIAudioStatus() {
|
||||
return window['go']['main']['App']['GetTCIAudioStatus']();
|
||||
}
|
||||
|
||||
export function GetTelemetryEnabled() {
|
||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||
}
|
||||
@@ -2354,6 +2358,10 @@ export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StartTCIAudio(arg1, arg2) {
|
||||
return window['go']['main']['App']['StartTCIAudio'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -2362,6 +2370,10 @@ export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
|
||||
export function StopTCIAudio() {
|
||||
return window['go']['main']['App']['StopTCIAudio']();
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
@@ -1208,6 +1208,28 @@ export namespace cat {
|
||||
this.fixed = source["fixed"];
|
||||
}
|
||||
}
|
||||
export class TCIAudioStatus {
|
||||
running: boolean;
|
||||
sample_rate: number;
|
||||
frames: number;
|
||||
samples: number;
|
||||
peak_db: number;
|
||||
last_err?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TCIAudioStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.running = source["running"];
|
||||
this.sample_rate = source["sample_rate"];
|
||||
this.frames = source["frames"];
|
||||
this.samples = source["samples"];
|
||||
this.peak_db = source["peak_db"];
|
||||
this.last_err = source["last_err"];
|
||||
}
|
||||
}
|
||||
export class YaesuTXState {
|
||||
available: boolean;
|
||||
model?: string;
|
||||
|
||||
+14
-1
@@ -34,6 +34,11 @@ type TCI struct {
|
||||
OnSpotClick func(callsign string, freqHz int64)
|
||||
unhandledSeen map[string]bool // log each unknown TCI message type once
|
||||
|
||||
// audio holds the receive-audio stream — see tci_audio.go. TCI carries it
|
||||
// on this same WebSocket, which is what lets a SunSDR record and decode
|
||||
// without a virtual audio cable in the way.
|
||||
audio tciAudio
|
||||
|
||||
mu sync.Mutex // guards conn + writes + state
|
||||
conn *websocket.Conn
|
||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||
@@ -354,10 +359,18 @@ func (t *TCI) send(cmd string) error {
|
||||
// connection closes.
|
||||
func (t *TCI) reader(conn *websocket.Conn) {
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
mt, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// TEXT frames are commands, BINARY frames are streams. The type used to
|
||||
// be ignored and every frame split on ';' — harmless only for as long as
|
||||
// no stream was ever opened, since audio bytes would then have been fed
|
||||
// to the command parser a hundred times a second.
|
||||
if wsMessageIsBinary(mt) {
|
||||
t.handleBinary(data)
|
||||
continue
|
||||
}
|
||||
// A frame may carry several ";"-terminated commands.
|
||||
for _, cmd := range strings.Split(string(data), ";") {
|
||||
t.handle(strings.TrimSpace(cmd))
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
// TCI audio — receiving the radio's audio over the same WebSocket that carries
|
||||
// the commands, so a SunSDR needs no virtual audio cable.
|
||||
//
|
||||
// TCI mixes two kinds of frame on one socket: TEXT frames are the commands
|
||||
// ("trx:0,true;"), BINARY frames are streams. A binary frame is a fixed header
|
||||
// followed by float32 samples:
|
||||
//
|
||||
// uint32 receiver which receiver the stream belongs to
|
||||
// uint32 sampleRate Hz
|
||||
// uint32 format 0 = float32
|
||||
// uint32 codec 0 = uncompressed
|
||||
// uint32 crc unused in practice
|
||||
// uint32 length samples in the payload
|
||||
// uint32 type which stream this is (see tciStream*)
|
||||
// uint32 reserved[9]
|
||||
// float32 payload[…] stereo, interleaved
|
||||
//
|
||||
// The stream is asked for with "audio_samplerate:" then "audio_start:<rx>;",
|
||||
// and stopped with "audio_stop:<rx>;".
|
||||
//
|
||||
// NOTHING HERE IS CONFIRMED ON A RADIO YET. The layout above is read from the
|
||||
// TCI documentation, and the stream-type numbers in particular are the sort of
|
||||
// detail a document gets right and a memory of it does not — so every header is
|
||||
// logged for the first few seconds of a session, and the numbers the radio
|
||||
// actually sends will settle it. Same discipline as the Yaesu meters and the
|
||||
// Flex spot feed: measure on the real thing, then write the constant down.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// TCI stream types. RX audio is the one this file consumes; the others are
|
||||
// named so a log line says what arrived rather than "type 3".
|
||||
const (
|
||||
tciStreamIQ = 0
|
||||
tciStreamRXAudio = 1
|
||||
tciStreamTXAudio = 2
|
||||
tciStreamTXChrono = 3
|
||||
)
|
||||
|
||||
// tciHeaderWords is the header length in uint32 words (7 named + 9 reserved).
|
||||
const tciHeaderWords = 16
|
||||
|
||||
// tciHeaderBytes is the same in bytes.
|
||||
const tciHeaderBytes = tciHeaderWords * 4
|
||||
|
||||
// tciAudioProbeMax bounds the header logging. Enough frames to see the shape
|
||||
// and the rate; few enough that an evening of listening does not fill the log.
|
||||
const tciAudioProbeMax = 40
|
||||
|
||||
// TCIAudioStatus is what the panel polls while testing the stream.
|
||||
type TCIAudioStatus struct {
|
||||
Running bool `json:"running"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
Frames int64 `json:"frames"` // binary frames accepted
|
||||
Samples int64 `json:"samples"` // audio samples decoded
|
||||
// PeakDB is the loudest sample of the last second, in dBFS: the one number
|
||||
// that says "audio is really arriving" rather than "a socket is open".
|
||||
PeakDB float64 `json:"peak_db"`
|
||||
LastErr string `json:"last_err,omitempty"`
|
||||
}
|
||||
|
||||
// tciAudio is the receive-side state, kept on the backend so it lives exactly
|
||||
// as long as the connection does.
|
||||
type tciAudio struct {
|
||||
mu sync.Mutex
|
||||
want bool // the host asked for audio
|
||||
rx int // which receiver
|
||||
rate int
|
||||
frames int64
|
||||
samples int64
|
||||
peak float64
|
||||
peakAt time.Time
|
||||
probe int
|
||||
lastErr string
|
||||
|
||||
// OnSamples receives decoded MONO samples (the two channels averaged) at
|
||||
// the negotiated rate. Mono because everything downstream — the QSO
|
||||
// recorder, the CW decoder — works on one channel, and a receiver's two
|
||||
// channels carry the same audio.
|
||||
OnSamples func(rate int, samples []float32)
|
||||
}
|
||||
|
||||
// StartTCIAudio asks the radio to stream receiver rx's audio.
|
||||
func (t *TCI) StartTCIAudio(rx, rate int) error {
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
}
|
||||
t.audio.mu.Lock()
|
||||
t.audio.want = true
|
||||
t.audio.rx = rx
|
||||
t.audio.rate = rate
|
||||
t.audio.frames, t.audio.samples, t.audio.peak = 0, 0, 0
|
||||
t.audio.probe = 0
|
||||
t.audio.lastErr = ""
|
||||
t.audio.mu.Unlock()
|
||||
|
||||
// Sample rate first: the radio applies it to the stream it is about to
|
||||
// open, and asking afterwards restarts the stream on some firmware.
|
||||
if err := t.send(fmt.Sprintf("audio_samplerate:%d;", rate)); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.send(fmt.Sprintf("audio_start:%d;", rx))
|
||||
}
|
||||
|
||||
// StopTCIAudio closes the stream.
|
||||
func (t *TCI) StopTCIAudio() error {
|
||||
t.audio.mu.Lock()
|
||||
t.audio.want = false
|
||||
rx := t.audio.rx
|
||||
t.audio.mu.Unlock()
|
||||
return t.send(fmt.Sprintf("audio_stop:%d;", rx))
|
||||
}
|
||||
|
||||
// TCIAudioStatus reports what has arrived.
|
||||
func (t *TCI) TCIAudioStatus() TCIAudioStatus {
|
||||
t.audio.mu.Lock()
|
||||
defer t.audio.mu.Unlock()
|
||||
st := TCIAudioStatus{
|
||||
Running: t.audio.want,
|
||||
SampleRate: t.audio.rate,
|
||||
Frames: t.audio.frames,
|
||||
Samples: t.audio.samples,
|
||||
LastErr: t.audio.lastErr,
|
||||
}
|
||||
// A peak older than a second is not a level, it is a memory. Reported as
|
||||
// silence rather than left standing, so a stream that has stopped arriving
|
||||
// looks stopped.
|
||||
if time.Since(t.audio.peakAt) < time.Second && t.audio.peak > 0 {
|
||||
st.PeakDB = 20 * math.Log10(t.audio.peak)
|
||||
} else {
|
||||
st.PeakDB = -99
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// handleBinary decodes one binary WebSocket frame.
|
||||
//
|
||||
// Called from the reader goroutine. Anything malformed is counted and dropped:
|
||||
// a stream frame is not worth breaking the command connection over, and the
|
||||
// command connection is what keeps the radio usable.
|
||||
func (t *TCI) handleBinary(data []byte) {
|
||||
if len(data) < tciHeaderBytes {
|
||||
t.audioErr(fmt.Sprintf("binary frame of %d bytes is shorter than a header", len(data)))
|
||||
return
|
||||
}
|
||||
le := binary.LittleEndian
|
||||
receiver := int(le.Uint32(data[0:]))
|
||||
rate := int(le.Uint32(data[4:]))
|
||||
format := le.Uint32(data[8:])
|
||||
codec := le.Uint32(data[12:])
|
||||
length := int(le.Uint32(data[20:]))
|
||||
stype := int(le.Uint32(data[24:]))
|
||||
|
||||
t.audio.mu.Lock()
|
||||
probe := t.audio.probe
|
||||
if probe < tciAudioProbeMax {
|
||||
t.audio.probe++
|
||||
}
|
||||
t.audio.mu.Unlock()
|
||||
if probe < tciAudioProbeMax {
|
||||
debugLog.Printf("TCI: binary frame — rx=%d rate=%d format=%d codec=%d length=%d type=%d payload=%d bytes",
|
||||
receiver, rate, format, codec, length, stype, len(data)-tciHeaderBytes)
|
||||
}
|
||||
|
||||
if stype != tciStreamRXAudio {
|
||||
return // IQ, TX audio echo, chrono: not this file's business yet
|
||||
}
|
||||
if format != 0 || codec != 0 {
|
||||
t.audioErr(fmt.Sprintf("stream is format=%d codec=%d, expected float32 uncompressed", format, codec))
|
||||
return
|
||||
}
|
||||
|
||||
payload := data[tciHeaderBytes:]
|
||||
n := len(payload) / 4
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
var peak float64
|
||||
for i := 0; i+1 < n; i += 2 {
|
||||
l := math.Float32frombits(le.Uint32(payload[i*4:]))
|
||||
r := math.Float32frombits(le.Uint32(payload[(i+1)*4:]))
|
||||
v := (l + r) / 2
|
||||
if a := math.Abs(float64(v)); a > peak {
|
||||
peak = a
|
||||
}
|
||||
mono = append(mono, v)
|
||||
}
|
||||
|
||||
t.audio.mu.Lock()
|
||||
t.audio.frames++
|
||||
t.audio.samples += int64(len(mono))
|
||||
if rate > 0 {
|
||||
t.audio.rate = rate
|
||||
}
|
||||
if peak > t.audio.peak || time.Since(t.audio.peakAt) > time.Second {
|
||||
t.audio.peak = peak
|
||||
t.audio.peakAt = time.Now()
|
||||
}
|
||||
cb := t.audio.OnSamples
|
||||
t.audio.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(rate, mono)
|
||||
}
|
||||
}
|
||||
|
||||
// audioErr records a decoding complaint, once, so the panel can show it without
|
||||
// the log filling with the same line at fifty frames a second.
|
||||
func (t *TCI) audioErr(msg string) {
|
||||
t.audio.mu.Lock()
|
||||
first := t.audio.lastErr != msg
|
||||
t.audio.lastErr = msg
|
||||
t.audio.mu.Unlock()
|
||||
if first {
|
||||
debugLog.Printf("TCI: audio: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// resumeAudio re-opens the stream after a reconnect, if the host had asked for
|
||||
// it. A dropped WebSocket takes the audio with it, and an operator who switched
|
||||
// recording on does not expect to switch it on again.
|
||||
func (t *TCI) resumeAudio() {
|
||||
t.audio.mu.Lock()
|
||||
want, rx, rate := t.audio.want, t.audio.rx, t.audio.rate
|
||||
t.audio.mu.Unlock()
|
||||
if !want {
|
||||
return
|
||||
}
|
||||
if err := t.StartTCIAudio(rx, rate); err != nil {
|
||||
debugLog.Printf("TCI: re-opening the audio stream failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// wsMessageIsBinary keeps the type test in one place — the reader used to
|
||||
// ignore the message type entirely and split every frame on ';', which would
|
||||
// have fed audio bytes to the command parser the moment a stream was opened.
|
||||
func wsMessageIsBinary(mt int) bool { return mt == websocket.BinaryMessage }
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TCIAudioController is the receive-audio capability of the TCI backend, kept
|
||||
// as an interface for the same reason as the Flex and Yaesu ones: the host asks
|
||||
// the manager, and a station running something else gets a clear "this backend
|
||||
// does not do that" instead of a nil dereference.
|
||||
type TCIAudioController interface {
|
||||
StartTCIAudio(rx, rate int) error
|
||||
StopTCIAudio() error
|
||||
TCIAudioStatus() TCIAudioStatus
|
||||
}
|
||||
|
||||
// TCIAudioState returns the stream's state, or (zero, false) when the active
|
||||
// backend is not a TCI radio.
|
||||
func (m *Manager) TCIAudioState() (TCIAudioStatus, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if tc, ok := b.(TCIAudioController); ok {
|
||||
return tc.TCIAudioStatus(), true
|
||||
}
|
||||
return TCIAudioStatus{}, false
|
||||
}
|
||||
|
||||
// TCIAudioDo dispatches an audio command onto the CAT goroutine, like every
|
||||
// other backend-specific control.
|
||||
func (m *Manager) TCIAudioDo(fn func(TCIAudioController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
tc, ok := b.(TCIAudioController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not a TCI radio")
|
||||
}
|
||||
return fn(tc)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user