implement wspr

This commit is contained in:
Jakob Ketterl 2019-07-13 23:16:25 +02:00
parent 420b0c60d7
commit 6d5c8491e4
6 changed files with 135 additions and 53 deletions

23
csdr.py
View File

@ -25,7 +25,7 @@ import os
import signal
import threading
from functools import partial
from owrx.wsjt import Ft8Chopper
from owrx.wsjt import Ft8Chopper, WsprChopper
import logging
logger = logging.getLogger(__name__)
@ -174,7 +174,7 @@ class dsp(object):
"csdr limit_ff"
]
# fixed sample rate necessary for the wsjt-x tools. fix with sox...
if self.get_secondary_demodulator() == "ft8" and self.get_audio_rate() != self.get_output_rate():
if self.isWsjtMode() and self.get_audio_rate() != self.get_output_rate():
chain += [
"sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - "
]
@ -196,8 +196,8 @@ class dsp(object):
"csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \
"CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
elif which == "ft8":
chain = secondary_chain_base + "csdr realpart_cf | "
elif self.isWsjtMode(which):
chain = secondary_chain_base + "csdr realpart_cf | "
if self.last_decimation != 1.0 :
chain += "csdr fractional_decimator_ff {last_decimation} | "
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
@ -271,8 +271,12 @@ class dsp(object):
self.secondary_processes_running = True
self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())))
if self.get_secondary_demodulator() == "ft8":
chopper = Ft8Chopper(self.secondary_process_demod.stdout)
if self.isWsjtMode():
smd = self.get_secondary_demodulator()
if smd == "ft8":
chopper = Ft8Chopper(self.secondary_process_demod.stdout)
elif smd == "wspr":
chopper = WsprChopper(self.secondary_process_demod.stdout)
chopper.start()
self.output.add_output("wsjt_demod", chopper.read)
else:
@ -355,7 +359,7 @@ class dsp(object):
def get_audio_rate(self):
if self.isDigitalVoice():
return 48000
elif self.secondary_demodulator == "ft8":
elif self.isWsjtMode():
return 12000
return self.get_output_rate()
@ -364,6 +368,11 @@ class dsp(object):
demodulator = self.get_demodulator()
return demodulator in ["dmr", "dstar", "nxdn", "ysf"]
def isWsjtMode(self, demodulator = None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator in ["ft8", "wspr"]
def set_output_rate(self,output_rate):
self.output_rate=output_rate
self.calculate_decimation()

View File

@ -835,15 +835,21 @@ img.openwebrx-mirror-img
width: 35px;
}
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container {
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container
{
display: none;
}
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container {
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container
{
height: 200px;
margin: -10px;
}
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel {
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel
{
display: none;
}

View File

@ -81,6 +81,7 @@
<option value="none"></option>
<option value="bpsk31">BPSK31</option>
<option value="ft8">FT8</option>
<option value="wspr">WSPR</option>
</select>
</div>
<div class="openwebrx-panel-line">

View File

@ -2693,6 +2693,7 @@ function demodulator_digital_replace(subtype)
case "bpsk31":
case "rtty":
case "ft8":
case "wspr":
secondary_demod_start(subtype);
demodulator_analog_replace('usb', true);
demodulator_buttons_update();
@ -2700,7 +2701,7 @@ function demodulator_digital_replace(subtype)
}
$('#openwebrx-panel-digimodes').attr('data-mode', subtype);
toggle_panel("openwebrx-panel-digimodes", true);
toggle_panel("openwebrx-panel-wsjt-message", subtype == 'ft8');
toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr'].indexOf(subtype) >= 0);
}
function secondary_demod_create_canvas()
@ -2862,20 +2863,17 @@ function secondary_demod_waterfall_dequeue()
secondary_demod_listbox_updating = false;
function secondary_demod_listbox_changed()
{
if(secondary_demod_listbox_updating) return;
switch ($("#openwebrx-secondary-demod-listbox")[0].value)
{
if (secondary_demod_listbox_updating) return;
var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
switch (sdm) {
case "none":
demodulator_analog_replace_last();
break;
case "bpsk31":
demodulator_digital_replace('bpsk31');
break;
case "rtty":
demodulator_digital_replace('rtty');
break;
case "ft8":
demodulator_digital_replace('ft8');
case "wspr":
demodulator_digital_replace(sdm);
break;
}
}

View File

@ -165,13 +165,15 @@ class FeatureDetector(object):
return version >= required_version
except FileNotFoundError:
return False
return reduce(and_,
map(
check_digiham_version,
["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
"digitalvoice_filter"]
),
True)
return reduce(
and_,
map(
check_digiham_version,
["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
"digitalvoice_filter"]
),
True
)
def has_dsd(self):
"""
@ -201,4 +203,11 @@ class FeatureDetector(object):
[WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions
on how to build from source.
"""
return self.command_is_runnable("jt9")
return reduce(
and_,
map(
self.command_is_runnable,
["jt9", "wsprd"]
),
True
)

View File

@ -14,7 +14,7 @@ import logging
logger = logging.getLogger(__name__)
class Ft8Chopper(threading.Thread):
class WsjtChopper(threading.Thread):
def __init__(self, source):
self.source = source
self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
@ -27,7 +27,7 @@ class Ft8Chopper(threading.Thread):
super().__init__()
def getWaveFile(self):
filename = "{tmp_dir}/openwebrx-ft8chopper-{id}-{timestamp}.wav".format(
filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format(
tmp_dir = self.tmp_dir,
id = id(self),
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
@ -40,11 +40,10 @@ class Ft8Chopper(threading.Thread):
def getNextDecodingTime(self):
t = datetime.now()
seconds = (int(t.second / 15) + 1) * 15
if seconds >= 60:
t = t + timedelta(minutes = 1)
seconds = 0
t = t.replace(second = seconds, microsecond = 0)
zeroed = t.replace(minute=0, second=0, microsecond=0)
delta = t - zeroed
seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval
t = zeroed + timedelta(seconds = seconds)
logger.debug("scheduling: {0}".format(t))
return t.timestamp()
@ -70,10 +69,15 @@ class Ft8Chopper(threading.Thread):
self.fileQueue.append(filename)
self._scheduleNextSwitch()
def decoder_commandline(self, file):
'''
must be overridden in child classes
'''
return []
def decode(self):
def decode_and_unlink(file):
#TODO expose decoding quality parameters through config
decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE, cwd=self.tmp_dir)
decoder = subprocess.Popen(self.decoder_commandline(file), stdout=subprocess.PIPE, cwd=self.tmp_dir)
while True:
line = decoder.stdout.readline()
if line is None or (isinstance(line, bytes) and len(line) == 0):
@ -91,12 +95,12 @@ class Ft8Chopper(threading.Thread):
threading.Thread(target=decode_and_unlink, args=[file]).start()
def run(self) -> None:
logger.debug("FT8 chopper starting up")
logger.debug("WSJT chopper starting up")
self.startScheduler()
while self.doRun:
data = self.source.read(256)
if data is None or (isinstance(data, bytes) and len(data) == 0):
logger.warning("zero read on ft8 chopper")
logger.warning("zero read on WSJT chopper")
self.doRun = False
else:
self.switchingLock.acquire()
@ -104,7 +108,7 @@ class Ft8Chopper(threading.Thread):
self.switchingLock.release()
self.decode()
logger.debug("FT8 chopper shutting down")
logger.debug("WSJT chopper shutting down")
self.outputReader.close()
self.outputWriter.close()
self.emptyScheduler()
@ -120,10 +124,34 @@ class Ft8Chopper(threading.Thread):
return None
class Ft8Chopper(WsjtChopper):
def __init__(self, source):
self.interval = 15
super().__init__(source)
def decoder_commandline(self, file):
#TODO expose decoding quality parameters through config
return ["jt9", "--ft8", "-d", "3", file]
class WsprChopper(WsjtChopper):
def __init__(self, source):
self.interval = 120
super().__init__(source)
def decoder_commandline(self, file):
#TODO expose decoding quality parameters through config
return ["wsprd", "-d", file]
class WsjtParser(object):
locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$")
jt9_pattern = re.compile("^[0-9]{6} .*")
wspr_pattern = re.compile("^[0-9]{4} .*")
wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-90]+)")
def __init__(self, handler):
self.handler = handler
self.locator_pattern = re.compile(".*\s([A-Z0-9]+)\s([A-R]{2}[0-9]{2})$")
modes = {
"~": "FT8"
@ -132,8 +160,6 @@ class WsjtParser(object):
def parse(self, data):
try:
msg = data.decode().rstrip()
# sample
# '222100 -15 -0.0 508 ~ CQ EA7MJ IM66'
# known debug messages we know to skip
if msg.startswith("<DecodeFinished>"):
return
@ -141,23 +167,33 @@ class WsjtParser(object):
return
out = {}
ts = datetime.strptime(msg[0:6], "%H%M%S")
out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000)
out["db"] = float(msg[7:10])
out["dt"] = float(msg[11:15])
out["freq"] = int(msg[16:20])
modeChar = msg[21:22]
out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown"
wsjt_msg = msg[24:60].strip()
self.parseLocator(wsjt_msg, mode)
out["msg"] = wsjt_msg
if WsjtParser.jt9_pattern.match(msg):
out = self.parse_from_jt9(msg)
elif WsjtParser.wspr_pattern.match(msg):
out = self.parse_from_wsprd(msg)
self.handler.write_wsjt_message(out)
except ValueError:
logger.exception("error while parsing wsjt message")
def parse_from_jt9(self, msg):
# ft8 sample
# '222100 -15 -0.0 508 ~ CQ EA7MJ IM66'
out = {}
ts = datetime.strptime(msg[0:6], "%H%M%S")
out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000)
out["db"] = float(msg[7:10])
out["dt"] = float(msg[11:15])
out["freq"] = int(msg[16:20])
modeChar = msg[21:22]
out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown"
wsjt_msg = msg[24:60].strip()
self.parseLocator(wsjt_msg, mode)
out["msg"] = wsjt_msg
return out
def parseLocator(self, msg, mode):
m = self.locator_pattern.match(msg)
m = WsjtParser.locator_pattern.match(msg)
if m is None:
return
# this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very
@ -165,3 +201,26 @@ class WsjtParser(object):
if m.group(2) == "RR73":
return
Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode)
def parse_from_wsprd(self, msg):
# wspr sample
# '2600 -24 0.4 0.001492 -1 G8AXA JO01 33'
out = {}
now = datetime.now()
ts = datetime.strptime(msg[0:4], "%M%S").replace(hour=now.hour)
out["timestamp"] = int(datetime.combine(date.today(), ts.time(), now.tzinfo).timestamp() * 1000)
out["db"] = float(msg[5:8])
out["dt"] = float(msg[9:13])
out["freq"] = float(msg[14:24])
out["drift"] = int(msg[25:28])
out["mode"] = "wspr"
wsjt_msg = msg[29:60].strip()
out["msg"] = wsjt_msg
self.parseWsprMessage(wsjt_msg)
return out
def parseWsprMessage(self, msg):
m = WsjtParser.wspr_splitter_pattern.match(msg)
if m is None:
return
Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), "WSPR")