feat: added full support in USB (local) & ethernet (local or remote) of audio for Icom
This commit is contained in:
@@ -96,6 +96,7 @@ const (
|
|||||||
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
|
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
|
||||||
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||||
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||||
|
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
|
||||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||||
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
||||||
@@ -279,6 +280,7 @@ type CATSettings struct {
|
|||||||
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
|
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
|
||||||
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
|
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
|
||||||
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
|
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
|
||||||
|
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
|
||||||
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
||||||
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
||||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||||
@@ -4321,7 +4323,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -4338,6 +4340,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
IcomNetHost: m[keyCATIcomNetHost],
|
IcomNetHost: m[keyCATIcomNetHost],
|
||||||
IcomNetUser: m[keyCATIcomNetUser],
|
IcomNetUser: m[keyCATIcomNetUser],
|
||||||
IcomNetPass: m[keyCATIcomNetPass],
|
IcomNetPass: m[keyCATIcomNetPass],
|
||||||
|
IcomNetAudio: m[keyCATIcomNetAudio] == "1",
|
||||||
TCIHost: m[keyCATTCIHost],
|
TCIHost: m[keyCATTCIHost],
|
||||||
TCIPort: 40001,
|
TCIPort: 40001,
|
||||||
TCISpots: m[keyCATTCISpots] == "1",
|
TCISpots: m[keyCATTCISpots] == "1",
|
||||||
@@ -4413,6 +4416,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.TCISpots {
|
if s.TCISpots {
|
||||||
tciSpots = "1"
|
tciSpots = "1"
|
||||||
}
|
}
|
||||||
|
icomNetAudio := "0"
|
||||||
|
if s.IcomNetAudio {
|
||||||
|
icomNetAudio = "1"
|
||||||
|
}
|
||||||
if s.DigitalDefault == "" {
|
if s.DigitalDefault == "" {
|
||||||
s.DigitalDefault = "FT8"
|
s.DigitalDefault = "FT8"
|
||||||
}
|
}
|
||||||
@@ -4429,6 +4436,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
|
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
|
||||||
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
|
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
|
||||||
keyCATIcomNetPass: s.IcomNetPass,
|
keyCATIcomNetPass: s.IcomNetPass,
|
||||||
|
keyCATIcomNetAudio: icomNetAudio,
|
||||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||||
keyCATTCISpots: tciSpots,
|
keyCATTCISpots: tciSpots,
|
||||||
@@ -5877,6 +5885,72 @@ func (a *App) DVKStop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you
|
||||||
|
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
|
||||||
|
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
|
||||||
|
// device. This is the USB half of the audio feature; the network 50003 stream
|
||||||
|
// will later Push into the same output path (see audio.Manager.PushMonitorAudio).
|
||||||
|
func (a *App) AudioStartMonitor() error {
|
||||||
|
if a.audioMgr == nil {
|
||||||
|
return fmt.Errorf("audio not initialized")
|
||||||
|
}
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
if strings.TrimSpace(cfg.FromRadio) == "" {
|
||||||
|
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
|
||||||
|
}
|
||||||
|
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
|
||||||
|
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioStopMonitor stops the RX monitor passthrough.
|
||||||
|
func (a *App) AudioStopMonitor() {
|
||||||
|
if a.audioMgr != nil {
|
||||||
|
a.audioMgr.StopMonitor()
|
||||||
|
applog.Printf("audio: RX monitor stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioMonitorActive reports whether the RX monitor is running (for the toggle).
|
||||||
|
func (a *App) AudioMonitorActive() bool {
|
||||||
|
return a.audioMgr != nil && a.audioMgr.MonitorActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioStartTX keys PTT and pipes your live mic into the rig ("To Radio" device)
|
||||||
|
// so you can talk through the PC — the USB half of TX voice. PTT uses the
|
||||||
|
// configured method (CAT/RTS/DTR); if keying fails the audio route isn't started.
|
||||||
|
func (a *App) AudioStartTX() error {
|
||||||
|
if a.audioMgr == nil {
|
||||||
|
return fmt.Errorf("audio not initialized")
|
||||||
|
}
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
if strings.TrimSpace(cfg.ToRadio) == "" {
|
||||||
|
return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`)
|
||||||
|
}
|
||||||
|
if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil {
|
||||||
|
a.pttUnkey()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applog.Printf("audio: TX start (mic=%q → to-radio=%q, ptt=%q)", cfg.RecordingDevice, cfg.ToRadio, cfg.PTTMethod)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioStopTX stops the TX passthrough and unkeys PTT.
|
||||||
|
func (a *App) AudioStopTX() {
|
||||||
|
if a.audioMgr != nil {
|
||||||
|
a.audioMgr.StopTXAudio()
|
||||||
|
}
|
||||||
|
a.pttUnkey()
|
||||||
|
applog.Printf("audio: TX stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioTXActive reports whether the TX passthrough is running (for the toggle).
|
||||||
|
func (a *App) AudioTXActive() bool {
|
||||||
|
return a.audioMgr != nil && a.audioMgr.TXAudioActive()
|
||||||
|
}
|
||||||
|
|
||||||
// GetLogFilePath returns where the diagnostic log file lives so the user
|
// GetLogFilePath returns where the diagnostic log file lives so the user
|
||||||
// can open it from the Settings UI. Empty when applog hasn't initialised.
|
// can open it from the Settings UI. Empty when applog hasn't initialised.
|
||||||
func (a *App) GetLogFilePath() string {
|
func (a *App) GetLogFilePath() string {
|
||||||
@@ -8606,7 +8680,28 @@ func (a *App) reloadCAT() {
|
|||||||
case "icom-net":
|
case "icom-net":
|
||||||
// Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote
|
// Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote
|
||||||
// Utility). Reuses the whole IcomController over the network transport.
|
// Utility). Reuses the whole IcomController over the network transport.
|
||||||
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault))
|
var audioSink func([]byte)
|
||||||
|
if s.IcomNetAudio && a.audioMgr != nil {
|
||||||
|
// Experimental: stream RX audio over 50003. Start the render-only monitor
|
||||||
|
// sink (no USB capture) and feed each decoded payload into it, so remote
|
||||||
|
// RX audio plays through the "Listening" device. Decoding is via the PCM
|
||||||
|
// codec (Opus later). The 50003 payload OFFSET is still pending on-rig
|
||||||
|
// verification (see icomaudio.go) — hence experimental + opt-in.
|
||||||
|
acfg, _ := a.GetAudioSettings()
|
||||||
|
a.audioMgr.StopMonitor() // clear any prior monitor/sink so a re-save restarts cleanly
|
||||||
|
if err := a.audioMgr.StartMonitorSink(acfg.ListeningDevice); err != nil {
|
||||||
|
applog.Printf("icom-net audio: cannot start output sink: %v", err)
|
||||||
|
} else {
|
||||||
|
codec := audio.NewPCM16Codec()
|
||||||
|
audioSink = func(payload []byte) {
|
||||||
|
if pcm, err := codec.Decode(payload); err == nil {
|
||||||
|
a.audioMgr.PushMonitorAudio(pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
|
||||||
case "tci":
|
case "tci":
|
||||||
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
||||||
// TCI-compatible server.
|
// TCI-compatible server.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||||
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||||
|
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
|
||||||
|
AudioStartTX, AudioStopTX, AudioTXActive,
|
||||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
@@ -796,7 +798,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '',
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
@@ -863,6 +865,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
const [dvkErr, setDvkErr] = useState('');
|
const [dvkErr, setDvkErr] = useState('');
|
||||||
|
const [monitorOn, setMonitorOn] = useState(false);
|
||||||
|
const [txOn, setTxOn] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
AudioMonitorActive().then(setMonitorOn).catch(() => {});
|
||||||
|
AudioTXActive().then(setTxOn).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const toggleMonitor = async () => {
|
||||||
|
try {
|
||||||
|
if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
|
||||||
|
else { await AudioStartMonitor(); setMonitorOn(true); }
|
||||||
|
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
|
||||||
|
};
|
||||||
|
const toggleTX = async () => {
|
||||||
|
try {
|
||||||
|
if (txOn) { await AudioStopTX(); setTxOn(false); }
|
||||||
|
else { await AudioStartTX(); setTxOn(true); }
|
||||||
|
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
|
||||||
|
};
|
||||||
|
|
||||||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
@@ -2065,6 +2085,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
||||||
|
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!(catCfg as any).icom_net_audio}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
|
||||||
|
<span>
|
||||||
|
{t('cat.icomNetAudio')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'tci' && (
|
{catCfg.backend === 'tci' && (
|
||||||
@@ -3747,6 +3775,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={monitorOn ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={toggleMonitor}
|
||||||
|
disabled={!monitorOn && !audioCfg.from_radio}
|
||||||
|
title="Hear the rig's RX audio (From Radio) through your Listening device"
|
||||||
|
>
|
||||||
|
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{monitorOn
|
||||||
|
? 'RX monitor running — From Radio → Listening device.'
|
||||||
|
: 'Live-monitor the rig here (USB codec now; network audio later).'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={txOn ? 'destructive' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={toggleTX}
|
||||||
|
disabled={!txOn && !audioCfg.to_radio}
|
||||||
|
title="Key PTT and pipe your live mic into the rig (To Radio device)"
|
||||||
|
>
|
||||||
|
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{txOn
|
||||||
|
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
|
||||||
|
: 'Live mic → rig with PTT (USB now; network TX later).'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ const en: Dict = {
|
|||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
|
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
|
'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
||||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||||
@@ -327,6 +329,8 @@ const fr: Dict = {
|
|||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
|
'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
||||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||||
|
|||||||
Vendored
+12
@@ -36,6 +36,18 @@ export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
|||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function AudioMonitorActive():Promise<boolean>;
|
||||||
|
|
||||||
|
export function AudioStartMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStartTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioTXActive():Promise<boolean>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
|
|||||||
@@ -34,6 +34,30 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AudioMonitorActive() {
|
||||||
|
return window['go']['main']['App']['AudioMonitorActive']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStartMonitor() {
|
||||||
|
return window['go']['main']['App']['AudioStartMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStartTX() {
|
||||||
|
return window['go']['main']['App']['AudioStartTX']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStopMonitor() {
|
||||||
|
return window['go']['main']['App']['AudioStopMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStopTX() {
|
||||||
|
return window['go']['main']['App']['AudioStopTX']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioTXActive() {
|
||||||
|
return window['go']['main']['App']['AudioTXActive']();
|
||||||
|
}
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1349,6 +1349,7 @@ export namespace main {
|
|||||||
icom_net_host: string;
|
icom_net_host: string;
|
||||||
icom_net_user: string;
|
icom_net_user: string;
|
||||||
icom_net_pass: string;
|
icom_net_pass: string;
|
||||||
|
icom_net_audio: boolean;
|
||||||
tci_host: string;
|
tci_host: string;
|
||||||
tci_port: number;
|
tci_port: number;
|
||||||
tci_spots: boolean;
|
tci_spots: boolean;
|
||||||
@@ -1374,6 +1375,7 @@ export namespace main {
|
|||||||
this.icom_net_host = source["icom_net_host"];
|
this.icom_net_host = source["icom_net_host"];
|
||||||
this.icom_net_user = source["icom_net_user"];
|
this.icom_net_user = source["icom_net_user"];
|
||||||
this.icom_net_pass = source["icom_net_pass"];
|
this.icom_net_pass = source["icom_net_pass"];
|
||||||
|
this.icom_net_audio = source["icom_net_audio"];
|
||||||
this.tci_host = source["tci_host"];
|
this.tci_host = source["tci_host"];
|
||||||
this.tci_port = source["tci_port"];
|
this.tci_port = source["tci_port"];
|
||||||
this.tci_spots = source["tci_spots"];
|
this.tci_spots = source["tci_spots"];
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Codec converts between the wire payload of a network audio stream (Icom 50003)
|
||||||
|
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
|
||||||
|
// capture/render engine and the pcmRing use). It exists so the transport code
|
||||||
|
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
|
||||||
|
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
|
||||||
|
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
|
||||||
|
type Codec interface {
|
||||||
|
// Name is a short label for logs/UI.
|
||||||
|
Name() string
|
||||||
|
// Decode turns one received audio payload into internal PCM. The returned
|
||||||
|
// slice is freshly allocated (the caller may retain it).
|
||||||
|
Decode(payload []byte) ([]byte, error)
|
||||||
|
// Encode turns internal PCM into a payload to transmit.
|
||||||
|
Encode(pcm []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
|
||||||
|
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
|
||||||
|
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
|
||||||
|
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
|
||||||
|
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
|
||||||
|
//
|
||||||
|
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
|
||||||
|
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
|
||||||
|
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
|
||||||
|
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
|
||||||
|
// in one place.
|
||||||
|
type pcm16Codec struct{}
|
||||||
|
|
||||||
|
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
|
||||||
|
func NewPCM16Codec() Codec { return pcm16Codec{} }
|
||||||
|
|
||||||
|
func (pcm16Codec) Name() string { return "pcm16" }
|
||||||
|
|
||||||
|
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
|
||||||
|
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
|
||||||
|
// packet — trim the stray byte rather than emit a half-sample click.
|
||||||
|
n := len(payload) &^ 1
|
||||||
|
if n != len(payload) {
|
||||||
|
if n == 0 {
|
||||||
|
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, payload[:n])
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
|
||||||
|
out := make([]byte, len(pcm))
|
||||||
|
copy(out, pcm)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPCM16CodecRoundTrip(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
|
||||||
|
enc, err := c.Encode(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
dec, err := c.Decode(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if string(dec) != string(in) {
|
||||||
|
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPCM16CodecTrimsOddByte(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(dec) != 2 {
|
||||||
|
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
if _, err := c.Decode([]byte{0x10}); err == nil {
|
||||||
|
t.Fatalf("expected an error for a sub-sample payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
||||||
|
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
||||||
|
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
||||||
|
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
||||||
|
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
||||||
|
// without touching the render side, mirroring the civTransport split on the CAT
|
||||||
|
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
||||||
|
// underrun Pull simply returns short and the render loop pads with silence.
|
||||||
|
type pcmRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []byte
|
||||||
|
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
||||||
|
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
||||||
|
func newPCMRing(maxBytes int) *pcmRing {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = bytesPerSec // 1 s default
|
||||||
|
}
|
||||||
|
return &pcmRing{max: maxBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
||||||
|
// the cap (a slow/absent consumer never makes the producer block or grow without
|
||||||
|
// bound). A short glitch beats runaway latency for live monitoring.
|
||||||
|
func (r *pcmRing) Push(p []byte) {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.buf = append(r.buf, p...)
|
||||||
|
if len(r.buf) > r.max {
|
||||||
|
drop := len(r.buf) - r.max
|
||||||
|
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
||||||
|
// when empty. The render loop pads any shortfall with silence.
|
||||||
|
func (r *pcmRing) pull(maxBytes int) []byte {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.buf) == 0 || maxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := maxBytes
|
||||||
|
if n > len(r.buf) {
|
||||||
|
n = len(r.buf)
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, r.buf[:n])
|
||||||
|
r.buf = append(r.buf[:0], r.buf[n:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStream continuously renders PCM pulled from src to a device until stop
|
||||||
|
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
||||||
|
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
||||||
|
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
|
||||||
|
// COM-initialised, OS-locked thread.
|
||||||
|
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
if err := coInit(); err != nil {
|
||||||
|
return fmt.Errorf("CoInitialize: %w", err)
|
||||||
|
}
|
||||||
|
defer ole.CoUninitialize()
|
||||||
|
|
||||||
|
dev, err := openDevice(wca.ERender, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dev.Release()
|
||||||
|
|
||||||
|
var ac *wca.IAudioClient
|
||||||
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
||||||
|
return fmt.Errorf("activate render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Release()
|
||||||
|
|
||||||
|
frameBytes := ch * bits / 8
|
||||||
|
if frameBytes <= 0 {
|
||||||
|
return fmt.Errorf("bad audio format")
|
||||||
|
}
|
||||||
|
wfx := &wca.WAVEFORMATEX{
|
||||||
|
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
||||||
|
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
||||||
|
WBitsPerSample: uint16(bits), CbSize: 0,
|
||||||
|
}
|
||||||
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
||||||
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
||||||
|
return fmt.Errorf("initialize render: %w", err)
|
||||||
|
}
|
||||||
|
var bufFrames uint32
|
||||||
|
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var arc *wca.IAudioRenderClient
|
||||||
|
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
||||||
|
return fmt.Errorf("get render service: %w", err)
|
||||||
|
}
|
||||||
|
defer arc.Release()
|
||||||
|
|
||||||
|
// feed fills up to `frames` render frames: as much real audio as the ring
|
||||||
|
// has, the remainder silence (so the buffer stays full and the clock steady).
|
||||||
|
feed := func(frames int) error {
|
||||||
|
if frames <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data *byte
|
||||||
|
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst := unsafe.Slice(data, frames*frameBytes)
|
||||||
|
got := src.pull(frames * frameBytes)
|
||||||
|
n := copy(dst, got)
|
||||||
|
for i := n; i < len(dst); i++ {
|
||||||
|
dst[i] = 0 // silence-fill the shortfall
|
||||||
|
}
|
||||||
|
arc.ReleaseBuffer(uint32(frames), 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ac.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var padding uint32
|
||||||
|
ac.GetCurrentPadding(&padding)
|
||||||
|
if err := feed(int(bufFrames - padding)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(8 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+131
-1
@@ -15,7 +15,10 @@ type Manager struct {
|
|||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
playStop chan struct{}
|
playStop chan struct{}
|
||||||
onChange func() // fired on any record/playback state transition
|
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||||
|
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||||
|
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||||
|
onChange func() // fired on any record/playback state transition
|
||||||
}
|
}
|
||||||
|
|
||||||
type recResult struct {
|
type recResult struct {
|
||||||
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
|
|||||||
m.notify()
|
m.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||||
|
//
|
||||||
|
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
|
||||||
|
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
|
||||||
|
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
|
||||||
|
// is later fed by the network 50003 stream instead of a USB capture — the render
|
||||||
|
// half is transport-agnostic. inputDev "" = system default capture.
|
||||||
|
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
|
||||||
|
return m.startMonitor(inputDev, outputDev, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
||||||
|
// producer — the network 50003 stream — can feed decoded RX PCM via
|
||||||
|
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
||||||
|
func (m *Manager) StartMonitorSink(outputDev string) error {
|
||||||
|
return m.startMonitor("", outputDev, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
|
||||||
|
// when capture is true it also captures inputDev into that ring (USB monitor).
|
||||||
|
// When false the ring is fed only by PushMonitorAudio (network audio).
|
||||||
|
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.monStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("monitor already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
|
||||||
|
m.monStop, m.monRing = stop, ring
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if capture {
|
||||||
|
// Producer: capture the rig's USB audio into the ring.
|
||||||
|
go func() {
|
||||||
|
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||||
|
go func() {
|
||||||
|
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopMonitor stops the RX monitor passthrough.
|
||||||
|
func (m *Manager) StopMonitor() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.monStop
|
||||||
|
m.monStop, m.monRing = nil, nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorActive reports whether the RX monitor passthrough is running.
|
||||||
|
func (m *Manager) MonitorActive() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.monStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
|
||||||
|
// active monitor's output — the hook the network 50003 audio stream uses to play
|
||||||
|
// decoded RX through the very same output path a USB capture feeds. No-op when no
|
||||||
|
// monitor is running. Keeps the unexported ring inside the package.
|
||||||
|
func (m *Manager) PushMonitorAudio(pcm []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
ring := m.monRing
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ring != nil {
|
||||||
|
ring.Push(pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
||||||
|
//
|
||||||
|
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
|
||||||
|
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
|
||||||
|
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
|
||||||
|
// capture + render primitives, source/sink swapped). PTT keying is the caller's
|
||||||
|
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
|
||||||
|
// audio route. The captured 16 kHz mono stream is also the exact shape the future
|
||||||
|
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
|
||||||
|
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.txStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("TX audio already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
|
||||||
|
m.txStop = stop
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopTXAudio stops the TX mic→rig passthrough.
|
||||||
|
func (m *Manager) StopTXAudio() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.txStop
|
||||||
|
m.txStop = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TXAudioActive reports whether the TX mic→rig passthrough is running.
|
||||||
|
func (m *Manager) TXAudioActive() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.txStop != nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
|
|||||||
return "IC-7300"
|
return "IC-7300"
|
||||||
case 0x98:
|
case 0x98:
|
||||||
return "IC-7610"
|
return "IC-7610"
|
||||||
|
case 0x7C:
|
||||||
|
return "IC-9100"
|
||||||
case 0xA2:
|
case 0xA2:
|
||||||
return "IC-9700"
|
return "IC-9700"
|
||||||
case 0xA4:
|
case 0xA4:
|
||||||
|
|||||||
+47
-10
@@ -803,24 +803,44 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
||||||
// use. Caller holds f.mu.
|
// use. Caller holds f.mu.
|
||||||
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||||
best, bestS := 1<<30, (*flexSlice)(nil)
|
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
|
||||||
for idx, s := range f.slices {
|
// randomises. When two slices transiently BOTH report active=1 (e.g. an
|
||||||
|
// external controller like DXHunter activates a slice on another band while
|
||||||
|
// ours still holds active, before SmartSDR sends active=0 to the old one),
|
||||||
|
// map order returned a RANDOM active slice each call → the operating frequency
|
||||||
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
|
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||||
|
firstInUse := -1
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
s := f.slices[idx]
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if firstInUse < 0 {
|
||||||
|
firstInUse = idx
|
||||||
|
}
|
||||||
if s.active {
|
if s.active {
|
||||||
return idx, s
|
return idx, s
|
||||||
}
|
}
|
||||||
if idx < best {
|
|
||||||
best, bestS = idx, s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if bestS != nil {
|
if firstInUse >= 0 {
|
||||||
return best, bestS
|
return firstInUse, f.slices[firstInUse]
|
||||||
}
|
}
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortedSliceIdxLocked returns the slice indices in ascending order so every
|
||||||
|
// slice-selection helper is deterministic (map iteration is randomised). Caller
|
||||||
|
// holds f.mu.
|
||||||
|
func (f *Flex) sortedSliceIdxLocked() []int {
|
||||||
|
idxs := make([]int, 0, len(f.slices))
|
||||||
|
for idx := range f.slices {
|
||||||
|
idxs = append(idxs, idx)
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
return idxs
|
||||||
|
}
|
||||||
|
|
||||||
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
||||||
// slice, else 0). Caller holds f.mu.
|
// slice, else 0). Caller holds f.mu.
|
||||||
func (f *Flex) activeSliceIndexLocked() int {
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
@@ -841,8 +861,8 @@ func sliceLetter(idx int) string {
|
|||||||
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
||||||
// Caller holds f.mu.
|
// Caller holds f.mu.
|
||||||
func (f *Flex) txSliceLocked() *flexSlice {
|
func (f *Flex) txSliceLocked() *flexSlice {
|
||||||
for _, s := range f.slices {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if s.inUse && s.tx {
|
if s := f.slices[idx]; s.inUse && s.tx {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -871,7 +891,8 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
|||||||
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
|
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
|
||||||
rx = main
|
rx = main
|
||||||
} else {
|
} else {
|
||||||
for _, s := range f.slices {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
s := f.slices[idx]
|
||||||
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
|
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
|
||||||
rx = s
|
rx = s
|
||||||
break
|
break
|
||||||
@@ -927,6 +948,15 @@ func (f *Flex) SetFrequency(hz int64) error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
idx := f.activeSliceIndexLocked()
|
idx := f.activeSliceIndexLocked()
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
|
// Optimistically update the active slice's cached freq NOW, before the radio
|
||||||
|
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
|
||||||
|
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
|
||||||
|
// jumped to the new band while the slice cache — which the FlexPanel and the
|
||||||
|
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
|
||||||
|
// the stale value. The real echo confirms/corrects this a moment later.
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.freqHz = hz
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -952,6 +982,13 @@ func (f *Flex) SetMode(mode string) error {
|
|||||||
if fm == "" {
|
if fm == "" {
|
||||||
return fmt.Errorf("flex: unsupported mode %q", mode)
|
return fmt.Errorf("flex: unsupported mode %q", mode)
|
||||||
}
|
}
|
||||||
|
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
|
||||||
|
// panel reflects it immediately instead of lagging the radio's echo.
|
||||||
|
f.mu.Lock()
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.mode = fm
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
||||||
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
|
||||||
|
// It is the third stream alongside control (50001) and CI-V (50002): once the
|
||||||
|
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
|
||||||
|
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
|
||||||
|
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
|
||||||
|
// PROVEN transport — and hands each received audio payload to a sink callback
|
||||||
|
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
|
||||||
|
//
|
||||||
|
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
|
||||||
|
// icnLocalID, icnLE) and the same seq/retransmit discipline.
|
||||||
|
//
|
||||||
|
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
|
||||||
|
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
|
||||||
|
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
|
||||||
|
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
|
||||||
|
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
|
||||||
|
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
|
||||||
|
// confirmed/corrected on the first on-rig test without a packet capture, the same
|
||||||
|
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
|
||||||
|
// audio stream is opt-in and entirely separate from control/CI-V.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// icaAudioOffset is where the PCM payload begins inside an audio data packet
|
||||||
|
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
|
||||||
|
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
|
||||||
|
// is a one-line edit.
|
||||||
|
const icaAudioOffset = 0x16
|
||||||
|
|
||||||
|
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
|
||||||
|
// offset verification. After the layout is confirmed on a real rig this can go to
|
||||||
|
// 0 (or the const above corrected).
|
||||||
|
const icaDumpFirst = 6
|
||||||
|
|
||||||
|
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
|
||||||
|
// 5) will add an encode+send path mirroring icomNet.Write.
|
||||||
|
type icomAudio struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
aID, aRemote uint32
|
||||||
|
|
||||||
|
sink func([]byte) // receives each raw audio payload (app decodes + plays)
|
||||||
|
|
||||||
|
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
|
||||||
|
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
|
||||||
|
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
|
||||||
|
rxHaveSeq bool
|
||||||
|
rxLastSeq uint16
|
||||||
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
|
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
||||||
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
||||||
|
func (a *icomAudio) Close() {
|
||||||
|
a.closeOnce.Do(func() {
|
||||||
|
close(a.done)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
}
|
||||||
|
_ = a.conn.Close()
|
||||||
|
debugLog.Printf("icom audio: stream closed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
|
||||||
|
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
|
||||||
|
// audioport=50003) must already have authorized it. sink receives each raw audio
|
||||||
|
// payload. cancel aborts a slow dial (Stop/Start).
|
||||||
|
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
|
||||||
|
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aID := icnLocalID(conn)
|
||||||
|
aRemote, err := icnHandshake(conn, aID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
debugLog.Printf("icom audio: handshake FAILED: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetReadBuffer(1 << 20)
|
||||||
|
a := &icomAudio{
|
||||||
|
conn: conn, aID: aID, aRemote: aRemote,
|
||||||
|
sink: sink,
|
||||||
|
rxMissing: make(map[uint16]int),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
a.markRx()
|
||||||
|
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
|
||||||
|
go a.audioPump()
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
|
||||||
|
// requests retransmits for lost packets, and hands each audio payload to sink.
|
||||||
|
func (a *icomAudio) audioPump() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastReq := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
|
||||||
|
a.markRx()
|
||||||
|
switch typ := icnLE.Uint16(buf[4:]); {
|
||||||
|
case typ == 0x07: // ping
|
||||||
|
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
|
||||||
|
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
|
||||||
|
case typ == 0x05: // rig-initiated disconnect
|
||||||
|
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
|
||||||
|
case typ == 0x00 && k > icaAudioOffset: // audio data packet
|
||||||
|
a.trackRxSeq(icnLE.Uint16(buf[6:]))
|
||||||
|
if a.dumped < icaDumpFirst {
|
||||||
|
a.dumped++
|
||||||
|
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
|
||||||
|
}
|
||||||
|
if a.sink != nil {
|
||||||
|
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
|
||||||
|
a.sink(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastReq) > 100*time.Millisecond {
|
||||||
|
a.sendRetransmitReq()
|
||||||
|
lastReq = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
|
||||||
|
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
|
||||||
|
// the audio stream owns its own seq state with no shared locking.
|
||||||
|
func (a *icomAudio) trackRxSeq(seq uint16) {
|
||||||
|
if !a.rxHaveSeq {
|
||||||
|
a.rxHaveSeq = true
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch d := int16(seq - a.rxLastSeq); {
|
||||||
|
case d == 0:
|
||||||
|
case d < 0:
|
||||||
|
delete(a.rxMissing, seq)
|
||||||
|
case d == 1:
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
case int(d) <= icnMaxMissing:
|
||||||
|
for f := a.rxLastSeq + 1; f != seq; f++ {
|
||||||
|
a.rxMissing[f] = 0
|
||||||
|
}
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
default:
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) sendRetransmitReq() {
|
||||||
|
if len(a.rxMissing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(a.rxMissing) > icnMaxMissing {
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seqs []uint16
|
||||||
|
for s, cnt := range a.rxMissing {
|
||||||
|
if cnt >= 4 {
|
||||||
|
delete(a.rxMissing, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.rxMissing[s] = cnt + 1
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(seqs) == 0:
|
||||||
|
return
|
||||||
|
case len(seqs) == 1:
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
|
||||||
|
default:
|
||||||
|
b := make([]byte, 16+4*len(seqs))
|
||||||
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
||||||
|
icnLE.PutUint16(b[4:], 0x01)
|
||||||
|
icnLE.PutUint32(b[8:], a.aID)
|
||||||
|
icnLE.PutUint32(b[12:], a.aRemote)
|
||||||
|
off := 16
|
||||||
|
for _, s := range seqs {
|
||||||
|
icnLE.PutUint16(b[off:], s)
|
||||||
|
icnLE.PutUint16(b[off+2:], s)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
_, _ = a.conn.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-8
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
|
|||||||
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
||||||
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
||||||
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
||||||
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial {
|
//
|
||||||
|
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
|
||||||
|
// non-nil the conninfo asks the rig to stream audio and each received payload is
|
||||||
|
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
|
||||||
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
||||||
|
// so enabling it can't affect freq/mode/DSP control.
|
||||||
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
|
|||||||
b.dialMu.Lock()
|
b.dialMu.Lock()
|
||||||
cancel := b.dialCancel
|
cancel := b.dialCancel
|
||||||
b.dialMu.Unlock()
|
b.dialMu.Unlock()
|
||||||
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel)
|
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -130,6 +136,10 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
|
audio *icomAudio
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
@@ -232,6 +242,9 @@ var icnTrace = false
|
|||||||
func (n *icomNet) Close() error {
|
func (n *icomNet) Close() error {
|
||||||
n.closeOnce.Do(func() {
|
n.closeOnce.Do(func() {
|
||||||
close(n.done)
|
close(n.done)
|
||||||
|
if n.audio != nil {
|
||||||
|
n.audio.Close()
|
||||||
|
}
|
||||||
// Tell the rig we're leaving so it frees its SINGLE control session at
|
// Tell the rig we're leaving so it frees its SINGLE control session at
|
||||||
// once. If it never gets a disconnect it holds the session for minutes and
|
// once. If it never gets a disconnect it holds the session for minutes and
|
||||||
// refuses every new login — which is why a lost link (or a hard app exit)
|
// refuses every new login — which is why a lost link (or a hard app exit)
|
||||||
@@ -475,8 +488,9 @@ func (n *icomNet) resend(seq uint16) {
|
|||||||
|
|
||||||
// ------------------------- connect -------------------------
|
// ------------------------- connect -------------------------
|
||||||
|
|
||||||
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) {
|
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
|
||||||
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
|
wantAudio := audioSink != nil
|
||||||
|
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
|
||||||
// ---- control stream (50001): handshake → login → token → conninfo ----
|
// ---- control stream (50001): handshake → login → token → conninfo ----
|
||||||
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -562,7 +576,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
rigMAC = make([]byte, 6)
|
rigMAC = make([]byte, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
var rxEnable byte
|
||||||
|
if wantAudio {
|
||||||
|
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
|
||||||
|
}
|
||||||
|
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
|
||||||
cTracked++
|
cTracked++
|
||||||
cInner++
|
cInner++
|
||||||
drainEnd := time.Now().Add(500 * time.Millisecond)
|
drainEnd := time.Now().Add(500 * time.Millisecond)
|
||||||
@@ -634,6 +652,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
|
|
||||||
go n.ctrlPump()
|
go n.ctrlPump()
|
||||||
go n.civPump()
|
go n.civPump()
|
||||||
|
|
||||||
|
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
||||||
|
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
||||||
|
// CAT works without audio, so we log and continue rather than tear down a
|
||||||
|
// perfectly good control/CI-V session.
|
||||||
|
if wantAudio {
|
||||||
|
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
||||||
|
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
||||||
|
} else {
|
||||||
|
n.audio = a
|
||||||
|
}
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +802,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
|
||||||
b := make([]byte, 0x90)
|
b := make([]byte, 0x90)
|
||||||
icnLE.PutUint32(b[0:], 0x90)
|
icnLE.PutUint32(b[0:], 0x90)
|
||||||
icnLE.PutUint16(b[6:], seq)
|
icnLE.PutUint16(b[6:], seq)
|
||||||
@@ -788,8 +818,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
|
|||||||
copy(b[0x2a:0x30], rigMAC)
|
copy(b[0x2a:0x30], rigMAC)
|
||||||
copy(b[0x40:0x60], []byte("IC-7610"))
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
copy(b[0x60:0x70], icnPasscode(user))
|
copy(b[0x60:0x70], icnPasscode(user))
|
||||||
b[0x70] = 0x00 // rxenable (audio off — CI-V only)
|
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
|
||||||
b[0x71] = 0x00 // txenable
|
b[0x71] = 0x00 // txenable (Phase 5)
|
||||||
b[0x72] = 0x10 // rxcodec
|
b[0x72] = 0x10 // rxcodec
|
||||||
b[0x73] = 0x04 // txcodec
|
b[0x73] = 0x04 // txcodec
|
||||||
icnBE.PutUint32(b[0x74:], 16000)
|
icnBE.PutUint32(b[0x74:], 16000)
|
||||||
|
|||||||
Reference in New Issue
Block a user