Quisk version 4.2.55

This commit is contained in:
James C. Ahlstrom
2026-10-12 13:37:47 -04:00
parent 745882a506
commit c97ffa155e
8 changed files with 174 additions and 20 deletions
+6
View File
@@ -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 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. Quisk can now accept audio samples for Tx from TCI at rates 48000, 24000, 12000 and 8000. Please test.
+1 -1
View File
@@ -1,2 +1,2 @@
#Quisk version 4.2.54 #Quisk version 4.2.55
from .quisk import main from .quisk import main
+101 -8
View File
@@ -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_sent;
static int packets_recd; 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 // Receive stereo 16-bit pcm radio speaker sound on the control head via UDP
int read_remote_radio_sound_socket(complex double * cSamples) 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 // Receive stereo 16-bit pcm microphone samples at the remote radio via UDP
int read_remote_mic_sound_socket(complex double * cSamples) 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]; int16_t buf[UDP_SEND_INT16];
double samp_r, samp_l; double now, due;
struct timeval tm_wait; struct timeval tm_wait;
fd_set fds; fd_set fds;
static struct quisk_cHB45Filter HalfBand; 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)); quisk_filt_cInit(&cFiltInterp3, quiskAudio24p3Coefs, sizeof(quiskAudio24p3Coefs)/sizeof(double));
} }
// read all available packets, one per loop // read all available packets into the jitter buffer, one per loop
nSamples = 0; now = QuiskTimeSec();
while (1) { while (1) {
tm_wait.tv_sec = 0; tm_wait.tv_sec = 0;
tm_wait.tv_usec = 0; tm_wait.tv_usec = 0;
@@ -152,15 +187,71 @@ int read_remote_mic_sound_socket(complex double * cSamples)
break; break;
} }
if (bytes > 0) { 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; nInt16 = bytes / 2;
for (i = 0; i < nInt16; i += 2) { for (i = 0; i + 1 < nInt16; i += 2) {
samp_r = buf[i]; if (mic_jb_count >= MIC_JB_SIZE) { // full: drop the oldest frame
samp_l = buf[i + 1]; mic_jb_read = (mic_jb_read + 1) % MIC_JB_SIZE;
cSamples[nSamples++] = (samp_r + I * samp_l) / CLIP16 * CLIP32; 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) } // 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_cInterpolate(cSamples, nSamples, &cFiltInterp3, 3);
nSamples = quisk_cInterp2HB45(cSamples, nSamples, &HalfBand); 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_sent = 0;
packets_recd = 0; packets_recd = 0;
mic_jb_reset();
return Py_None; return Py_None;
} }
@@ -742,6 +834,7 @@ PyObject * quisk_stop_remote_radio_remote_sound(PyObject * self, PyObject * args
control_head_graph_socket_started = 0; control_head_graph_socket_started = 0;
remote_radio_graph_socket_started = 0; remote_radio_graph_socket_started = 0;
control_head_data_width = 0; control_head_data_width = 0;
mic_jb_reset();
QuiskPrintf("total packets sent = %i, recd = %i\n", packets_sent, packets_recd); QuiskPrintf("total packets sent = %i, recd = %i\n", packets_sent, packets_recd);
+22 -3
View File
@@ -190,10 +190,20 @@ class Remot: # Remote comtrol base class
return return
try: # Read any data from the socket try: # Read any data from the socket
text = self.remote_ctl_connection.recv(1024) text = self.remote_ctl_connection.recv(1024)
except: # Thanks to Karl Heinz - K5KHK
#traceback.print_exc() 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 return
else: # We got some characters 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): if not isinstance(text, str):
text = text.decode('utf-8') text = text.decode('utf-8')
self.received += text self.received += text
@@ -375,13 +385,22 @@ class Remot: # Remote comtrol base class
self.cw_next_keydown = None self.cw_next_keydown = None
def StopTransmit(self): 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): while len(self.cw_event_queue):
self.cw_event_queue.popleft() self.cw_event_queue.popleft()
self.cw_next_event_ts = None self.cw_next_event_ts = None
self.cw_next_keydown = None self.cw_next_keydown = None
self.cw_key_down = 0 self.cw_key_down = 0
QS.set_remote_cwkey(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): def ProcessRadioBtn(self, command, cmd_text):
# Large and Small format screens send different button events for radio buttons. # Large and Small format screens send different button events for radio buttons.
+2 -1
View File
@@ -2302,7 +2302,8 @@ class ControlMixin:
if name in ('hot_key_ptt_toggle', 'hot_key_ptt_if_hidden', 'keyupDelay', 'cwTone', 'pulse_audio_verbose_output', 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', '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', '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) setattr(conf, name, x)
application.ImmediateChange(name) application.ImmediateChange(name)
elif name[0:4] in ('lin_', 'win_'): elif name[0:4] in ('lin_', 'win_'):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "quisk" name = "quisk"
version = "4.2.54" version = "4.2.55"
requires-python = ">=3.13" requires-python = ">=3.13"
description = 'QUISK is a Software Defined Radio (SDR) transceiver that can control various radio hardware.' description = 'QUISK is a Software Defined Radio (SDR) transceiver that can control various radio hardware.'
license = "GPL-2.0-or-later" license = "GPL-2.0-or-later"
+28 -6
View File
@@ -742,6 +742,7 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
self.vfo = "Main" self.vfo = "Main"
self.split_mode = 0 self.split_mode = 0
self.split_vfo = 'VFO' self.split_vfo = 'VFO'
self.lock_mode = 0
h = self.Handlers = {} h = self.Handlers = {}
h[''] = self.ErrProtocol h[''] = self.ErrProtocol
h['dump_state'] = self.DumpState h['dump_state'] = self.DumpState
@@ -765,6 +766,8 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
h['set_split_freq'] = self.SetSplitFreq h['set_split_freq'] = self.SetSplitFreq
h['get_split_vfo'] = self.GetSplitVfo h['get_split_vfo'] = self.GetSplitVfo
h['set_split_vfo'] = self.SetSplitVfo h['set_split_vfo'] = self.SetSplitVfo
h['get_lock_mode'] = self.GetLockMode
h['set_lock_mode'] = self.SetLockMode
self.MakeDumpState() self.MakeDumpState()
def MakeDumpState(self): def MakeDumpState(self):
dump_state = [] dump_state = []
@@ -1009,7 +1012,8 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
self.ErrParam() self.ErrParam()
else: else:
freq = int(freq + 0.5) 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 def GetSplitFreq(self): # The Tx Frequency
self.Reply('TX Frequency', self.app.txFreq + self.app.VFO, 0) self.Reply('TX Frequency', self.app.txFreq + self.app.VFO, 0)
def SetSplitFreq(self): # The Tx Frequency def SetSplitFreq(self): # The Tx Frequency
@@ -1022,7 +1026,7 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
else: else:
freq = int(freq + 0.5) freq = int(freq + 0.5)
# This always controls the Tx frequency # This always controls the Tx frequency
self.app.ChangeRxTxFrequency(None, freq) self.app.ChangeRxTxFrequency(None, freq, keep_mode=True)
def GetSplitVfo(self): def GetSplitVfo(self):
self.Reply('Split', self.split_mode, 'TX VFO', self.split_vfo, 0) self.Reply('Split', self.split_mode, 'TX VFO', self.split_vfo, 0)
def SetSplitVfo(self): def SetSplitVfo(self):
@@ -1107,6 +1111,22 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
Lab = label Lab = label
diff = df diff = df
self.app.filterButns.SetLabel(Lab, True) 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): def GetVfo(self):
self.Reply('VFO', self.vfo, 0) self.Reply('VFO', self.vfo, 0)
def SetVfo(self): def SetVfo(self):
@@ -6136,7 +6156,7 @@ class App(wx.App):
self.freqDisplay.Display(self.txFreq + self.VFO) self.freqDisplay.Display(self.txFreq + self.VFO)
self.fldigi_new_freq = self.txFreq + self.VFO self.fldigi_new_freq = self.txFreq + self.VFO
return change 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: if not self.split_rxtx and not tx_freq:
tx_freq = rx_freq tx_freq = rx_freq
if tx_freq: if tx_freq:
@@ -6147,7 +6167,7 @@ class App(wx.App):
else: # Change the VFO else: # Change the VFO
vfo = (tx_freq // 5000) * 5000 - 5000 vfo = (tx_freq // 5000) * 5000 - 5000
tune = tx_freq - vfo tune = tx_freq - vfo
self.BandFromFreq(tx_freq) self.BandFromFreq(tx_freq, keep_mode)
self.ChangeHwFrequency(tune, vfo, 'FreqEntry') self.ChangeHwFrequency(tune, vfo, 'FreqEntry')
if rx_freq and self.split_rxtx: # Frequency must be on-screen if rx_freq and self.split_rxtx: # Frequency must be on-screen
tune = rx_freq - self.VFO tune = rx_freq - self.VFO
@@ -6337,7 +6357,8 @@ class App(wx.App):
self.pttButton.Enable(False) self.pttButton.Enable(False)
else: else:
self.pttButton.Enable(True) 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: if self.screen == self.bandscope_screen:
return return
try: try:
@@ -6359,7 +6380,8 @@ class App(wx.App):
vfo, tune, mode = self.bandState[band] vfo, tune, mode = self.bandState[band]
except KeyError: except KeyError:
vfo, tune, mode = (0, 0, 'LSB') vfo, tune, mode = (0, 0, 'LSB')
self.modeButns.SetLabel(mode, True) if not keep_mode:
self.modeButns.SetLabel(mode, True)
self.ChangeBand(band) self.ChangeBand(band)
break break
def ChangeBand(self, band): def ChangeBand(self, band):
+13
View File
@@ -1508,6 +1508,19 @@ remote_radio_ip = ""
# It is only necessary to enter it once on each computer. # It is only necessary to enter it once on each computer.
remote_radio_password = "" 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 ## k4_tcp_ip IP address for K4 TCP, text
# This is the Quisk IP address for the TCP server implementing K4 commands. # This is the Quisk IP address for the TCP server implementing K4 commands.
k4_tcp_ip = "" k4_tcp_ip = ""