diff --git a/app.go b/app.go index 0f4b7ee..dede007 100644 --- a/app.go +++ b/app.go @@ -781,8 +781,8 @@ type App struct { // so closing the QSL Manager — or starting another download — stops the previous // one instead of leaving it running against the app-lifetime context (which made // a still-running QRZ sync bleed its log into a freshly started LoTW download). - confDLMu sync.Mutex - confDLCancel context.CancelFunc + confDLMu sync.Mutex + confDLCancel context.CancelFunc // hamlogUnmatched holds the confirmations the last HAMLOG.online import // could not place onto a QSO, kept so they can be exported and worked @@ -790,31 +790,31 @@ type App struct { // accumulated: it describes one run, not a history. hamlogUnmatchedMu sync.Mutex hamlogUnmatched []qso.QSO - udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check - adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets) - syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file - syncSent int64 // changes written to the folder this session - syncReceived int64 // changes taken from the other machines this session - syncLast time.Time // last completed pass, for the status panel - syncErr string // last folder error, shown in settings — a share that dropped is otherwise invisible - relayAutoMu sync.Mutex // serialises relay auto-control evaluation - relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change - relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off - relayDrvMu sync.Mutex // guards the cached relay drivers below - relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call) - pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted - pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle - pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) - startupErr string // captured for surfacing to the frontend - settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme - dbPath string // settings/config database file (settings + profiles); may be a user-chosen location - logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere - logDbPath string // resolved SQLite file actually backing logDb ("" on MySQL, or when the settings db serves as the logbook) — the file the backup snapshots - logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback) - dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup - dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite - offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable - offlineMode bool // last write failed because the DB was unreachable + udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check + adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets) + syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file + syncSent int64 // changes written to the folder this session + syncReceived int64 // changes taken from the other machines this session + syncLast time.Time // last completed pass, for the status panel + syncErr string // last folder error, shown in settings — a share that dropped is otherwise invisible + relayAutoMu sync.Mutex // serialises relay auto-control evaluation + relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change + relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off + relayDrvMu sync.Mutex // guards the cached relay drivers below + relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call) + pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted + pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle + pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) + startupErr string // captured for surfacing to the frontend + settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme + dbPath string // settings/config database file (settings + profiles); may be a user-chosen location + logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere + logDbPath string // resolved SQLite file actually backing logDb ("" on MySQL, or when the settings db serves as the logbook) — the file the backup snapshots + logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback) + dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup + dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable + offlineMode bool // last write failed because the DB was unreachable catFlexSpots bool // push cluster spots to the FlexRadio panadapter catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message @@ -8313,8 +8313,49 @@ type AudioSettings struct { // ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints // for the device dropdowns. -func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() } -func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() } +// ListAudioInputDevices lists the microphones and line inputs, plus THE RADIO +// when the CAT link carries its receive audio. +// +// Same reasoning as the output list: over TCI there is no sound device for +// Windows to show, so without this the one correct answer to "where does the +// received audio come from" could not be chosen at all. +func (a *App) ListAudioInputDevices() ([]audio.Device, error) { + devs, err := audio.ListInputDevices() + if err != nil { + return devs, err + } + if a.tciAudioAvailable() { + devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (TCI network audio)"}}, devs...) + } + return devs, nil +} + +// tciAudioAvailable says whether the active CAT backend is a radio that streams +// its audio over the CAT link. +func (a *App) tciAudioAvailable() bool { + if a.cat == nil { + return false + } + _, ok := a.cat.TCIAudioState() + return ok +} + +// ListAudioOutputDevices lists the sound cards, plus THE RADIO ITSELF when the +// CAT link can carry transmit audio. +// +// Offered only while it is actually available, and named as a radio rather than +// as a protocol: an operator choosing where their voice goes is picking between +// "my sound card" and "the radio", not between WASAPI and TCI. +func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { + devs, err := audio.ListOutputDevices() + if err != nil { + return devs, err + } + if audio.NetworkPlayerReady() { + devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (TCI network audio)"}}, devs...) + } + return devs, nil +} // GetAudioSettings returns the stored audio config (preroll defaults to 8s). func (a *App) GetAudioSettings() (AudioSettings, error) { @@ -8416,6 +8457,15 @@ func (a *App) SaveAudioSettings(s AudioSettings) error { return err } } + // Choosing the radio as the receive device opens its stream, and choosing + // anything else closes it. Done HERE rather than left to the next restart: + // a device chosen in a dropdown that only takes effect after a relaunch + // reads as a device that does not work. + if s.FromRadio == audio.NetworkDeviceID { + a.startTCIRecording() + } else if a.tciAudioAvailable() && a.settingOr(keyTCIRecAudio, "") != "1" { + _ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() }) + } // Apply device/preroll/enable changes to the running recorder. a.startQSORecorderIfEnabled() // And to a monitor ALREADY RUNNING: the operator is listening while they @@ -8460,7 +8510,7 @@ func (a *App) startQSORecorderIfEnabled() { // nothing right to point at. The stream is pushed into the recorder instead // — same samples, no sound card in the middle, and no virtual cable to set up. from := cfg.FromRadio - a.qsoRecPushed = a.icomNetAudioActive() + a.qsoRecPushed = a.icomNetAudioActive() || cfg.FromRadio == audio.NetworkDeviceID if a.qsoRecPushed { from = audio.PushedSource } @@ -15141,6 +15191,12 @@ func (a *App) reloadCAT() { } else { a.catSig = sig } + // Withdraw the radio as an audio output before deciding anything else. The + // TCI case below puts it back; every other backend, and a CAT link turned + // off entirely, leaves it withdrawn — a voice keyer that still lists a radio + // it can no longer reach would play a message to nowhere, and the operator + // hears their own PTT click and assumes it went out. + a.installTCITXPlayer(false) if !s.Enabled { a.cat.Stop() return @@ -15267,6 +15323,9 @@ func (a *App) reloadCAT() { // app_tci_rec.go. Armed after the backend is up, since it is the // backend that carries the stream. defer a.startTCIRecording() + // And the other direction: the voice keyer can send its messages over + // the same link — see app_tci_dvk.go. + defer a.installTCITXPlayer(true) tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots) // Clicking one of our spots on the ExpertSDR panorama fills the entry form. tb.OnSpotClick = func(call string, hz int64) { @@ -20341,8 +20400,6 @@ func clampSpotMax(n int) int { return n } - - // GetSpotTTLMinutes returns how long a spot stays in the list, in minutes. // 0 means spots are kept until the count cap pushes them out, which is what // OpsLog always did. diff --git a/app_tci_dvk.go b/app_tci_dvk.go new file mode 100644 index 0000000..eab1075 --- /dev/null +++ b/app_tci_dvk.go @@ -0,0 +1,60 @@ +package main + +// The voice keyer, through the radio's own link. +// +// Selecting the radio as the "To radio" output makes the voice keyer hand its +// messages to the CAT backend instead of a sound card. Everything around it is +// unchanged — the same PTT before and after, the same gain, the same files — +// which is the point: the audio takes a different road, not a different route. + +import ( + "fmt" + + "hamlog/internal/applog" + "hamlog/internal/audio" + "hamlog/internal/cat" +) + +// tciTXPlayer hands one message to the radio. +// +// The controller is fetched on the CAT goroutine and the message is then played +// OFF it. Playing on it would hold that goroutine for the length of the +// message, and everything else about the rig — frequency, mode, PTT state — +// goes through the same place: a ten-second call would freeze the display and +// the antenna following for ten seconds. The TCI backend serialises its own +// writes, so this is safe to call from here. +func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error { + if a.cat == nil { + return fmt.Errorf("CAT not initialized") + } + type txPlayer interface { + PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error + } + var player txPlayer + err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { + p, ok := t.(txPlayer) + if !ok { + return fmt.Errorf("this radio cannot take transmit audio over its CAT link") + } + player = p + return nil + }) + if err != nil { + return err + } + return player.PlayTXAudio(pcm, rate, ch, bits, stop) +} + +// installTCITXPlayer offers the radio as an audio output, or withdraws it. +// +// Withdrawing matters as much as offering: a radio that has gone away must stop +// being a device the voice keyer will happily "play" to, or a message goes +// nowhere and the operator hears their own PTT click and assumes it worked. +func (a *App) installTCITXPlayer(on bool) { + if !on { + audio.SetNetworkPlayer(nil) + return + } + audio.SetNetworkPlayer(a.tciTXPlayer) + applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer") +} diff --git a/app_tci_rec.go b/app_tci_rec.go index 905e587..ced5a51 100644 --- a/app_tci_rec.go +++ b/app_tci_rec.go @@ -71,7 +71,15 @@ func tciToRecorderPCM(rate int, samples []float32) []byte { // for it: opening a 384 kB/s stream on a station that records nothing is work // the radio does for nobody. func (a *App) startTCIRecording() { - if a.cat == nil || a.settingOr(keyTCIRecAudio, "") != "1" { + 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. + cfg, _ := a.GetAudioSettings() + if a.settingOr(keyTCIRecAudio, "") != "1" && cfg.FromRadio != audio.NetworkDeviceID { return } err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { diff --git a/internal/audio/manager.go b/internal/audio/manager.go index 2f9ae9e..0c4b36c 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -157,7 +157,21 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error { // instantly, the PTT is released 120 ms later, and NOTHING says why — // which is exactly what a station heard as "it plays once, then never // again": the call succeeded, the sound did not. - if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil { + play := func() error { return playPCM(deviceID, pcm, rate, ch, bits, stop) } + if deviceID == NetworkDeviceID { + // Straight to the radio over its own link. Decided HERE rather than + // inside playPCM because there is no Windows endpoint to open: asked + // for one, the system complains about a missing device instead of + // saying the true thing, which is that no radio is connected. + fn := networkPlayer() + play = func() error { + if fn == nil { + return errNoNetworkRadio + } + return fn(pcm, rate, ch, bits, stop) + } + } + if err := play(); err != nil { LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err) } m.mu.Lock() diff --git a/internal/audio/network.go b/internal/audio/network.go new file mode 100644 index 0000000..d1a9c7d --- /dev/null +++ b/internal/audio/network.go @@ -0,0 +1,67 @@ +package audio + +// Playing a message through the RADIO instead of a sound card. +// +// A SunSDR takes its transmit audio over TCI, on the same socket as the +// commands, so the voice keyer can hand it the message directly: no virtual +// cable, no second sound card, no Windows mixer between the recording and the +// air. To everything above, that radio is simply another output device. +// +// The device it presents itself as is a name rather than a WASAPI endpoint id, +// which is why Play checks for it before opening anything: there is no endpoint +// to open, and asking Windows for one produces a confusing error about a device +// that does not exist rather than the truth, which is that nothing is connected +// to the radio. + +import ( + "errors" + "sync" +) + +// NetworkDeviceID is the id the radio-over-network output carries in the +// settings and in the device lists. A fixed string, not a Windows endpoint id: +// it is chosen by us and must survive a radio being switched off and on. +const NetworkDeviceID = "net:radio" + +// NetworkPlayer sends already-decoded PCM to the radio, returning when the +// message has been played or when stop is closed. +// +// It carries the same arguments as the sound-card path so that Play can hand +// over whatever it read, and the radio can decide what converting it needs — +// the sample rate a WAV was recorded at is not the radio's business until the +// moment it has to be resampled. +type NetworkPlayer func(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error + +var ( + netMu sync.RWMutex + netPlayer NetworkPlayer +) + +// SetNetworkPlayer installs (or clears, with nil) the radio's transmit path. +// +// Package-level rather than per-Manager: there is one radio, the CAT backend +// owns it, and a Manager that happened to be built before the radio connected +// would otherwise be permanently unable to reach it. +func SetNetworkPlayer(fn NetworkPlayer) { + netMu.Lock() + netPlayer = fn + netMu.Unlock() +} + +// networkPlayer returns the installed player, or nil. +func networkPlayer() NetworkPlayer { + netMu.RLock() + defer netMu.RUnlock() + return netPlayer +} + +// NetworkPlayerReady says whether a radio is currently able to take transmit +// audio, so the settings panel can offer the option honestly rather than +// listing a device that would fail when used. +func NetworkPlayerReady() bool { return networkPlayer() != nil } + +// errNoNetworkRadio is what a message played to a radio that is not there +// comes back with. Named, because "the device could not be opened" would send +// an operator hunting through Windows sound settings for a device that never +// existed. +var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (the radio output only works with a TCI radio)") diff --git a/internal/cat/tci_tx_play.go b/internal/cat/tci_tx_play.go new file mode 100644 index 0000000..d1249ee --- /dev/null +++ b/internal/cat/tci_tx_play.go @@ -0,0 +1,189 @@ +package cat + +// Playing a recorded message to the radio over TCI — the voice keyer's path. +// +// The same exchange the tone probe established, with a WAV in place of the +// sine: the radio asks for a frame, we answer with the next slice of the +// message, and it sets the pace. What is added here is the conversion, because +// a recording is whatever the microphone gave it — 16-bit, often mono, often +// not 48 kHz — and the radio wants interleaved float32 at the stream's rate. +// +// The message is converted ONCE, up front, rather than per frame. A voice +// message is a few hundred kilobytes; resampling it inside the callback would +// put arithmetic on the path that has 21 ms to answer, and a late frame is a +// gap in what goes out. + +import ( + "encoding/binary" + "fmt" + "math" + "time" +) + +// tciTXFirstAskTimeout is how long to wait for the radio to ask for the first +// frame before giving up. +// +// It answers within a frame or two when it is going to answer at all, so this +// is generous. When it stays quiet the cause is always the same — the transmit +// audio source is the microphone rather than TCI — and a fifth of a second of +// carrier is a cheap way to find that out. +const tciTXFirstAskTimeout = 200 * time.Millisecond + +// PlayTXAudio sends one message and returns when it has all been handed over, +// or when stop is closed. +// +// The PTT is NOT touched here. The voice keyer keys before calling and unkeys +// after, exactly as it does with a sound card, so the transmission is bracketed +// by the same code whichever way the audio travels. +func (t *TCI) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error { + t.mu.Lock() + connected := t.conn != nil + t.mu.Unlock() + if !connected { + return fmt.Errorf("not connected to the radio") + } + + t.audio.mu.Lock() + outRate := t.audio.rate + t.audio.mu.Unlock() + if outRate <= 0 { + outRate = 48000 + } + + mono := decodeToMono(pcm, ch, bits) + if len(mono) == 0 { + return fmt.Errorf("the message is empty") + } + if rate > 0 && rate != outRate { + mono = resampleLinear(mono, rate, outRate) + } + + // Served from here on. The callback does nothing but copy and interleave, + // which is what keeps it inside the frame interval. + pos := 0 + done := make(chan struct{}) + var closed bool + t.setTXFeed(func(samples int) []byte { + if samples <= 0 { + samples = 2048 + } + pairs := samples / 2 + if pos >= len(mono) { + if !closed { + closed = true + close(done) + } + return nil + } + payload := make([]byte, samples*4) + le := binary.LittleEndian + for i := 0; i < pairs; i++ { + var v float32 + if pos < len(mono) { + v = mono[pos] + pos++ + } + bits := math.Float32bits(v) + le.PutUint32(payload[(i*2)*4:], bits) // left + le.PutUint32(payload[(i*2+1)*4:], bits) // right + } + return payload + }) + defer t.setTXFeed(nil) + + // Nothing asked for in a fifth of a second means nothing is listening. + // Reported plainly: the message would otherwise go out as silence, and a + // voice keyer that transmits silence is worse than one that refuses. + deadline := time.Now().Add(tciTXFirstAskTimeout) + for time.Now().Before(deadline) { + t.audio.mu.Lock() + asked := t.audio.txSent > 0 + t.audio.mu.Unlock() + if asked { + break + } + select { + case <-stop: + return nil + case <-time.After(10 * time.Millisecond): + } + } + t.audio.mu.Lock() + asked := t.audio.txSent + t.audio.mu.Unlock() + if asked == 0 { + return fmt.Errorf("the radio did not ask for any audio — set its transmit audio source to TCI instead of the microphone") + } + + // The radio drains the message at real time, so this waits for the feed to + // run out. The cap is the message's own length with a second to spare: a + // radio that stops asking mid-message must not hold the transmitter up. + limit := time.Duration(float64(len(mono))/float64(outRate)*float64(time.Second)) + time.Second + select { + case <-done: + case <-stop: + case <-time.After(limit): + debugLog.Printf("TCI: the radio stopped asking for audio before the message ended") + } + return nil +} + +// decodeToMono turns interleaved PCM into one channel of -1…1 floats. +func decodeToMono(pcm []byte, ch, bits int) []float32 { + if ch <= 0 { + ch = 1 + } + switch bits { + case 16: + frame := ch * 2 + out := make([]float32, 0, len(pcm)/frame+1) + for i := 0; i+frame <= len(pcm); i += frame { + var sum float32 + for c := 0; c < ch; c++ { + v := int16(uint16(pcm[i+c*2]) | uint16(pcm[i+c*2+1])<<8) + sum += float32(v) / 32768 + } + out = append(out, sum/float32(ch)) + } + return out + case 8: + // Unsigned, centred on 128 — the one format where silence is not zero. + out := make([]float32, 0, len(pcm)/ch+1) + for i := 0; i+ch <= len(pcm); i += ch { + var sum float32 + for c := 0; c < ch; c++ { + sum += (float32(pcm[i+c]) - 128) / 128 + } + out = append(out, sum/float32(ch)) + } + return out + } + return nil +} + +// resampleLinear moves samples from one rate to another. +// +// Linear interpolation, which is crude and entirely adequate here: a voice +// recording at 16 kHz going to 48 kHz is being INTERPOLATED, and interpolation +// invents no frequencies to alias. Going the other way would want a filter +// first, but a message recorded above the radio's stream rate is not a case +// that arises — the recorder works at 16 kHz and radios stream at 48. +func resampleLinear(in []float32, from, to int) []float32 { + if from <= 0 || to <= 0 || from == to || len(in) == 0 { + return in + } + ratio := float64(from) / float64(to) + n := int(float64(len(in)) / ratio) + out := make([]float32, n) + for i := 0; i < n; i++ { + src := float64(i) * ratio + j := int(src) + frac := float32(src - float64(j)) + if j+1 < len(in) { + out[i] = in[j]*(1-frac) + in[j+1]*frac + } else { + out[i] = in[len(in)-1] + } + } + return out +}