refactor(tci): remove the test bench now that choosing the device is the setup
The TCI section of the audio settings was an investigation: open the stream, read what arrives, record ten seconds to listen to, key a tone. It answered every question it was built for — the frame layout, the sample width, that the radio asks rather than follows a clock, that the transmit audio source decides — and confirmed 80 W on real hardware. None of that belongs in front of an operator now. The radio is simply one of the devices in the two dropdowns, and choosing it IS the configuration: one control, in the place where the question is already being asked. Keeping the tick box beside it would have been two switches for one decision, with the second one where nobody looks. Gone with it: the stream/record/probe bindings, the audio.tci_rx setting, and twenty-four translation keys. The plumbing they proved out stays and now carries the voice keyer.
This commit is contained in:
@@ -8500,7 +8500,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
// reads as a device that does not work.
|
||||
if s.FromRadio == audio.NetworkDeviceID {
|
||||
a.startTCIRecording()
|
||||
} else if a.tciAudioAvailable() && a.settingOr(keyTCIRecAudio, "") != "1" {
|
||||
} else if a.tciAudioAvailable() {
|
||||
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
|
||||
}
|
||||
// Apply device/preroll/enable changes to the running recorder.
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// ProbeTCITransmit keys the radio and pushes a tone over TCI, to find out
|
||||
// whether the transmit half of the stream works at all.
|
||||
//
|
||||
// A first real transmission proved that the radio sends nothing extra while the
|
||||
// OPERATOR keys it — 282 receive frames and no chrono over six seconds — so the
|
||||
// only way forward is to key it from here and watch. Everything interesting
|
||||
// lands in the log; see internal/cat/tci_tx_probe.go for what the two possible
|
||||
// answers mean.
|
||||
//
|
||||
// THIS TRANSMITS. The button that calls it says so, and the radio must be on a
|
||||
// dummy load.
|
||||
func (a *App) ProbeTCITransmit(seconds int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
p, ok := t.(interface {
|
||||
ProbeTXStream(seconds int, toneHz float64) error
|
||||
})
|
||||
if !ok {
|
||||
return fmt.Errorf("this CAT backend is not a TCI radio")
|
||||
}
|
||||
applog.Printf("tci: TX PROBE requested (%d s) — the radio should be on a dummy load", seconds)
|
||||
return p.ProbeTXStream(seconds, 1000)
|
||||
})
|
||||
}
|
||||
+7
-37
@@ -68,18 +68,18 @@ func tciToRecorderPCM(rate int, samples []float32) []byte {
|
||||
// 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.
|
||||
// for it by choosing the radio as their receive device: 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 {
|
||||
return
|
||||
}
|
||||
// Two ways to ask for the same thing: the option in the TCI section, or
|
||||
// simply choosing the radio as the "From radio" device. The second is where
|
||||
// an operator looks first — it is the question they are already answering —
|
||||
// so it has to work as well as the tick box.
|
||||
// One switch, and it is the one an operator is already looking at: the
|
||||
// "From radio" device. There used to be a tick box here as well, from when
|
||||
// this was an experiment with no device to choose — two controls for one
|
||||
// question, and the second was where nobody would look.
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if a.settingOr(keyTCIRecAudio, "") != "1" && cfg.FromRadio != audio.NetworkDeviceID {
|
||||
if cfg.FromRadio != audio.NetworkDeviceID {
|
||||
return
|
||||
}
|
||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
@@ -96,33 +96,3 @@ func (a *App) startTCIRecording() {
|
||||
}
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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, RecordTCIAudio, GetTCIRecordAudio, SetTCIRecordAudio, ProbeTCITransmit,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -2038,18 +2038,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// 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 });
|
||||
const [tciRecBusy, setTciRecBusy] = useState(false);
|
||||
const [tciTXBusy, setTciTXBusy] = 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;
|
||||
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) => {
|
||||
@@ -6762,78 +6754,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* The transmit experiment. It KEYS THE RADIO, which is why it is
|
||||
worded as a warning and not as another test button: a first pass
|
||||
on real hardware showed the radio sends nothing extra while the
|
||||
operator keys it by hand, so the only way to learn what it wants
|
||||
is to key it from here and push a tone. */}
|
||||
<div className="rounded border border-caution-border bg-caution-muted p-2 space-y-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={tciTXBusy}
|
||||
onClick={() => {
|
||||
setTciTXBusy(true);
|
||||
ProbeTCITransmit(5)
|
||||
.catch((e: any) => setTciAudio((s: any) => ({ ...s, last_err: String(e?.message ?? e) })))
|
||||
.finally(() => setTciTXBusy(false));
|
||||
}}>
|
||||
{tciTXBusy ? t('aud.tciTXBusy') : t('aud.tciTX')}
|
||||
</Button>
|
||||
<p className="text-[11px] text-caution-muted-foreground">{t('aud.tciTXWarn')}</p>
|
||||
</div>
|
||||
{!!tciAudio.last_err && <p className="text-[11px] text-danger">{tciAudio.last_err}</p>}
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={tciRec}
|
||||
onCheckedChange={(c) => { setTciRec(!!c); SetTCIRecordAudio(!!c).catch(() => {}); }} />
|
||||
<span>
|
||||
{t('aud.tciRecord')}
|
||||
<span className="block text-muted-foreground">{t('aud.tciRecordHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground">{t('aud.tciHint')}</p>
|
||||
</div>
|
||||
{/* The radio is one of the devices above when it can carry its own
|
||||
audio — see ListAudioInputDevices. What used to be here was a test
|
||||
bench: open the stream, record ten seconds, key a tone. It settled
|
||||
how TCI works and has no business in front of an operator now that
|
||||
choosing the device is the whole of the setup. */}
|
||||
<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.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.tciTX': 'Test transmit — 5 s tone', 'aud.tciTXBusy': 'Transmitting…', 'aud.tciTXWarn': 'THIS TRANSMITS. Put the radio on a dummy load first. It keys the radio and sends a 1 kHz tone over TCI for five seconds, to find out whether the transmit half of the stream works — the log holds the answer.', '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.monitorOn': 'RX monitor running — From Radio → Listening device.', '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.tciRecord': 'Enregistrer les QSO depuis ce flux', 'aud.tciTX': "Test d'émission — tonalité 5 s", 'aud.tciTXBusy': 'Émission…', 'aud.tciTXWarn': "CECI ÉMET. Mettre la radio sur charge fictive d'abord. Le poste est passé en émission et une tonalité de 1 kHz est envoyée par TCI pendant cinq secondes, pour savoir si la moitié émission du flux fonctionne — la réponse est dans le journal.", '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.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.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
-14
@@ -575,10 +575,6 @@ export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTCIAudioStatus():Promise<cat.TCIAudioStatus>;
|
||||
|
||||
export function GetTCIRecordAudio():Promise<boolean>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetTrackedAwards():Promise<Array<string>>;
|
||||
@@ -841,8 +837,6 @@ export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||
|
||||
export function ProbeTCITransmit(arg1:number):Promise<void>;
|
||||
|
||||
export function PublishLogNow():Promise<string>;
|
||||
|
||||
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
|
||||
@@ -911,8 +905,6 @@ 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>;
|
||||
@@ -1165,8 +1157,6 @@ export function SetSpotMax(arg1:number):Promise<void>;
|
||||
|
||||
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
||||
|
||||
export function SetTCIRecordAudio(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -1219,14 +1209,10 @@ 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,14 +1090,6 @@ export function GetStationStatus() {
|
||||
return window['go']['main']['App']['GetStationStatus']();
|
||||
}
|
||||
|
||||
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']();
|
||||
}
|
||||
@@ -1622,10 +1614,6 @@ export function PopulateBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
|
||||
}
|
||||
|
||||
export function ProbeTCITransmit(arg1) {
|
||||
return window['go']['main']['App']['ProbeTCITransmit'](arg1);
|
||||
}
|
||||
|
||||
export function PublishLogNow() {
|
||||
return window['go']['main']['App']['PublishLogNow']();
|
||||
}
|
||||
@@ -1762,10 +1750,6 @@ 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']();
|
||||
}
|
||||
@@ -2270,10 +2254,6 @@ 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);
|
||||
}
|
||||
@@ -2378,10 +2358,6 @@ 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);
|
||||
}
|
||||
@@ -2390,10 +2366,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1210,28 +1210,6 @@ 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;
|
||||
|
||||
@@ -26,24 +26,20 @@ package cat
|
||||
// used. Answering the request is what makes the difference, and it also means
|
||||
// the radio sets the pace — no drift, no buffer to tune.
|
||||
//
|
||||
// What remains here is the probe: a tone, on demand, to prove the path end to
|
||||
// end on real hardware. The voice keyer will use the same feed mechanism with
|
||||
// WAV samples in place of the sine.
|
||||
// All of it was established with a tone probe — key the radio, push a sine,
|
||||
// watch — which is gone now that it has served its purpose: it answered the
|
||||
// three questions above, confirmed 80 W out on a real SunSDR, and had no
|
||||
// business in front of an operator once the voice keyer worked. What is left is
|
||||
// the exchange it discovered, with tci_tx_play.go supplying the message.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// tciTXProbeMaxSeconds caps the pass. Long enough to read a power meter, short
|
||||
// enough that a carrier left running by a defect is a mistake and not an
|
||||
// incident.
|
||||
const tciTXProbeMaxSeconds = 10
|
||||
|
||||
// sendBinaryFrame writes one TCI binary frame: the 16-word header the radio's
|
||||
// own frames carry, then the payload.
|
||||
func (t *TCI) sendBinaryFrame(stype, rx, rate, length int, payload []byte) error {
|
||||
@@ -116,152 +112,3 @@ func (t *TCI) setTXFeed(fn func(samples int) []byte) {
|
||||
t.audio.txSent, t.audio.txShort = 0, 0
|
||||
t.audio.mu.Unlock()
|
||||
}
|
||||
|
||||
// ProbeTXStream keys the radio, answers its chrono requests with a tone for the
|
||||
// given number of seconds, unkeys, and reports what happened.
|
||||
//
|
||||
// INTO A DUMMY LOAD. Confirmed on a SunSDR: 80 W out of a 1 kHz tone at 70% of
|
||||
// full scale into 80% drive.
|
||||
//
|
||||
// If the radio's transmit audio source is the microphone rather than TCI it
|
||||
// will not ask for anything, and this stops within a fifth of a second and says
|
||||
// which setting to change.
|
||||
func (t *TCI) ProbeTXStream(seconds int, toneHz float64) error {
|
||||
if seconds <= 0 {
|
||||
seconds = 5
|
||||
}
|
||||
if seconds > tciTXProbeMaxSeconds {
|
||||
seconds = tciTXProbeMaxSeconds
|
||||
}
|
||||
if toneHz <= 0 {
|
||||
toneHz = 1000
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
allowed, known, connected := t.txAllowed, t.txAllowedKnown, t.conn != nil
|
||||
mode := t.mode
|
||||
drive := t.drive
|
||||
t.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("not connected to the radio")
|
||||
}
|
||||
if known && !allowed {
|
||||
return fmt.Errorf("the radio refuses transmitting (tx_enable is false)")
|
||||
}
|
||||
if !tciDigitalMode(mode) {
|
||||
// A NOTE, not a refusal.
|
||||
//
|
||||
// The first experiments said "digital modes only": SSB produced nothing
|
||||
// four times over, DIGU answered at once. That was a real observation
|
||||
// and the wrong rule. ExpertSDR3 has a TRANSMIT AUDIO SOURCE — the
|
||||
// microphone or TCI — and it was simply set to the microphone; the mode
|
||||
// had nothing to do with it. Refusing SSB would have blocked the one
|
||||
// thing a voice keyer exists for.
|
||||
debugLog.Printf("TCI: TX PROBE — mode is %s, not a digital mode. That is fine IF ExpertSDR3's transmit audio source is set to TCI rather than the microphone; if it is not, the radio will not ask for audio and this stops straight away", mode)
|
||||
}
|
||||
|
||||
t.audio.mu.Lock()
|
||||
rate := t.audio.rate
|
||||
t.audio.mu.Unlock()
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
}
|
||||
|
||||
// The tone, generated on demand: the radio asks for a size and gets exactly
|
||||
// that, at whatever pace it asks. Phase is carried across the calls, since a
|
||||
// sine restarted every frame is a click 47 times a second.
|
||||
phase := 0.0
|
||||
step := 2 * math.Pi * toneHz / float64(rate)
|
||||
// Near full scale.
|
||||
//
|
||||
// A quarter was the first choice, out of caution, and the first real test
|
||||
// showed exactly what that produces: a clean signal on the panadapter and a
|
||||
// wattmeter that never moves. In a digital mode the radio expects a line
|
||||
// level it can drive to full output — the POWER is set by its own drive
|
||||
// control, not by how loud we send — so sending quietly just wastes the
|
||||
// range. Short of 1.0 to leave room for the sine's peaks.
|
||||
const amp = 0.7
|
||||
const chans = 2
|
||||
le := binary.LittleEndian
|
||||
t.setTXFeed(func(samples int) []byte {
|
||||
payload := make([]byte, samples*4)
|
||||
for s := 0; s+chans-1 < samples; s += chans {
|
||||
v := float32(math.Sin(phase) * amp)
|
||||
phase += step
|
||||
if phase > 2*math.Pi {
|
||||
phase -= 2 * math.Pi
|
||||
}
|
||||
bits := math.Float32bits(v)
|
||||
le.PutUint32(payload[s*4:], bits) // left
|
||||
le.PutUint32(payload[(s+1)*4:], bits) // right
|
||||
}
|
||||
return payload
|
||||
})
|
||||
defer t.setTXFeed(nil)
|
||||
|
||||
// The drive is in the line because it is half of "how much power came out".
|
||||
// A tone at full scale into a drive of 15 is still 15% of the radio.
|
||||
debugLog.Printf("TCI: TX PROBE starting — %d s of a %.0f Hz tone at %.0f%% of full scale, answered to the radio's own requests, mode %s, radio drive %d%%, INTO A DUMMY LOAD",
|
||||
seconds, toneHz, amp*100, mode, drive)
|
||||
|
||||
if err := t.SetPTT(true); err != nil {
|
||||
return fmt.Errorf("could not key the radio: %w", err)
|
||||
}
|
||||
// Every path out unkeys, including the panic that has not happened yet. A
|
||||
// transmitter left keyed by a defect is the one fault here that would reach
|
||||
// somebody else's band.
|
||||
defer func() {
|
||||
if err := t.SetPTT(false); err != nil {
|
||||
debugLog.Printf("TCI: TX PROBE — UNKEY FAILED (%v) — stop the transmission at the radio", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the radio to ask, and give up quickly if it does not.
|
||||
//
|
||||
// The radio declares what it wants by requesting audio — 47 times a second
|
||||
// when it wants any at all. So there is no need to decide in advance whether
|
||||
// this mode or that setting will work: key, listen for one request, and if
|
||||
// none comes in a fifth of a second, stop. That is a quarter of a second of
|
||||
// carrier instead of five, and an answer that names the setting to change.
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
t.audio.mu.Lock()
|
||||
asked := t.audio.txSent > 0
|
||||
t.audio.mu.Unlock()
|
||||
if asked {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.audio.mu.Lock()
|
||||
started := t.audio.txSent
|
||||
t.audio.mu.Unlock()
|
||||
if started == 0 {
|
||||
debugLog.Printf("TCI: TX PROBE — the radio never asked for audio; set ExpertSDR3's transmit audio source to TCI (it is on the microphone)")
|
||||
return fmt.Errorf("the radio did not ask for any audio — set ExpertSDR3's transmit audio source to TCI instead of the microphone, then try again")
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(seconds)*time.Second - 200*time.Millisecond)
|
||||
|
||||
t.audio.mu.Lock()
|
||||
sent, short := t.audio.txSent, t.audio.txShort
|
||||
chrono := t.audio.countByType[tciStreamTXChrono] - t.audio.txMark[tciStreamTXChrono]
|
||||
t.audio.mu.Unlock()
|
||||
debugLog.Printf("TCI: TX PROBE finished — the radio asked %d times, %d frames sent, %d requests unanswered",
|
||||
chrono, sent, short)
|
||||
if sent == 0 {
|
||||
debugLog.Printf("TCI: TX PROBE — the radio never asked for audio; in a digital mode it should, so check that ExpertSDR3 takes its transmit audio from TCI")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tciDigitalMode says whether the radio's current mode is one where network
|
||||
// audio reaches the modulator. Measured on a SunSDR: DIGU asks for audio, SSB
|
||||
// never does.
|
||||
func tciDigitalMode(mode string) bool {
|
||||
switch mode {
|
||||
case "digu", "digl", "DIGU", "DIGL", "FT8", "ft8", "FT4", "ft4", "DATA", "data", "RTTY", "rtty":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user