diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 8039be2..714be3e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,9 @@ +Quisk Version 4.2.55 October 2026 +================================== +Karl Heinz, K5KHK, provided a patche to fix the AC2YD remote control head. He provided another patch +to fix Hamlib control when using WSJT-X improved. He added a mic jitter buffer to the remote control +head so it works over WiFi. Thanks Karl! + Quisk Version 4.2.54 August 2026 ================================= Quisk can now accept audio samples for Tx from TCI at rates 48000, 24000, 12000 and 8000. Please test. diff --git a/__init__.py b/__init__.py index aaad8d6..04e3763 100644 --- a/__init__.py +++ b/__init__.py @@ -1,2 +1,2 @@ -#Quisk version 4.2.54 +#Quisk version 4.2.55 from .quisk import main diff --git a/ac2yd/remote.c b/ac2yd/remote.c index 46a33de..47faf96 100644 --- a/ac2yd/remote.c +++ b/ac2yd/remote.c @@ -53,6 +53,41 @@ static int control_head_data_width; // app.data_width of control head for gr static int packets_sent; static int packets_recd; +///// The jitter buffer is thanks to Karl Heinz - K5KHK +// Jitter buffer for the microphone samples received from the control head. The network can hold +// packets for 100 msec or more (Wi-Fi power saving and scanning) and then deliver them in a burst, +// but the Hermes Tx buffer only has 50 msec of slack. Without a buffer here, each late burst is an +// underflow (a gap on the air) followed by an overflow (discarded samples). So buffer mic_jb_target +// frames before playing, and then release them at a steady MIC_JB_RATE. +// The buffer adds its delay to the transmit audio, so it is off unless the configuration item +// remote_radio_mic_jitter_ms is set. When it is off, samples are passed on as they arrive. +#define MIC_JB_RATE 8000 // frames per second sent by the control head +#define MIC_JB_SIZE 8000 // ring buffer size in stereo frames +#define MIC_JB_MAX_MS 500 // largest configurable delay; MIC_JB_SIZE must hold this plus MIC_JB_EXTRA +#define MIC_JB_EXTRA 2000 // trim back to mic_jb_target above mic_jb_target + this (clock drift): 250 msec +#define MIC_JB_IDLE 0.5 // seconds without packets that end a transmission + +static int16_t mic_jb[MIC_JB_SIZE * 2]; +static int mic_jb_target; // frames to buffer before playing, from remote_radio_mic_jitter_ms; zero for off +static int mic_jb_read, mic_jb_count, mic_jb_playing, mic_jb_starved; +static int mic_jb_underruns, mic_jb_overruns; +static double mic_jb_time, mic_jb_frac, mic_jb_last_packet; + +static void mic_jb_reset(void) +{ + int msec; + + msec = QuiskGetConfigInt("remote_radio_mic_jitter_ms", 0); + if (msec < 0) + msec = 0; + else if (msec > MIC_JB_MAX_MS) + msec = MIC_JB_MAX_MS; + mic_jb_target = msec * MIC_JB_RATE / 1000; + mic_jb_read = mic_jb_count = mic_jb_playing = mic_jb_starved = 0; + mic_jb_underruns = mic_jb_overruns = 0; + mic_jb_frac = 0; +} + // Receive stereo 16-bit pcm radio speaker sound on the control head via UDP int read_remote_radio_sound_socket(complex double * cSamples) { @@ -117,9 +152,9 @@ int read_remote_radio_sound_socket(complex double * cSamples) // Receive stereo 16-bit pcm microphone samples at the remote radio via UDP int read_remote_mic_sound_socket(complex double * cSamples) { - int i, bytes, nInt16, nSamples; + int i, j, bytes, nInt16, nSamples, nFrames; int16_t buf[UDP_SEND_INT16]; - double samp_r, samp_l; + double now, due; struct timeval tm_wait; fd_set fds; static struct quisk_cHB45Filter HalfBand; @@ -134,8 +169,8 @@ int read_remote_mic_sound_socket(complex double * cSamples) quisk_filt_cInit(&cFiltInterp3, quiskAudio24p3Coefs, sizeof(quiskAudio24p3Coefs)/sizeof(double)); } - // read all available packets, one per loop - nSamples = 0; + // read all available packets into the jitter buffer, one per loop + now = QuiskTimeSec(); while (1) { tm_wait.tv_sec = 0; tm_wait.tv_usec = 0; @@ -152,15 +187,71 @@ int read_remote_mic_sound_socket(complex double * cSamples) break; } if (bytes > 0) { + if (mic_jb_starved) { // the buffer ran dry; count it unless the transmission had ended + mic_jb_starved = 0; + if (now - mic_jb_last_packet < MIC_JB_IDLE) + mic_jb_underruns++; + } + mic_jb_last_packet = now; nInt16 = bytes / 2; - for (i = 0; i < nInt16; i += 2) { - samp_r = buf[i]; - samp_l = buf[i + 1]; - cSamples[nSamples++] = (samp_r + I * samp_l) / CLIP16 * CLIP32; + for (i = 0; i + 1 < nInt16; i += 2) { + if (mic_jb_count >= MIC_JB_SIZE) { // full: drop the oldest frame + mic_jb_read = (mic_jb_read + 1) % MIC_JB_SIZE; + mic_jb_count--; + } + j = (mic_jb_read + mic_jb_count) % MIC_JB_SIZE * 2; + mic_jb[j] = buf[i]; + mic_jb[j + 1] = buf[i + 1]; + mic_jb_count++; } } } // while(1) + // release the frames that are due since the last call + nSamples = 0; + if (mic_jb_target == 0) { // the jitter buffer is off: pass on everything received + nFrames = mic_jb_count; + } + else if ( ! mic_jb_playing) { + nFrames = 0; + if (mic_jb_count >= mic_jb_target) { + mic_jb_playing = 1; + mic_jb_time = now; + mic_jb_frac = 0; + } + } + else { + due = (now - mic_jb_time) * MIC_JB_RATE + mic_jb_frac; + if (due < 0) // the clock was set back + due = 0; + else if (due > MIC_JB_RATE / 2) // limit to 0.5 sec, well within SAMP_BUFFER_SIZE after interpolation + due = MIC_JB_RATE / 2; + mic_jb_time = now; + nFrames = (int)due; + mic_jb_frac = due - nFrames; + if (nFrames > mic_jb_count) { // ran dry: play what is left, then buffer up again + nFrames = mic_jb_count; + mic_jb_playing = 0; + mic_jb_starved = 1; + } + } + for (i = 0; i < nFrames; i++) { + j = mic_jb_read * 2; + cSamples[nSamples++] = ((double)mic_jb[j] + I * (double)mic_jb[j + 1]) / CLIP16 * CLIP32; + mic_jb_read = (mic_jb_read + 1) % MIC_JB_SIZE; + } + mic_jb_count -= nFrames; + if (mic_jb_target && mic_jb_count > mic_jb_target + MIC_JB_EXTRA) { // the control head's clock is faster than ours + i = mic_jb_count - mic_jb_target; + mic_jb_read = (mic_jb_read + i) % MIC_JB_SIZE; + mic_jb_count -= i; + mic_jb_overruns++; + } + if ((mic_jb_underruns || mic_jb_overruns) && now - mic_jb_last_packet > MIC_JB_IDLE) { + QuiskPrintf("Remote mic jitter buffer: %d underruns, %d overruns\n", mic_jb_underruns, mic_jb_overruns); + mic_jb_underruns = mic_jb_overruns = 0; + } + nSamples = quisk_cInterpolate(cSamples, nSamples, &cFiltInterp3, 3); nSamples = quisk_cInterp2HB45(cSamples, nSamples, &HalfBand); @@ -716,6 +807,7 @@ PyObject * quisk_start_remote_radio_remote_sound(PyObject * self, PyObject * arg packets_sent = 0; packets_recd = 0; + mic_jb_reset(); return Py_None; } @@ -742,6 +834,7 @@ PyObject * quisk_stop_remote_radio_remote_sound(PyObject * self, PyObject * args control_head_graph_socket_started = 0; remote_radio_graph_socket_started = 0; control_head_data_width = 0; + mic_jb_reset(); QuiskPrintf("total packets sent = %i, recd = %i\n", packets_sent, packets_recd); diff --git a/ac2yd/remote_common.py b/ac2yd/remote_common.py index 43d996a..8cfc6e5 100644 --- a/ac2yd/remote_common.py +++ b/ac2yd/remote_common.py @@ -190,10 +190,20 @@ class Remot: # Remote comtrol base class return try: # Read any data from the socket text = self.remote_ctl_connection.recv(1024) - except: - #traceback.print_exc() + # Thanks to Karl Heinz - K5KHK + except (BlockingIOError, InterruptedError, socket.timeout): # No data available now + return + except OSError: # For example ConnectionResetError: the Control Head is gone + print('Closing Remote Control connection: recv() failed') + self.RemoteCtlClose(False) return else: # We got some characters + if not text: # recv() returns nothing only when the Control Head has closed the connection + # Close now rather than waiting for the heartbeat timeout: a Control Head that + # crashed or lost its network may have left the transmitter keyed. + print('Closing Remote Control connection: closed by Control Head') + self.RemoteCtlClose(False) + return if not isinstance(text, str): text = text.decode('utf-8') self.received += text @@ -375,13 +385,22 @@ class Remot: # Remote comtrol base class self.cw_next_keydown = None def StopTransmit(self): - # TODO: Add code for modes other than CW + # PTT code added thanks to Karl Heinz - K5KHK + # Called whenever the remote session ends (RemoteCtlClose). Release everything the + # Control Head may have keyed: the CW key and, for voice and digital modes, PTT. + # Without the PTT release, a Control Head that disappears while transmitting (a crash, + # a lost network, a dead battery) leaves the transmitter keyed until someone unkeys it + # at the radio. while len(self.cw_event_queue): self.cw_event_queue.popleft() self.cw_next_event_ts = None self.cw_next_keydown = None self.cw_key_down = 0 QS.set_remote_cwkey(0) + ptt = getattr(self.app, 'pttButton', None) + if ptt and ptt.GetValue(): + print('Remote Control connection closed while transmitting: releasing PTT') + ptt.SetValue(0, True) # Same as releasing PTT at the radio; runs OnButtonPTT def ProcessRadioBtn(self, command, cmd_text): # Large and Small format screens send different button events for radio buttons. diff --git a/configure.py b/configure.py index eb07d10..0e41a75 100644 --- a/configure.py +++ b/configure.py @@ -2302,7 +2302,8 @@ class ControlMixin: if name in ('hot_key_ptt_toggle', 'hot_key_ptt_if_hidden', 'keyupDelay', 'cwTone', 'pulse_audio_verbose_output', 'start_cw_delay', 'start_ssb_delay', 'maximum_tx_secs', 'quisk_serial_cts', 'quisk_serial_dsr', 'hot_key_ptt1', 'hot_key_ptt2', 'midi_ptt_toggle', 'TxRxSilenceMsec', - 'fixed_tune_offset', 'calibrate_temp_20', 'calibrate_temp_40', 'calibrate_current_0', 'calibrate_current_1'): + 'fixed_tune_offset', 'calibrate_temp_20', 'calibrate_temp_40', 'calibrate_current_0', 'calibrate_current_1', + 'remote_radio_mic_jitter_ms'): setattr(conf, name, x) application.ImmediateChange(name) elif name[0:4] in ('lin_', 'win_'): diff --git a/pyproject.toml b/pyproject.toml index 7dd0271..d525bfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quisk" -version = "4.2.54" +version = "4.2.55" requires-python = ">=3.13" description = 'QUISK is a Software Defined Radio (SDR) transceiver that can control various radio hardware.' license = "GPL-2.0-or-later" diff --git a/quisk.py b/quisk.py index 96b6352..24d2ee5 100644 --- a/quisk.py +++ b/quisk.py @@ -742,6 +742,7 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532 self.vfo = "Main" self.split_mode = 0 self.split_vfo = 'VFO' + self.lock_mode = 0 h = self.Handlers = {} h[''] = self.ErrProtocol h['dump_state'] = self.DumpState @@ -765,6 +766,8 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532 h['set_split_freq'] = self.SetSplitFreq h['get_split_vfo'] = self.GetSplitVfo h['set_split_vfo'] = self.SetSplitVfo + h['get_lock_mode'] = self.GetLockMode + h['set_lock_mode'] = self.SetLockMode self.MakeDumpState() def MakeDumpState(self): dump_state = [] @@ -1009,7 +1012,8 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532 self.ErrParam() else: freq = int(freq + 0.5) - self.app.ChangeRxTxFrequency(freq, None) + # Like a real radio, keep the mode. Hamlib caches the mode, so the client would not notice a mode change. + self.app.ChangeRxTxFrequency(freq, None, keep_mode=True) def GetSplitFreq(self): # The Tx Frequency self.Reply('TX Frequency', self.app.txFreq + self.app.VFO, 0) def SetSplitFreq(self): # The Tx Frequency @@ -1022,7 +1026,7 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532 else: freq = int(freq + 0.5) # This always controls the Tx frequency - self.app.ChangeRxTxFrequency(None, freq) + self.app.ChangeRxTxFrequency(None, freq, keep_mode=True) def GetSplitVfo(self): self.Reply('Split', self.split_mode, 'TX VFO', self.split_vfo, 0) def SetSplitVfo(self): @@ -1107,6 +1111,22 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532 Lab = label diff = df self.app.filterButns.SetLabel(Lab, True) + # The code for the lock_mode and keep_mode was supplied by Karl Heinz - K5KHK. Thanks Karl! + def GetLockMode(self): + # Hamlib netrigctl always reads two lines, so an error reply "RPRT -4" makes it wait for its 10 second timeout. + # Reply like rigctld with the lock value and then "RPRT 0". + if self.extended: + self.Reply('Locked', self.lock_mode, 0) + else: + self.Send("%d\nRPRT 0\n" % self.lock_mode) + def SetLockMode(self): + lock = self.GetParamNumber() + try: + self.lock_mode = int(lock) + except: + self.ErrParam() + else: + self.Reply(0) def GetVfo(self): self.Reply('VFO', self.vfo, 0) def SetVfo(self): @@ -6136,7 +6156,7 @@ class App(wx.App): self.freqDisplay.Display(self.txFreq + self.VFO) self.fldigi_new_freq = self.txFreq + self.VFO return change - def ChangeRxTxFrequency(self, rx_freq=None, tx_freq=None): + def ChangeRxTxFrequency(self, rx_freq=None, tx_freq=None, keep_mode=False): if not self.split_rxtx and not tx_freq: tx_freq = rx_freq if tx_freq: @@ -6147,7 +6167,7 @@ class App(wx.App): else: # Change the VFO vfo = (tx_freq // 5000) * 5000 - 5000 tune = tx_freq - vfo - self.BandFromFreq(tx_freq) + self.BandFromFreq(tx_freq, keep_mode) self.ChangeHwFrequency(tune, vfo, 'FreqEntry') if rx_freq and self.split_rxtx: # Frequency must be on-screen tune = rx_freq - self.VFO @@ -6337,7 +6357,8 @@ class App(wx.App): self.pttButton.Enable(False) else: self.pttButton.Enable(True) - def BandFromFreq(self, frequency): # Change to a new band based on the frequency + def BandFromFreq(self, frequency, keep_mode=False): # Change to a new band based on the frequency + # If keep_mode is True, do not change to the mode saved for the new band. if self.screen == self.bandscope_screen: return try: @@ -6359,7 +6380,8 @@ class App(wx.App): vfo, tune, mode = self.bandState[band] except KeyError: vfo, tune, mode = (0, 0, 'LSB') - self.modeButns.SetLabel(mode, True) + if not keep_mode: + self.modeButns.SetLabel(mode, True) self.ChangeBand(band) break def ChangeBand(self, band): diff --git a/quisk_conf_defaults.py b/quisk_conf_defaults.py index 2f88e77..9253933 100644 --- a/quisk_conf_defaults.py +++ b/quisk_conf_defaults.py @@ -1508,6 +1508,19 @@ remote_radio_ip = "" # It is only necessary to enter it once on each computer. remote_radio_password = "" +## remote_radio_mic_jitter_ms Mic buffer msec for remote radio, integer +# This is set on the remote radio, not the control head. The network can hold the control +# head's microphone packets for 100 msec or more and then deliver them in a burst, which is +# common over Wi-Fi. The transmit audio then has gaps. This buffers the microphone audio +# for the given time before transmitting it, so the bursts are smoothed out, at the cost +# of that much more transmit delay. Zero turns the buffer off, which is best on a wired +# network. If needed, try 150 msec. The maximum is 500. A change takes effect when the +# next control head connects. +remote_radio_mic_jitter_ms = 0 +#remote_radio_mic_jitter_ms = 100 +#remote_radio_mic_jitter_ms = 150 +#remote_radio_mic_jitter_ms = 250 + ## k4_tcp_ip IP address for K4 TCP, text # This is the Quisk IP address for the TCP server implementing K4 commands. k4_tcp_ip = ""