129 Commits

Author SHA1 Message Date
330598ddf2 use check implemented in python 2021-09-29 17:23:23 +02:00
8f36355817 update changelog 2021-09-29 15:43:48 +02:00
ccf741da00 add nmux back to recommended packages 2021-09-29 15:42:49 +02:00
c3917c61d3 fix audio compression switching 2021-09-28 17:46:18 +02:00
d869c5ee2d restore live switching capability for fft compression 2021-09-28 16:55:17 +02:00
c89394822a Merge branch 'develop' into pycsdr 2021-09-28 16:44:50 +02:00
1836344fab update to wsjt-x 2.5.0 2021-09-28 16:42:52 +02:00
25e2a8013e parser typing 2021-09-28 00:27:01 +02:00
757ec01ea4 clientReader is not used any more 2021-09-28 00:14:53 +02:00
a07480fd9a remove old csdr code 2021-09-27 18:53:49 +02:00
e77b0f4a67 fix the secondary fft display 2021-09-27 18:18:31 +02:00
909a969e04 restore wfm deemphasis tau functionality 2021-09-27 17:46:19 +02:00
edace3d451 work on some todos 2021-09-27 17:29:51 +02:00
5b1000df87 Merge branch 'develop' into pycsdr 2021-09-24 23:08:37 +02:00
fae281a507 update codecserver in docker 2021-09-24 22:34:53 +02:00
cbcba5807f restore PSK decoding 2021-09-23 18:43:41 +02:00
3fa3aac766 introduce defaults layer to fix codecserver when empty 2021-09-23 15:17:46 +02:00
981948b708 update components in docker build 2021-09-22 18:15:47 +02:00
c41b303130 update dependencies 2021-09-22 17:22:00 +02:00
6589c9dbe1 add a feature check for js8py since it's optional now 2021-09-22 13:11:27 +02:00
acc70b6449 re-implement format conversion with pycsdr 2021-09-20 18:36:24 +02:00
81925986a6 update dependencies 2021-09-20 18:04:24 +02:00
83d01553e3 use "Optional" in typing 2021-09-20 17:24:10 +02:00
f2a97415b9 more localized imports 2021-09-20 16:55:17 +02:00
95b4510c3a more localized imports 2021-09-20 16:53:00 +02:00
81ed1a9ebb abstract chain features; use local imports to avoid hard dependencies 2021-09-20 16:14:23 +02:00
b2e15c559e refactor 2021-09-20 15:32:26 +02:00
c10fdd2a53 move 2021-09-20 15:16:06 +02:00
9efe41a2b1 move the pump mechanism, allowing the old output code to be removed 2021-09-20 15:09:26 +02:00
4b36aca6fc update wording to direct users to the feature report 2021-09-20 14:45:00 +02:00
cb29fc251c update dependencies 2021-09-17 18:58:48 +02:00
78dcdd5715 add support for DMR locations 2021-09-17 18:24:33 +02:00
6fbe6b4983 restore automatic config application for direwolf 2021-09-15 15:37:09 +02:00
284059a920 clean up direwolf config file after use 2021-09-15 15:04:12 +02:00
0403ebff5c improve handling of source processes 2021-09-15 15:03:11 +02:00
6129b92277 avoid duplicate method 2021-09-15 15:01:36 +02:00
1ff3c174c2 limit freedv to 4kHz since it's audio input is only 8kHz 2021-09-13 16:58:45 +02:00
e5b120311d get freedv back by modeling a corresponding module and chain 2021-09-13 16:58:02 +02:00
1c937e147e use a generic unpickler 2021-09-13 00:14:38 +02:00
1d2ee127e0 Merge branch 'develop' into pycsdr 2021-09-12 23:31:44 +02:00
ee9b602e4f catch http errors 2021-09-12 23:31:33 +02:00
72f925e537 receive pocsag messages in pickled form 2021-09-10 15:38:36 +02:00
bf37dee78b receive metadata in pickled form 2021-09-09 22:25:45 +02:00
72920135e9 fix initial setup of dial frequency 2021-09-09 22:24:41 +02:00
66cf940523 refactor the metaparser into a modules and use accordingly 2021-09-09 15:11:33 +02:00
ca0f7af1d0 use stereo downmix; disable squelch for DRM 2021-09-08 13:48:11 +02:00
d9db74e565 fix reading from process 2021-09-08 13:47:46 +02:00
3218e0b8aa update hpsdrconnector to 0.6.0 2021-09-08 12:54:34 +02:00
c8ebbb505a restore dmr filter 2021-09-07 17:37:32 +02:00
9ca5e0ebd6 restore DRM functionality 2021-09-07 17:31:32 +02:00
f3b05c6318 re-add m17 2021-09-07 14:45:52 +02:00
f9f0bdde12 restore js8 functionality 2021-09-06 22:50:57 +02:00
6014ce8921 restore pocsag functionality 2021-09-06 20:00:14 +02:00
b9f43654cd restore aprs functionality 2021-09-06 15:05:33 +02:00
7c43c78c4b refactor aprs stuff 2021-09-02 11:00:57 +02:00
efa7faaa2a correctly shutdown resampler 2021-09-02 10:53:05 +02:00
f9df35ffd4 rebuilt the resampler using pycsdr 2021-09-01 15:58:39 +02:00
01260d66c8 create a base class for python-implemented modules 2021-09-01 15:08:28 +02:00
51453662e2 fix dial frequencies 2021-08-31 22:46:11 +02:00
120328ce12 restore background services 2021-08-31 21:53:15 +02:00
869f971ced add the remaining modes 2021-08-31 17:01:52 +02:00
73d326037c restore audio chopper decoding 2021-08-31 16:54:37 +02:00
4a4901fa38 restore secondary fft 2021-08-28 00:10:46 +02:00
47e78579d4 handle unparseable utf meta data 2021-08-27 18:31:10 +02:00
54a1cae352 fix hd audio 2021-08-27 18:30:46 +02:00
4c1777dc19 refactor 2021-08-27 17:34:48 +02:00
42b315ef86 handle empty converter chain 2021-08-27 16:11:03 +02:00
3bb4f48faf fix errors on shutdown (duplicate calls) 2021-08-26 17:22:10 +02:00
ee3d934529 fix thread leak 2021-08-26 17:21:52 +02:00
aecb79a4d4 restore demodulation of digital voice modes 2021-08-26 15:58:02 +02:00
5032f4b66d first steps at rewiring the dsp stuff 2021-08-23 14:25:28 +02:00
0f1feb9d47 return to the simpler API 2021-08-16 16:41:18 +02:00
be6f533437 re-structure client audio conversion 2021-08-12 18:01:03 +02:00
c3d393252b parse metadata as UTF-8 2021-08-12 16:51:21 +02:00
bb56eb8db2 don't highlight for data 2021-08-11 14:10:52 +02:00
1e8527da68 add YSF chain; re-introduce RRC filters 2021-08-10 14:03:49 +02:00
2b3123c7cb dmr tdma slot filter control 2021-08-07 00:09:40 +02:00
8e945d4149 update metadata asynchronously when download finishes 2021-08-06 21:23:44 +02:00
3ccb4a11d2 add new DMR chain 2021-08-06 20:02:59 +02:00
dd7255a9d2 display talker alias (if no radioid data is available) 2021-08-06 20:02:30 +02:00
307e944911 split metadata into lines (if more than one was received) 2021-08-06 20:01:35 +02:00
175e140f86 Merge branch 'develop' into pycsdr 2021-08-04 00:01:11 +02:00
c5a314810e read metadata from pipeline decoders 2021-08-01 00:49:20 +02:00
f8f2740c77 implement nxdn chain using new digiham components 2021-07-31 00:10:10 +02:00
1c91c6dcc1 start building digiham chains 2021-07-30 00:06:21 +02:00
11a3606070 use the new cutoff parameter to compensate the fractional decimator 2021-07-25 23:38:24 +02:00
75aac5969a implement WFM with the new chain elements 2021-07-25 22:44:53 +02:00
459a99cbf8 backport the sync implementation from the csdr++ branch 2021-07-25 20:06:14 +02:00
c07d9ecf92 use the right rates 2021-07-25 19:36:03 +02:00
99c7093a1a pack the client audio processing into its own chain 2021-07-25 19:31:56 +02:00
223c2d1709 BufferReader won't return bytes 2021-07-25 17:36:32 +02:00
6db80ec51a clarify s-meter interval calculations 2021-07-25 00:17:27 +02:00
207ada70fd restore s-meter display 2021-07-25 00:05:48 +02:00
c50da15bfd apply all decimation in comples to simplify the chain 2021-07-24 22:25:41 +02:00
ab99b8e476 don't wrap the module, it's not necessary 2021-07-24 22:11:41 +02:00
7d7cec1ec3 update to match pycsdr chaanges 2021-07-24 18:50:30 +02:00
aeca8265c3 fine-tune agc 2021-07-20 17:58:32 +02:00
b242f09d5d Merge branch 'develop' into pycsdr 2021-07-20 13:33:52 +02:00
2bcb62e706 add ssb chain 2021-07-20 00:57:43 +02:00
be093b8b05 implement a method to replace chain members 2021-07-20 00:44:41 +02:00
eb76ec4a9f add am demodulator chain 2021-07-19 23:32:03 +02:00
f03a6c127e fix initial demodulator parameters 2021-07-19 19:48:18 +02:00
5bb14a8997 first working nfm chain using pycsdr 2021-07-19 19:04:14 +02:00
bb77d2ce0a fix subscription 2021-07-18 14:57:50 +02:00
8531d5e4ab properly shutdown and unblock the final buffer 2021-07-18 14:56:48 +02:00
320521a74a adopt to updated api 2021-07-16 16:12:16 +02:00
5e7a0a38aa Merge branch 'develop' into pycsdr 2021-07-15 18:09:39 +02:00
faad38f72d Merge branch 'develop' into pycsdr 2021-01-24 00:37:58 +01:00
e11bbbf494 remove fft stuff from csdr 2021-01-23 19:40:05 +01:00
4b94126dc3 use the fft chain directly without csdr dsp classes 2021-01-23 19:27:01 +01:00
4e429d047d Merge branch 'develop' into pycsdr 2021-01-23 17:17:44 +01:00
ee8d896d60 implement output buffer shutdown 2021-01-17 21:01:54 +01:00
db83256bcf Merge branch 'develop' into pycsdr 2021-01-17 20:58:02 +01:00
297d6b540d Merge branch 'develop' into pycsdr 2021-01-17 18:16:32 +01:00
f4629804ff explicitly unset chain since automatic garbage collection is broken 2021-01-04 00:24:06 +01:00
2783091cea unset buffer since it can't be reused 2021-01-04 00:23:29 +01:00
91be89e44e Merge branch 'develop' into pycsdr 2021-01-03 15:59:27 +01:00
a2d731503f update api 2021-01-02 03:12:21 +01:00
3e69c71ed5 Merge branch 'develop' into pycsdr 2021-01-02 03:11:41 +01:00
ca183c7c5a Merge branch 'develop' into pycsdr 2020-12-27 20:22:42 +01:00
fa3b5cd7e6 implement new buffer input / output api 2020-12-25 20:27:30 +01:00
2df527ed20 wrap averager (prepare to make it switchable) 2020-12-21 00:33:48 +01:00
2c7c41cded move fft calculations into fft chain 2020-12-20 22:55:10 +01:00
1083d51e18 update fft parameters without restarting 2020-12-19 17:13:36 +01:00
40c07ebb57 move fft calculations to dsp class 2020-12-19 16:41:48 +01:00
efe80a75f5 put the socketclient on the source so it can be shared 2020-12-19 16:28:18 +01:00
1bd6aa73f3 encapsulate fft chain in its own class 2020-12-16 18:52:00 +01:00
4b61192b36 add a feature flag 2020-12-16 10:18:47 +01:00
664c6e049f pycsdr based ffd (baby steps) 2020-12-15 23:02:12 +01:00
59 changed files with 2449 additions and 1707 deletions

View File

@ -1,4 +1,6 @@
**unreleased**
- Major rewrite of all demodulation components to make use of the new csdr/pycsdr and digiham/pydigiham demodulator
modules
**1.1.0**
- Reworked most graphical elements as SVGs for faster loadtimes and crispier display on hi-dpi displays

View File

@ -1,835 +0,0 @@
"""
OpenWebRX csdr plugin: do the signal processing with csdr
This file is part of OpenWebRX,
an open-source SDR receiver software with a web UI.
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
Copyright (c) 2019-2021 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import subprocess
import os
import signal
import threading
import math
from functools import partial
from csdr.output import Output
from owrx.kiss import KissClient, DirewolfConfig, DirewolfConfigSubscriber
from owrx.audio.chopper import AudioChopper
from csdr.pipe import Pipe
import logging
logger = logging.getLogger(__name__)
class Dsp(DirewolfConfigSubscriber):
def __init__(self, output: Output):
self.samp_rate = 250000
self.output_rate = 11025
self.hd_output_rate = 44100
self.fft_size = 1024
self.fft_fps = 5
self.center_freq = 0
self.offset_freq = 0
self.low_cut = -4000
self.high_cut = 4000
self.bpf_transition_bw = 320 # Hz, and this is a constant
self.ddc_transition_bw_rate = 0.15 # of the IF sample rate
self.running = False
self.secondary_processes_running = False
self.audio_compression = "none"
self.fft_compression = "none"
self.demodulator = "nfm"
self.name = "csdr"
self.decimation = None
self.last_decimation = None
self.nc_port = None
self.squelch_level = -150
self.fft_averages = 50
self.wfm_deemphasis_tau = 50e-6
self.iqtee = False
self.iqtee2 = False
self.secondary_demodulator = None
self.secondary_fft_size = 1024
self.secondary_process_fft = None
self.secondary_process_demod = None
self.pipe_names = {
"bpf_pipe": Pipe.WRITE,
"shift_pipe": Pipe.WRITE,
"squelch_pipe": Pipe.WRITE,
"smeter_pipe": Pipe.READ,
"meta_pipe": Pipe.READ,
"iqtee_pipe": Pipe.NONE,
"iqtee2_pipe": Pipe.NONE,
"dmr_control_pipe": Pipe.WRITE,
}
self.pipes = {}
self.secondary_pipe_names = {"secondary_shift_pipe": Pipe.WRITE}
self.secondary_offset_freq = 1000
self.codecserver = None
self.modification_lock = threading.Lock()
self.output = output
self.temporary_directory = None
self.pipe_base_path = None
self.set_temporary_directory("/tmp")
self.is_service = False
self.direwolf_config = None
self.direwolf_config_path = None
self.process = None
def set_service(self, flag=True):
self.is_service = flag
def set_temporary_directory(self, what):
self.temporary_directory = what
self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_".format(tmp_dir=self.temporary_directory)
def chain(self, which):
chain = ["nc -v 127.0.0.1 {nc_port}"]
if which == "fft":
chain += [
"csdr++ fft {fft_size} {fft_block_size}",
"csdr++ logpower -70"
if self.fft_averages == 0
else "csdr++ logaveragepower {fft_size} {fft_averages} --add -70",
"csdr++ fftswap {fft_size}",
]
if self.fft_compression == "adpcm":
chain += ["csdr++ fftadpcm {fft_size}"]
return chain
chain += ["csdr++ shift --fifo {shift_pipe}"]
if self.decimation > 1:
chain += ["csdr++ firdecimate {decimation} {ddc_transition_bw} --window hamming"]
chain += ["csdr++ bandpass --fft --fifo {bpf_pipe} {bpf_transition_bw} --window hamming"]
if self.output.supports_type("smeter"):
if self.isSquelchActive():
chain += ["csdr++ squelch --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}"]
else:
chain += ["csdr++ power --outfifo {smeter_pipe} 5 {smeter_report_every}"]
if self.secondary_demodulator:
if self.output.supports_type("secondary_fft"):
chain += ["csdr tee {iqtee_pipe}"]
chain += ["csdr tee {iqtee2_pipe}"]
# early exit if we don't want audio
if not self.output.supports_type("audio"):
return chain
# safe some cpu cycles... no need to decimate if decimation factor is 1
last_decimation_block = []
if self.last_decimation >= 2.0:
# activate prefilter if signal has been oversampled, e.g. WFM
last_decimation_block = ["csdr++ fractionaldecimator --format float {last_decimation} --prefilter"]
elif self.last_decimation != 1.0:
last_decimation_block = ["csdr++ fractionaldecimator --format float {last_decimation}"]
if which == "nfm":
chain += ["csdr++ fmdemod", "csdr++ limit"]
chain += last_decimation_block
chain += [
"csdr++ deemphasis --nfm {audio_rate}",
"csdr++ agc --format float --profile slow --max 3",
]
if 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 - "
]
else:
chain += ["csdr++ convert -i float -o s16"]
elif which == "wfm":
chain += [
"csdr++ fmdemod",
"csdr++ limit",
]
chain += last_decimation_block
chain += ["csdr++ deemphasis --wfm {audio_rate} {wfm_deemphasis_tau}", "csdr++ convert -i float -o s16"]
elif self.isDigitalVoice(which):
chain += ["csdr++ fmdemod"]
chain += last_decimation_block
chain += ["dc_block"]
# m17
if which == "m17":
chain += [
"csdr++ limit",
"csdr++ convert -i float -o s16",
"m17-demod",
]
else:
# digiham modes
if which == "dstar":
chain += [
"fsk_demodulator -s 10",
"dstar_decoder --fifo {meta_pipe}",
"mbe_synthesizer -d {codecserver_arg}",
]
elif which == "nxdn":
chain += [
"rrc_filter --narrow",
"gfsk_demodulator --samples 20",
"nxdn_decoder --fifo {meta_pipe}",
"mbe_synthesizer {codecserver_arg}",
]
else:
chain += ["rrc_filter", "gfsk_demodulator"]
if which == "dmr":
chain += [
"dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}",
"mbe_synthesizer {codecserver_arg}",
]
elif which == "ysf":
chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y {codecserver_arg}"]
chain += ["digitalvoice_filter"]
chain += [
"csdr++ agc --format s16 --max 30 --initial 3",
"sox --buffer 320 -t raw -r 8000 -e signed-integer -b 16 -c 1 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
]
elif which == "am":
chain += ["csdr++ amdemod", "csdr++ dcblock"]
chain += last_decimation_block
chain += [
"csdr++ agc --format float --profile slow --initial 200",
"csdr++ convert -i float -o s16",
]
elif self.isFreeDV(which):
chain += ["csdr++ realpart"]
chain += last_decimation_block
chain += [
"csdr++ agc --format float",
"csdr++ convert -i float -o s16",
"freedv_rx 1600 - -",
"csdr++ agc --format s16 --max 30 --initial 3",
"sox -t raw -r 8000 -e signed-integer -b 16 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
]
elif self.isDrm(which):
if self.last_decimation != 1.0:
# we are still dealing with complex samples here, so the regular last_decimation_block doesn't fit
chain += ["csdr++ fractionaldecimator --format complex {last_decimation}"]
chain += [
"csdr++ convert -i float -o s16",
"dream -c 6 --sigsrate 48000 --audsrate 48000 -I - -O -",
"sox -t raw -r 48000 -e signed-integer -b 16 -c 2 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
]
elif which == "ssb":
chain += ["csdr++ realpart"]
chain += last_decimation_block
chain += ["csdr++ agc --format float"]
# fixed sample rate necessary for the wsjt-x tools. fix with sox...
if 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 - "
]
else:
chain += ["csdr++ convert -i float -o s16"]
if self.audio_compression == "adpcm":
chain += ["csdr++ adpcm -e --sync"]
return chain
def secondary_chain(self, which):
chain = ["cat {input_pipe}"]
if which == "fft":
chain += [
"csdr++ fft {secondary_fft_input_size} {secondary_fft_block_size}",
"csdr++ logpower -70"
if self.fft_averages == 0
else "csdr++ logaveragepower {secondary_fft_size} {fft_averages} --add -70",
"csdr++ fftswap {secondary_fft_input_size}",
]
if self.fft_compression == "adpcm":
chain += ["csdr++ fftadpcm {secondary_fft_size}"]
return chain
elif which == "bpsk31" or which == "bpsk63":
return chain + [
"csdr++ shift --fifo {secondary_shift_pipe}",
"csdr++ bandpass --low -{secondary_bpf_cutoff} --high {secondary_bpf_cutoff} {secondary_bpf_cutoff}",
"csdr++ agc --format complex",
"csdr++ timingrecovery --algorithm gardner {secondary_samples_per_bits} 0.5 2 --add_q",
"csdr++ dbpskdecode",
"csdr++ varicodedecode",
]
elif self.isWsjtMode(which) or self.isJs8(which):
chain += ["csdr++ realpart"]
if self.last_decimation != 1.0:
chain += ["csdr++ fractionaldecimator --format float {last_decimation}"]
return chain + ["csdr++ agc --format float", "csdr++ convert -i float -o s16"]
elif which == "packet":
chain += ["csdr++ fmdemod"]
if self.last_decimation != 1.0:
chain += ["csdr++ fractionaldecimator --format float {last_decimation}"]
return chain + ["csdr++ convert -i float -o s16", "direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h 1>&2"]
elif which == "pocsag":
chain += ["csdr++ fmdemod"]
if self.last_decimation != 1.0:
chain += ["csdr++ fractionaldecimator --format float {last_decimation}"]
return chain + ["fsk_demodulator -i", "pocsag_decoder"]
def set_secondary_demodulator(self, what):
if self.get_secondary_demodulator() == what:
return
self.secondary_demodulator = what
self.calculate_decimation()
self.restart()
def secondary_fft_block_size(self):
base = (self.samp_rate / self.decimation) / (self.fft_fps * 2)
if self.fft_averages == 0:
return round(base)
return round(base / self.fft_averages)
def secondary_decimation(self):
return 1 # currently unused
def secondary_bpf_cutoff(self):
if self.secondary_demodulator == "bpsk31":
return 31.25 / self.if_samp_rate()
elif self.secondary_demodulator == "bpsk63":
return 62.5 / self.if_samp_rate()
return 0
def secondary_bpf_transition_bw(self):
if self.secondary_demodulator == "bpsk31":
return 31.25 / self.if_samp_rate()
elif self.secondary_demodulator == "bpsk63":
return 62.5 / self.if_samp_rate()
return 0
def secondary_samples_per_bits(self):
if self.secondary_demodulator == "bpsk31":
return int(round(self.if_samp_rate() / 31.25)) & ~3
elif self.secondary_demodulator == "bpsk63":
return int(round(self.if_samp_rate() / 62.5)) & ~3
return 0
def secondary_bw(self):
if self.secondary_demodulator == "bpsk31":
return 31.25
elif self.secondary_demodulator == "bpsk63":
return 62.5
def start_secondary_demodulator(self):
if not self.secondary_demodulator:
return
logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate())
secondary_command_demod = " | ".join(self.secondary_chain(self.secondary_demodulator))
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod)
self.try_create_configs(secondary_command_demod)
secondary_command_demod = secondary_command_demod.format(
input_pipe=self.pipes["iqtee2_pipe"],
secondary_shift_pipe=self.pipes["secondary_shift_pipe"],
secondary_decimation=self.secondary_decimation(),
secondary_samples_per_bits=self.secondary_samples_per_bits(),
secondary_bpf_cutoff=self.secondary_bpf_cutoff(),
secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(),
if_samp_rate=self.if_samp_rate(),
last_decimation=self.last_decimation,
audio_rate=self.get_audio_rate(),
direwolf_config=self.direwolf_config_path,
)
logger.debug("secondary command (demod) = %s", secondary_command_demod)
if self.output.supports_type("secondary_fft"):
secondary_command_fft = " | ".join(self.secondary_chain("fft"))
secondary_command_fft = secondary_command_fft.format(
input_pipe=self.pipes["iqtee_pipe"],
secondary_fft_input_size=self.secondary_fft_size,
secondary_fft_size=self.secondary_fft_size,
secondary_fft_block_size=self.secondary_fft_block_size(),
fft_averages=self.fft_averages,
)
logger.debug("secondary command (fft) = %s", secondary_command_fft)
self.secondary_process_fft = subprocess.Popen(
secondary_command_fft, stdout=subprocess.PIPE, shell=True, start_new_session=True
)
self.output.send_output(
"secondary_fft",
partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())),
)
# direwolf does not provide any meaningful data on stdout
# more specifically, it doesn't provide any data. if however, for any strange reason, it would start to do so,
# it would block if not read. by piping it to devnull, we avoid a potential pitfall here.
secondary_output = subprocess.DEVNULL if self.isPacket() else subprocess.PIPE
self.secondary_process_demod = subprocess.Popen(
secondary_command_demod, stdout=secondary_output, shell=True, start_new_session=True
)
self.secondary_processes_running = True
if self.isWsjtMode() or self.isJs8():
chopper = AudioChopper(self, self.get_secondary_demodulator())
chopper.send_output("audio", self.secondary_process_demod.stdout.read)
output_type = "js8_demod" if self.isJs8() else "wsjt_demod"
self.output.send_output(output_type, chopper.read)
elif self.isPacket():
# we best get the ax25 packets from the kiss socket
kiss = KissClient(self.direwolf_config.getPort())
self.output.send_output("packet_demod", kiss.read)
elif self.isPocsag():
self.output.send_output("pocsag_demod", self.secondary_process_demod.stdout.readline)
else:
self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1))
# open control pipes for csdr and send initialization data
if self.has_pipe("secondary_shift_pipe"): # TODO digimodes
self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes
def set_secondary_offset_freq(self, value):
self.secondary_offset_freq = value
if self.secondary_processes_running and self.has_pipe("secondary_shift_pipe"):
self.pipes["secondary_shift_pipe"].write(
"%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate())
)
def stop_secondary_demodulator(self):
if not self.secondary_processes_running:
return
self.try_delete_pipes(self.secondary_pipe_names)
self.try_delete_configs()
if self.secondary_process_fft:
try:
os.killpg(os.getpgid(self.secondary_process_fft.pid), signal.SIGTERM)
# drain any leftover data to free file descriptors
self.secondary_process_fft.communicate()
self.secondary_process_fft = None
except ProcessLookupError:
# been killed by something else, ignore
pass
if self.secondary_process_demod:
try:
os.killpg(os.getpgid(self.secondary_process_demod.pid), signal.SIGTERM)
# drain any leftover data to free file descriptors
self.secondary_process_demod.communicate()
self.secondary_process_demod = None
except ProcessLookupError:
# been killed by something else, ignore
pass
self.secondary_processes_running = False
def get_secondary_demodulator(self):
return self.secondary_demodulator
def set_secondary_fft_size(self, secondary_fft_size):
if self.secondary_fft_size == secondary_fft_size:
return
self.secondary_fft_size = secondary_fft_size
self.restart()
def set_audio_compression(self, what):
if self.audio_compression == what:
return
self.audio_compression = what
self.restart()
def get_audio_bytes_to_read(self):
# desired latency: 5ms
# uncompressed audio has 16 bits = 2 bytes per sample
base = self.output_rate * 0.005 * 2
# adpcm compresses the bitstream by 4
if self.audio_compression == "adpcm":
base = base / 4
return int(base)
def set_fft_compression(self, what):
if self.fft_compression == what:
return
self.fft_compression = what
self.restart()
def get_fft_bytes_to_read(self):
if self.fft_compression == "none":
return self.fft_size * 4
if self.fft_compression == "adpcm":
return int((self.fft_size / 2) + (10 / 2))
def get_secondary_fft_bytes_to_read(self):
if self.fft_compression == "none":
return self.secondary_fft_size * 4
if self.fft_compression == "adpcm":
return (self.secondary_fft_size / 2) + (10 / 2)
def set_samp_rate(self, samp_rate):
self.samp_rate = samp_rate
self.calculate_decimation()
if self.running:
self.restart()
def calculate_decimation(self):
(self.decimation, self.last_decimation) = self.get_decimation(self.samp_rate, self.get_audio_rate())
def get_decimation(self, input_rate, output_rate):
if output_rate <= 0:
raise ValueError("invalid output rate: {rate}".format(rate=output_rate))
decimation = 1
target_rate = output_rate
# wideband fm has a much higher frequency deviation (75kHz).
# we cannot cover this if we immediately decimate to the sample rate the audio will have later on, so we need
# to compensate here.
if self.get_demodulator() == "wfm" and output_rate < 200000:
target_rate = 200000
while input_rate / (decimation + 1) >= target_rate:
decimation += 1
fraction = float(input_rate / decimation) / output_rate
return decimation, fraction
def if_samp_rate(self):
return self.samp_rate / self.decimation
def get_name(self):
return self.name
def get_output_rate(self):
return self.output_rate
def get_hd_output_rate(self):
return self.hd_output_rate
def get_audio_rate(self):
if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isDrm():
return 48000
elif self.isWsjtMode() or self.isJs8():
return 12000
elif self.isFreeDV():
return 8000
elif self.isHdAudio():
return self.get_hd_output_rate()
return self.get_output_rate()
def isDigitalVoice(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator in ["dmr", "dstar", "nxdn", "ysf", "m17"]
def isWsjtMode(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"]
def isJs8(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator == "js8"
def isPacket(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator == "packet"
def isPocsag(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator == "pocsag"
def isFreeDV(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator == "freedv"
def isHdAudio(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator == "wfm"
def isDrm(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator == "drm"
def set_output_rate(self, output_rate):
if self.output_rate == output_rate:
return
self.output_rate = output_rate
self.calculate_decimation()
self.restart()
def set_hd_output_rate(self, hd_output_rate):
if self.hd_output_rate == hd_output_rate:
return
self.hd_output_rate = hd_output_rate
self.calculate_decimation()
self.restart()
def set_demodulator(self, demodulator):
if demodulator in ["usb", "lsb", "cw"]:
demodulator = "ssb"
if self.demodulator == demodulator:
return
self.demodulator = demodulator
self.calculate_decimation()
self.restart()
def get_demodulator(self):
return self.demodulator
def set_fft_size(self, fft_size):
if self.fft_size == fft_size:
return
self.fft_size = fft_size
self.restart()
def set_fft_fps(self, fft_fps):
self.fft_fps = fft_fps
self.restart()
def set_fft_averages(self, fft_averages):
self.fft_averages = fft_averages
self.restart()
def fft_block_size(self):
if self.fft_averages == 0:
return round(self.samp_rate / self.fft_fps)
else:
return round(self.samp_rate / self.fft_fps / self.fft_averages)
def set_offset_freq(self, offset_freq):
if offset_freq is None:
return
self.offset_freq = offset_freq
if self.running:
self.pipes["shift_pipe"].write("%g\n" % (-float(self.offset_freq) / self.samp_rate))
def set_center_freq(self, center_freq):
# dsp only needs to know this to be able to pass it to decoders in the form of get_operating_freq()
self.center_freq = center_freq
def get_operating_freq(self):
return self.center_freq + self.offset_freq
def set_bandpass(self, bandpass):
self.set_bpf(bandpass.low_cut, bandpass.high_cut)
def set_bpf(self, low_cut, high_cut):
self.low_cut = low_cut
self.high_cut = high_cut
if self.running:
self.pipes["bpf_pipe"].write(
"%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate())
)
def get_bpf(self):
return [self.low_cut, self.high_cut]
def convertToLinear(self, db):
return float(math.pow(10, db / 10))
def isSquelchActive(self):
return not self.isDigitalVoice() and not self.isPacket() and not self.isPocsag() and not self.isFreeDV() and not self.isDrm()
def set_squelch_level(self, squelch_level):
self.squelch_level = squelch_level
# no squelch required on digital voice modes
actual_squelch = self.squelch_level if self.isSquelchActive() else -150
if self.running and "squelch_pipe" in self.pipes:
self.pipes["squelch_pipe"].write("%g\n" % (self.convertToLinear(actual_squelch)))
def set_codecserver(self, s):
if self.codecserver == s:
return
self.codecserver = s
self.restart()
def get_codecserver_arg(self):
return "-s {}".format(self.codecserver) if self.codecserver else ""
def set_dmr_filter(self, filter):
if self.has_pipe("dmr_control_pipe"):
self.pipes["dmr_control_pipe"].write("{0}\n".format(filter))
def set_wfm_deemphasis_tau(self, tau):
if self.wfm_deemphasis_tau == tau:
return
self.wfm_deemphasis_tau = tau
self.restart()
def ddc_transition_bw(self):
return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate))
def try_create_pipes(self, pipe_names, command_base):
for pipe_name, pipe_type in pipe_names.items():
if self.has_pipe(pipe_name):
logger.warning("{pipe_name} is still in use", pipe_name=pipe_name)
self.pipes[pipe_name].close()
if "{" + pipe_name + "}" in command_base:
p = self.pipe_base_path + pipe_name
encoding = None
# TODO make digiham output unicode and then change this here
# the whole pipe enoding feature onlye exists because of this
if pipe_name == "meta_pipe":
encoding = "cp437"
self.pipes[pipe_name] = Pipe.create(p, pipe_type, encoding=encoding)
else:
self.pipes[pipe_name] = None
def has_pipe(self, name):
return name in self.pipes and self.pipes[name] is not None
def try_delete_pipes(self, pipe_names):
for pipe_name in pipe_names:
if self.has_pipe(pipe_name):
self.pipes[pipe_name].close()
self.pipes[pipe_name] = None
def try_create_configs(self, command):
if "{direwolf_config}" in command:
self.direwolf_config_path = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format(
tmp_dir=self.temporary_directory, myid=id(self)
)
self.direwolf_config = DirewolfConfig()
self.direwolf_config.wire(self)
file = open(self.direwolf_config_path, "w")
file.write(self.direwolf_config.getConfig(self.is_service))
file.close()
else:
self.direwolf_config = None
self.direwolf_config_path = None
def try_delete_configs(self):
if self.direwolf_config is not None:
self.direwolf_config.unwire(self)
self.direwolf_config = None
if self.direwolf_config_path is not None:
try:
os.unlink(self.direwolf_config_path)
except FileNotFoundError:
# result suits our expectations. fine :)
pass
except Exception:
logger.exception("try_delete_configs()")
self.direwolf_config_path = None
def onConfigChanged(self):
self.restart()
def start(self):
with self.modification_lock:
if self.running:
return
self.running = True
command_base = " | ".join(self.chain(self.demodulator))
# create control pipes for csdr
self.try_create_pipes(self.pipe_names, command_base)
# send initial config through the pipes
if self.has_pipe("bpf_pipe"):
self.set_bpf(self.low_cut, self.high_cut)
if self.has_pipe("shift_pipe"):
self.set_offset_freq(self.offset_freq)
if self.has_pipe("squelch_pipe"):
self.set_squelch_level(self.squelch_level)
if self.has_pipe("dmr_control_pipe"):
self.set_dmr_filter(3)
# run the command
command = command_base.format(
bpf_pipe=self.pipes["bpf_pipe"],
shift_pipe=self.pipes["shift_pipe"],
squelch_pipe=self.pipes["squelch_pipe"],
smeter_pipe=self.pipes["smeter_pipe"],
meta_pipe=self.pipes["meta_pipe"],
iqtee_pipe=self.pipes["iqtee_pipe"],
iqtee2_pipe=self.pipes["iqtee2_pipe"],
dmr_control_pipe=self.pipes["dmr_control_pipe"],
decimation=self.decimation,
last_decimation=self.last_decimation,
fft_size=self.fft_size,
fft_block_size=self.fft_block_size(),
fft_averages=self.fft_averages,
bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(),
ddc_transition_bw=self.ddc_transition_bw(),
flowcontrol=int(self.samp_rate * 2),
nc_port=self.nc_port,
output_rate=self.get_output_rate(),
smeter_report_every=int(self.if_samp_rate() / 6000),
codecserver_arg=self.get_codecserver_arg(),
audio_rate=self.get_audio_rate(),
wfm_deemphasis_tau=self.wfm_deemphasis_tau,
)
logger.debug("Command = %s", command)
out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL
self.process = subprocess.Popen(command, stdout=out, shell=True, start_new_session=True, bufsize=128)
def watch_thread():
rc = self.process.wait()
logger.debug("dsp thread ended with rc=%d", rc)
if rc == 0 and self.running and not self.modification_lock.locked():
logger.debug("restarting since rc = 0, self.running = true, and no modification")
self.restart()
threading.Thread(target=watch_thread, name="csdr_watch_thread").start()
audio_type = "hd_audio" if self.isHdAudio() else "audio"
if self.output.supports_type(audio_type):
self.output.send_output(
audio_type,
partial(
self.process.stdout.read,
self.get_fft_bytes_to_read() if self.demodulator == "fft" else self.get_audio_bytes_to_read(),
),
)
self.start_secondary_demodulator()
if self.has_pipe("smeter_pipe"):
def read_smeter():
raw = self.pipes["smeter_pipe"].readline()
if len(raw) == 0:
return None
else:
return float(raw.rstrip("\n"))
self.output.send_output("smeter", read_smeter)
if self.has_pipe("meta_pipe"):
def read_meta():
raw = self.pipes["meta_pipe"].readline()
if len(raw) == 0:
return None
else:
return raw.rstrip("\n")
self.output.send_output("meta", read_meta)
def stop(self):
with self.modification_lock:
self.running = False
if self.process is not None:
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
# drain any leftover data to free file descriptors
self.process.communicate()
self.process = None
except ProcessLookupError:
# been killed by something else, ignore
pass
self.stop_secondary_demodulator()
self.try_delete_pipes(self.pipe_names)
self.try_delete_configs()
def restart(self):
if not self.running:
return
self.stop()
self.start()

142
csdr/chain/__init__.py Normal file
View File

@ -0,0 +1,142 @@
from csdr.module import Module
from pycsdr.modules import Buffer
from pycsdr.types import Format
from typing import Union, Callable, Optional
class Chain(Module):
def __init__(self, workers):
super().__init__()
self.workers = workers
for i in range(1, len(self.workers)):
self._connect(self.workers[i - 1], self.workers[i])
def empty(self):
return not self.workers
def _connect(self, w1, w2, buffer: Optional[Buffer] = None) -> None:
if buffer is None:
buffer = Buffer(w1.getOutputFormat())
w1.setWriter(buffer)
w2.setReader(buffer.getReader())
def setReader(self, reader):
if self.reader is reader:
return
super().setReader(reader)
if self.workers:
self.workers[0].setReader(reader)
def setWriter(self, writer):
if self.writer is writer:
return
super().setWriter(writer)
if self.workers:
self.workers[-1].setWriter(writer)
def indexOf(self, search: Union[Callable, object]) -> int:
def searchFn(x):
if callable(search):
return search(x)
else:
return x is search
try:
return next(i for i, v in enumerate(self.workers) if searchFn(v))
except StopIteration:
return -1
def replace(self, index, newWorker):
if index >= len(self.workers):
raise IndexError("Index {} does not exist".format(index))
self.workers[index].stop()
self.workers[index] = newWorker
error = None
if index == 0:
if self.reader is not None:
newWorker.setReader(self.reader)
else:
try:
previousWorker = self.workers[index - 1]
self._connect(previousWorker, newWorker)
except ValueError as e:
# store error for later raising, but still attempt the second connection
error = e
if index == len(self.workers) - 1:
if self.writer is not None:
newWorker.setWriter(self.writer)
else:
try:
nextWorker = self.workers[index + 1]
self._connect(newWorker, nextWorker)
except ValueError as e:
error = e
if error is not None:
raise error
def append(self, newWorker):
previousWorker = None
if self.workers:
previousWorker = self.workers[-1]
self.workers.append(newWorker)
if previousWorker:
self._connect(previousWorker, newWorker)
elif self.reader is not None:
newWorker.setReader(self.reader)
if self.writer is not None:
newWorker.setWriter(self.writer)
def insert(self, newWorker):
nextWorker = None
if self.workers:
nextWorker = self.workers[0]
self.workers.insert(0, newWorker)
if nextWorker:
self._connect(newWorker, nextWorker)
elif self.writer is not None:
newWorker.setWriter(self.writer)
if self.reader is not None:
newWorker.setReader(self.reader)
def remove(self, index):
removedWorker = self.workers[index]
self.workers.remove(removedWorker)
removedWorker.stop()
if index == 0:
if self.reader is not None and len(self.workers):
self.workers[0].setReader(self.reader)
elif index == len(self.workers):
if self.writer is not None:
self.workers[-1].setWriter(self.writer)
else:
previousWorker = self.workers[index - 1]
nextWorker = self.workers[index]
self._connect(previousWorker, nextWorker)
def stop(self):
for w in self.workers:
w.stop()
def getInputFormat(self) -> Format:
if self.workers:
return self.workers[0].getInputFormat()
else:
raise BufferError("getInputFormat on empty chain")
def getOutputFormat(self) -> Format:
if self.workers:
return self.workers[-1].getOutputFormat()
else:
raise BufferError("getOutputFormat on empty chain")

76
csdr/chain/analog.py Normal file
View File

@ -0,0 +1,76 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, HdAudio, DeemphasisTauChain
from pycsdr.modules import AmDemod, DcBlock, FmDemod, Limit, NfmDeemphasis, Agc, WfmDeemphasis, FractionalDecimator, RealPart
from pycsdr.types import Format, AgcProfile
class Am(BaseDemodulatorChain):
def __init__(self):
agc = Agc(Format.FLOAT)
agc.setProfile(AgcProfile.SLOW)
agc.setInitialGain(200)
workers = [
AmDemod(),
DcBlock(),
agc,
]
super().__init__(workers)
class NFm(BaseDemodulatorChain):
def __init__(self, sampleRate: int):
self.sampleRate = sampleRate
agc = Agc(Format.FLOAT)
agc.setProfile(AgcProfile.SLOW)
agc.setMaxGain(3)
workers = [
FmDemod(),
Limit(),
NfmDeemphasis(sampleRate),
agc,
]
super().__init__(workers)
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
self.replace(2, NfmDeemphasis(sampleRate))
class WFm(BaseDemodulatorChain, FixedIfSampleRateChain, DeemphasisTauChain, HdAudio):
def __init__(self, sampleRate: int, tau: float):
self.sampleRate = sampleRate
self.tau = tau
workers = [
FmDemod(),
Limit(),
FractionalDecimator(Format.FLOAT, 200000.0 / self.sampleRate, prefilter=True),
WfmDeemphasis(self.sampleRate, self.tau),
]
super().__init__(workers)
def getFixedIfSampleRate(self):
return 200000
def setDeemphasisTau(self, tau: float) -> None:
if tau == self.tau:
return
self.tau = tau
self.replace(3, WfmDeemphasis(self.sampleRate, self.tau))
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
self.replace(2, FractionalDecimator(Format.FLOAT, 200000.0 / self.sampleRate, prefilter=True))
self.replace(3, WfmDeemphasis(self.sampleRate, self.tau))
class Ssb(BaseDemodulatorChain):
def __init__(self):
workers = [
RealPart(),
Agc(Format.FLOAT),
]
super().__init__(workers)

72
csdr/chain/clientaudio.py Normal file
View File

@ -0,0 +1,72 @@
from csdr.chain import Chain
from pycsdr.modules import AudioResampler, Convert, AdpcmEncoder, Limit
from pycsdr.types import Format
class Converter(Chain):
def __init__(self, format: Format, inputRate: int, clientRate: int):
workers = []
if inputRate != clientRate:
# we only have an audio resampler for float ATM so if we need to resample, we need to convert
if format != Format.FLOAT:
workers += [Convert(format, Format.FLOAT)]
workers += [AudioResampler(inputRate, clientRate), Limit(), Convert(Format.FLOAT, Format.SHORT)]
elif format != Format.SHORT:
workers += [Convert(format, Format.SHORT)]
super().__init__(workers)
class ClientAudioChain(Chain):
def __init__(self, format: Format, inputRate: int, clientRate: int, compression: str):
self.format = format
self.inputRate = inputRate
self.clientRate = clientRate
workers = []
converter = self._buildConverter()
if not converter.empty():
workers += [converter]
if compression == "adpcm":
workers += [AdpcmEncoder(sync=True)]
super().__init__(workers)
def _buildConverter(self):
return Converter(self.format, self.inputRate, self.clientRate)
def _updateConverter(self):
converter = self._buildConverter()
index = self.indexOf(lambda x: isinstance(x, Converter))
if converter.empty():
if index >= 0:
self.remove(index)
else:
if index >= 0:
self.replace(index, converter)
else:
self.insert(converter)
def setFormat(self, format: Format) -> None:
if format == self.format:
return
self.format = format
self._updateConverter()
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
return
self.inputRate = inputRate
self._updateConverter()
def setClientRate(self, clientRate: int) -> None:
if clientRate == self.clientRate:
return
self.clientRate = clientRate
self._updateConverter()
def setAudioCompression(self, compression: str) -> None:
index = self.indexOf(lambda x: isinstance(x, AdpcmEncoder))
if compression == "adpcm":
if index < 0:
self.append(AdpcmEncoder(sync=True))
else:
if index >= 0:
self.remove(index)

69
csdr/chain/demodulator.py Normal file
View File

@ -0,0 +1,69 @@
from csdr.chain import Chain
from abc import ABC, ABCMeta, abstractmethod
from pycsdr.modules import Writer
class FixedAudioRateChain(ABC):
@abstractmethod
def getFixedAudioRate(self) -> int:
pass
class FixedIfSampleRateChain(ABC):
@abstractmethod
def getFixedIfSampleRate(self) -> int:
pass
class DialFrequencyReceiver(ABC):
@abstractmethod
def setDialFrequency(self, frequency: int) -> None:
pass
# marker interface
class HdAudio:
pass
class MetaProvider(ABC):
@abstractmethod
def setMetaWriter(self, writer: Writer) -> None:
pass
class SlotFilterChain(ABC):
@abstractmethod
def setSlotFilter(self, filter: int) -> None:
pass
class SecondarySelectorChain(ABC):
def getBandwidth(self) -> float:
pass
class DeemphasisTauChain(ABC):
@abstractmethod
def setDeemphasisTau(self, tau: float) -> None:
pass
class BaseDemodulatorChain(Chain):
def supportsSquelch(self) -> bool:
return True
def setSampleRate(self, sampleRate: int) -> None:
pass
class SecondaryDemodulator(Chain):
def supportsSquelch(self) -> bool:
return True
def setSampleRate(self, sampleRate: int) -> None:
pass
class ServiceDemodulator(SecondaryDemodulator, FixedAudioRateChain, metaclass=ABCMeta):
pass

105
csdr/chain/digiham.py Normal file
View File

@ -0,0 +1,105 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedAudioRateChain, FixedIfSampleRateChain, DialFrequencyReceiver, MetaProvider, SlotFilterChain
from pycsdr.modules import FmDemod, Agc, Writer, Buffer
from pycsdr.types import Format
from digiham.modules import DstarDecoder, DcBlock, FskDemodulator, GfskDemodulator, DigitalVoiceFilter, MbeSynthesizer, NarrowRrcFilter, NxdnDecoder, DmrDecoder, WideRrcFilter, YsfDecoder
from digiham.ambe import Modes
from owrx.meta import MetaParser
class DigihamChain(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver, MetaProvider):
def __init__(self, fskDemodulator, decoder, mbeMode, filter=None, codecserver: str = ""):
self.decoder = decoder
if codecserver is None:
codecserver = ""
agc = Agc(Format.SHORT)
agc.setMaxGain(30)
agc.setInitialGain(3)
workers = [FmDemod(), DcBlock()]
if filter is not None:
workers += [filter]
workers += [
fskDemodulator,
decoder,
MbeSynthesizer(mbeMode, codecserver),
DigitalVoiceFilter(),
agc
]
self.metaParser = None
self.dialFrequency = None
super().__init__(workers)
def getFixedIfSampleRate(self):
return 48000
def getFixedAudioRate(self):
return 8000
def setMetaWriter(self, writer: Writer) -> None:
if self.metaParser is None:
self.metaParser = MetaParser()
buffer = Buffer(Format.CHAR)
self.decoder.setMetaWriter(buffer)
self.metaParser.setReader(buffer.getReader())
if self.dialFrequency is not None:
self.metaParser.setDialFrequency(self.dialFrequency)
self.metaParser.setWriter(writer)
def supportsSquelch(self):
return False
def setDialFrequency(self, frequency: int) -> None:
self.dialFrequency = frequency
if self.metaParser is None:
return
self.metaParser.setDialFrequency(frequency)
def stop(self):
if self.metaParser is not None:
self.metaParser.stop()
super().stop()
class Dstar(DigihamChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=FskDemodulator(samplesPerSymbol=10),
decoder=DstarDecoder(),
mbeMode=Modes.DStarMode,
codecserver=codecserver
)
class Nxdn(DigihamChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=GfskDemodulator(samplesPerSymbol=20),
decoder=NxdnDecoder(),
mbeMode=Modes.NxdnMode,
filter=NarrowRrcFilter(),
codecserver=codecserver
)
class Dmr(DigihamChain, SlotFilterChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=GfskDemodulator(samplesPerSymbol=10),
decoder=DmrDecoder(),
mbeMode=Modes.DmrMode,
filter=WideRrcFilter(),
codecserver=codecserver,
)
def setSlotFilter(self, slotFilter: int) -> None:
self.decoder.setSlotFilter(slotFilter)
class Ysf(DigihamChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=GfskDemodulator(samplesPerSymbol=10),
decoder=YsfDecoder(),
mbeMode=Modes.YsfMode,
filter=WideRrcFilter(),
codecserver=codecserver
)

87
csdr/chain/digimodes.py Normal file
View File

@ -0,0 +1,87 @@
from csdr.chain.demodulator import ServiceDemodulator, SecondaryDemodulator, DialFrequencyReceiver, SecondarySelectorChain
from owrx.audio.chopper import AudioChopper, AudioChopperParser
from owrx.aprs.kiss import KissDeframer
from owrx.aprs import Ax25Parser, AprsParser
from pycsdr.modules import Convert, FmDemod, Agc, TimingRecovery, DBPskDecoder, VaricodeDecoder
from pycsdr.types import Format
from owrx.aprs.module import DirewolfModule
from digiham.modules import FskDemodulator, PocsagDecoder
from owrx.pocsag import PocsagParser
class AudioChopperDemodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self, mode: str, parser: AudioChopperParser):
self.chopper = AudioChopper(mode, parser)
workers = [Convert(Format.FLOAT, Format.SHORT), self.chopper]
super().__init__(workers)
def getFixedAudioRate(self):
return 12000
def setDialFrequency(self, frequency: int) -> None:
self.chopper.setDialFrequency(frequency)
class PacketDemodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self, service: bool = False):
self.parser = AprsParser()
workers = [
FmDemod(),
Convert(Format.FLOAT, Format.SHORT),
DirewolfModule(service=service),
KissDeframer(),
Ax25Parser(),
self.parser,
]
super().__init__(workers)
def supportsSquelch(self) -> bool:
return False
def getFixedAudioRate(self) -> int:
return 48000
def setDialFrequency(self, frequency: int) -> None:
self.parser.setDialFrequency(frequency)
class PocsagDemodulator(ServiceDemodulator):
def __init__(self):
workers = [
FmDemod(),
FskDemodulator(samplesPerSymbol=40, invert=True),
PocsagDecoder(),
PocsagParser(),
]
super().__init__(workers)
def supportsSquelch(self) -> bool:
return False
def getFixedAudioRate(self) -> int:
return 48000
class PskDemodulator(SecondaryDemodulator, SecondarySelectorChain):
def __init__(self, baudRate: float):
self.baudRate = baudRate
# this is an assumption, we will adjust in setSampleRate
self.sampleRate = 12000
secondary_samples_per_bits = int(round(self.sampleRate / self.baudRate)) & ~3
workers = [
Agc(Format.COMPLEX_FLOAT),
TimingRecovery(secondary_samples_per_bits, 0.5, 2, useQ=True),
DBPskDecoder(),
VaricodeDecoder(),
]
super().__init__(workers)
def getBandwidth(self):
return self.baudRate
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
secondary_samples_per_bits = int(round(self.sampleRate / self.baudRate)) & ~3
self.replace(1, TimingRecovery(secondary_samples_per_bits, 0.5, 2, useQ=True))

19
csdr/chain/drm.py Normal file
View File

@ -0,0 +1,19 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain
from pycsdr.modules import Convert, Downmix
from pycsdr.types import Format
from csdr.module.drm import DrmModule
class Drm(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain):
def __init__(self):
workers = [Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), DrmModule(), Downmix()]
super().__init__(workers)
def supportsSquelch(self) -> bool:
return False
def getFixedIfSampleRate(self) -> int:
return 48000
def getFixedAudioRate(self) -> int:
return 48000

96
csdr/chain/fft.py Normal file
View File

@ -0,0 +1,96 @@
from csdr.chain import Chain
from pycsdr.modules import Fft, LogPower, LogAveragePower, FftSwap, FftAdpcm
class FftAverager(Chain):
def __init__(self, fft_size, fft_averages):
self.fftSize = fft_size
self.fftAverages = fft_averages
workers = [self._getWorker()]
super().__init__(workers)
def setFftAverages(self, fft_averages):
if self.fftAverages == fft_averages:
return
self.fftAverages = fft_averages
self.replace(0, self._getWorker())
def _getWorker(self):
if self.fftAverages == 0:
return LogPower(add_db=-70)
else:
return LogAveragePower(add_db=-70, fft_size=self.fftSize, avg_number=self.fftAverages)
class FftChain(Chain):
def __init__(self, samp_rate, fft_size, fft_v_overlap_factor, fft_fps, fft_compression):
self.sampleRate = samp_rate
self.vOverlapFactor = fft_v_overlap_factor
self.fps = fft_fps
self.size = fft_size
self.blockSize = 0
self.fft = Fft(size=self.size, every_n_samples=self.blockSize)
self.averager = FftAverager(fft_size=self.size, fft_averages=10)
self.fftExchangeSides = FftSwap(fft_size=self.size)
workers = [
self.fft,
self.averager,
self.fftExchangeSides,
]
self.compressFftAdpcm = None
if fft_compression == "adpcm":
self.compressFftAdpcm = FftAdpcm(fft_size=self.size)
workers += [self.compressFftAdpcm]
self._updateParameters()
super().__init__(workers)
def _setBlockSize(self, fft_block_size):
if self.blockSize == int(fft_block_size):
return
self.blockSize = int(fft_block_size)
self.fft.setEveryNSamples(self.blockSize)
def setVOverlapFactor(self, fft_v_overlap_factor):
if self.vOverlapFactor == fft_v_overlap_factor:
return
self.vOverlapFactor = fft_v_overlap_factor
self._updateParameters()
def setFps(self, fft_fps):
if self.fps == fft_fps:
return
self.fps = fft_fps
self._updateParameters()
def setSampleRate(self, samp_rate):
if self.sampleRate == samp_rate:
return
self.sampleRate = samp_rate
self._updateParameters()
def _updateParameters(self):
fftAverages = 0
if self.vOverlapFactor > 0:
fftAverages = int(round(1.0 * self.sampleRate / self.size / self.fps / (1.0 - self.vOverlapFactor)))
self.averager.setFftAverages(fftAverages)
if fftAverages == 0:
self._setBlockSize(self.sampleRate / self.fps)
else:
self._setBlockSize(self.sampleRate / self.fps / fftAverages)
def setCompression(self, compression: str) -> None:
if compression == "adpcm" and not self.compressFftAdpcm:
self.compressFftAdpcm = FftAdpcm(self.size)
# should always be at the end
self.append(self.compressFftAdpcm)
elif compression == "none" and self.compressFftAdpcm:
self.compressFftAdpcm.stop()
self.compressFftAdpcm = None
# should always be at that position (right?)
self.remove(3)

28
csdr/chain/freedv.py Normal file
View File

@ -0,0 +1,28 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain
from csdr.module.freedv import FreeDVModule
from pycsdr.modules import RealPart, Agc, Convert
from pycsdr.types import Format
class FreeDV(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain):
def __init__(self):
agc = Agc(Format.SHORT)
agc.setMaxGain(30)
agc.setInitialGain(3)
workers = [
RealPart(),
Agc(Format.FLOAT),
Convert(Format.FLOAT, Format.SHORT),
FreeDVModule(),
agc,
]
super().__init__(workers)
def getFixedIfSampleRate(self) -> int:
return 8000
def getFixedAudioRate(self) -> int:
return 8000
def supportsSquelch(self) -> bool:
return False

26
csdr/chain/m17.py Normal file
View File

@ -0,0 +1,26 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain
from csdr.module.m17 import M17Module
from pycsdr.modules import FmDemod, Limit, Convert
from pycsdr.types import Format
from digiham.modules import DcBlock
class M17(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain):
def __init__(self):
workers = [
FmDemod(),
DcBlock(),
Limit(),
Convert(Format.FLOAT, Format.SHORT),
M17Module(),
]
super().__init__(workers)
def getFixedIfSampleRate(self) -> int:
return 48000
def getFixedAudioRate(self) -> int:
return 8000
def supportsSquelch(self) -> bool:
return False

160
csdr/chain/selector.py Normal file
View File

@ -0,0 +1,160 @@
from csdr.chain import Chain
from pycsdr.modules import Shift, FirDecimate, Bandpass, Squelch, FractionalDecimator, Writer
from pycsdr.types import Format
import math
class Decimator(Chain):
def __init__(self, inputRate: int, outputRate: int):
if outputRate > inputRate:
raise ValueError("impossible decimation: cannot upsample {} to {}".format(inputRate, outputRate))
self.inputRate = inputRate
self.outputRate = outputRate
decimation, fraction = self._getDecimation(outputRate)
transition = 0.15 * (outputRate / float(self.inputRate))
# set the cutoff on the fist decimation stage lower so that the resulting output
# is already prepared for the second (fractional) decimation stage.
# this spares us a second filter.
cutoff = 0.5 * decimation / (self.inputRate / outputRate)
workers = [
FirDecimate(decimation, transition, cutoff),
]
if fraction != 1.0:
workers += [FractionalDecimator(Format.COMPLEX_FLOAT, fraction)]
super().__init__(workers)
def _getDecimation(self, outputRate: int) -> (int, float):
d = self.inputRate / outputRate
dInt = int(d)
dFloat = float(self.inputRate / dInt) / outputRate
return dInt, dFloat
def _reconfigure(self):
decimation, fraction = self._getDecimation(self.outputRate)
transition = 0.15 * (self.outputRate / float(self.inputRate))
cutoff = 0.5 * decimation / (self.inputRate / self.outputRate)
self.replace(0, FirDecimate(decimation, transition, cutoff))
index = self.indexOf(lambda x: isinstance(x, FractionalDecimator))
if fraction != 1.0:
decimator = FractionalDecimator(Format.COMPLEX_FLOAT, fraction)
if index >= 0:
self.replace(index, decimator)
else:
self.append(decimator)
elif index >= 0:
self.remove(index)
def setOutputRate(self, outputRate: int) -> None:
if outputRate == self.outputRate:
return
self.outputRate = outputRate
self._reconfigure()
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
return
self.inputRate = inputRate
self._reconfigure()
class Selector(Chain):
def __init__(self, inputRate: int, outputRate: int, withSquelch: bool = True):
self.inputRate = inputRate
self.outputRate = outputRate
self.frequencyOffset = 0
self.shift = Shift(0.0)
self.decimation = Decimator(inputRate, outputRate)
self.bandpass = self._buildBandpass()
self.bandpassCutoffs = None
self.setBandpass(-4000, 4000)
workers = [self.shift, self.decimation, self.bandpass]
if withSquelch:
self.readings_per_second = 4
# s-meter readings are available every 1024 samples
# the reporting interval is measured in those 1024-sample blocks
self.squelch = Squelch(5, int(outputRate / (self.readings_per_second * 1024)))
workers += [self.squelch]
super().__init__(workers)
def _buildBandpass(self) -> Bandpass:
bp_transition = 320.0 / self.outputRate
return Bandpass(transition=bp_transition, use_fft=True)
def setFrequencyOffset(self, offset: int) -> None:
if offset == self.frequencyOffset:
return
self.frequencyOffset = offset
self._updateShift()
def _updateShift(self):
shift = -self.frequencyOffset / self.inputRate
self.shift.setRate(shift)
def _convertToLinear(self, db: float) -> float:
return float(math.pow(10, db / 10))
def setSquelchLevel(self, level: float) -> None:
self.squelch.setSquelchLevel(self._convertToLinear(level))
def setBandpass(self, lowCut: float, highCut: float) -> None:
self.bandpassCutoffs = [lowCut, highCut]
scaled = [x / self.outputRate for x in self.bandpassCutoffs]
self.bandpass.setBandpass(*scaled)
def setLowCut(self, lowCut: float) -> None:
self.bandpassCutoffs[0] = lowCut
self.setBandpass(*self.bandpassCutoffs)
def setHighCut(self, highCut: float) -> None:
self.bandpassCutoffs[1] = highCut
self.setBandpass(*self.bandpassCutoffs)
def setPowerWriter(self, writer: Writer) -> None:
self.squelch.setPowerWriter(writer)
def setOutputRate(self, outputRate: int) -> None:
if outputRate == self.outputRate:
return
self.outputRate = outputRate
self.decimation.setOutputRate(outputRate)
self.squelch.setReportInterval(int(outputRate / (self.readings_per_second * 1024)))
self.bandpass = self._buildBandpass()
self.setBandpass(*self.bandpassCutoffs)
self.replace(2, self.bandpass)
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
return
self.inputRate = inputRate
self.decimation.setInputRate(inputRate)
self._updateShift()
class SecondarySelector(Chain):
def __init__(self, sampleRate: int, bandwidth: float):
self.sampleRate = sampleRate
self.frequencyOffset = 0
self.shift = Shift(0.0)
cutoffRate = bandwidth / sampleRate
self.bandpass = Bandpass(-cutoffRate, cutoffRate, cutoffRate, use_fft=True)
workers = [self.shift, self.bandpass]
super().__init__(workers)
def setFrequencyOffset(self, offset: int) -> None:
if offset == self.frequencyOffset:
return
self.frequencyOffset = offset
if self.frequencyOffset is None:
return
self.shift.setRate(-offset / self.sampleRate)

129
csdr/module/__init__.py Normal file
View File

@ -0,0 +1,129 @@
from pycsdr.modules import Module as BaseModule
from pycsdr.modules import Reader, Writer
from pycsdr.types import Format
from abc import ABCMeta, abstractmethod
from threading import Thread
from io import BytesIO
from subprocess import Popen, PIPE
from functools import partial
import pickle
class Module(BaseModule, metaclass=ABCMeta):
def __init__(self):
self.reader = None
self.writer = None
super().__init__()
def setReader(self, reader: Reader) -> None:
self.reader = reader
def setWriter(self, writer: Writer) -> None:
self.writer = writer
@abstractmethod
def getInputFormat(self) -> Format:
pass
@abstractmethod
def getOutputFormat(self) -> Format:
pass
def pump(self, read, write):
def copy():
while True:
data = None
try:
data = read()
except ValueError:
pass
if data is None or isinstance(data, bytes) and len(data) == 0:
break
write(data)
return copy
class AutoStartModule(Module, metaclass=ABCMeta):
def _checkStart(self) -> None:
if self.reader is not None and self.writer is not None:
self.start()
def setReader(self, reader: Reader) -> None:
super().setReader(reader)
self._checkStart()
def setWriter(self, writer: Writer) -> None:
super().setWriter(writer)
self._checkStart()
@abstractmethod
def start(self):
pass
class ThreadModule(AutoStartModule, Thread, metaclass=ABCMeta):
def __init__(self):
self.doRun = True
super().__init__()
Thread.__init__(self)
@abstractmethod
def run(self):
pass
def stop(self):
self.doRun = False
self.reader.stop()
def start(self):
Thread.start(self)
class PickleModule(ThreadModule):
def getInputFormat(self) -> Format:
return Format.CHAR
def getOutputFormat(self) -> Format:
return Format.CHAR
def run(self):
while self.doRun:
data = self.reader.read()
if data is None:
self.doRun = False
break
io = BytesIO(data.tobytes())
try:
while True:
output = self.process(pickle.load(io))
if output is not None:
self.writer.write(pickle.dumps(output))
except EOFError:
pass
@abstractmethod
def process(self, input):
pass
class PopenModule(AutoStartModule, metaclass=ABCMeta):
def __init__(self):
self.process = None
super().__init__()
@abstractmethod
def getCommand(self):
pass
def start(self):
self.process = Popen(self.getCommand(), stdin=PIPE, stdout=PIPE)
Thread(target=self.pump(self.reader.read, self.process.stdin.write)).start()
Thread(target=self.pump(partial(self.process.stdout.read, 1024), self.writer.write)).start()
def stop(self):
if self.process is not None:
self.process.terminate()
self.process.wait()
self.process = None
self.reader.stop()

14
csdr/module/drm.py Normal file
View File

@ -0,0 +1,14 @@
from csdr.module import PopenModule
from pycsdr.types import Format
class DrmModule(PopenModule):
def getInputFormat(self) -> Format:
return Format.COMPLEX_FLOAT
def getOutputFormat(self) -> Format:
return Format.SHORT
def getCommand(self):
# dream -c 6 --sigsrate 48000 --audsrate 48000 -I - -O -
return ["dream", "-c", "6", "--sigsrate", "48000", "--audsrate", "48000", "-I", "-", "-O", "-"]

13
csdr/module/freedv.py Normal file
View File

@ -0,0 +1,13 @@
from pycsdr.types import Format
from csdr.module import PopenModule
class FreeDVModule(PopenModule):
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.SHORT
def getCommand(self):
return ["freedv_rx", "1600", "-", "-"]

13
csdr/module/m17.py Normal file
View File

@ -0,0 +1,13 @@
from csdr.module import PopenModule
from pycsdr.types import Format
class M17Module(PopenModule):
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.SHORT
def getCommand(self):
return ["m17-demod"]

View File

@ -1,36 +0,0 @@
import threading
import logging
logger = logging.getLogger(__name__)
class Output(object):
def send_output(self, t, read_fn):
if not self.supports_type(t):
# TODO rewrite the output mechanism in a way that avoids producing unnecessary data
logger.warning("dumping output of type %s since it is not supported.", t)
threading.Thread(target=self.pump(read_fn, lambda x: None), name="csdr_pump_thread").start()
return
self.receive_output(t, read_fn)
def receive_output(self, t, read_fn):
pass
def pump(self, read, write):
def copy():
run = True
while run:
data = None
try:
data = read()
except ValueError:
pass
if data is None or (isinstance(data, bytes) and len(data) == 0):
run = False
else:
write(data)
return copy
def supports_type(self, t):
return True

View File

@ -1,156 +0,0 @@
import os
import select
import time
import threading
import logging
logger = logging.getLogger(__name__)
class Pipe(object):
READ = "r"
WRITE = "w"
NONE = None
@staticmethod
def create(path, t, encoding=None):
if t == Pipe.READ:
return ReadingPipe(path, encoding=encoding)
elif t == Pipe.WRITE:
return WritingPipe(path, encoding=encoding)
elif t == Pipe.NONE:
return Pipe(path, None, encoding=encoding)
def __init__(self, path, direction, encoding=None):
self.doOpen = True
self.path = "{base}_{myid}".format(base=path, myid=id(self))
self.direction = direction
self.encoding = encoding
self.file = None
os.mkfifo(self.path)
def open(self):
"""
this method opens the file descriptor with an added O_NONBLOCK flag. This gives us a special behaviour for
FIFOS, when they are not opened by the opposing side:
- opening a pipe for writing will throw an OSError with errno = 6 (ENXIO). This is handled specially in the
WritingPipe class.
- opening a pipe for reading will pass through this method instantly, even if the opposing end has not been
opened yet, but the resulting file descriptor will behave as if O_NONBLOCK is set (even if we remove it
immediately here), resulting in empty reads until data is available. This is handled specially in the
ReadingPipe class.
"""
def opener(path, flags):
fd = os.open(path, flags | os.O_NONBLOCK)
os.set_blocking(fd, True)
return fd
self.file = open(self.path, self.direction, encoding=self.encoding, opener=opener)
def close(self):
self.doOpen = False
try:
if self.file is not None:
self.file.close()
os.unlink(self.path)
except FileNotFoundError:
# it seems like we keep calling this twice. no idea why, but we don't need the resulting error.
pass
except Exception:
logger.exception("Pipe.close()")
def __str__(self):
return self.path
class WritingPipe(Pipe):
def __init__(self, path, encoding=None):
self.queue = []
self.queueLock = threading.Lock()
super().__init__(path, "w", encoding=encoding)
self.open()
def open_and_dequeue(self):
"""
This method implements a retry loop that can be interrupted in case the Pipe gets shutdown before actually
being connected.
After the pipe is opened successfully, all data that has been queued is sent in the order it was passed into
write().
"""
retries = 0
while self.file is None and self.doOpen and retries < 10:
try:
super().open()
except OSError as error:
# ENXIO = FIFO has not been opened for reading
if error.errno == 6:
time.sleep(0.1)
retries += 1
else:
raise
# if doOpen is false, opening has been canceled, so no warning in that case.
if self.file is None:
if self.doOpen:
logger.warning("could not open FIFO %s", self.path)
return
with self.queueLock:
for i in self.queue:
self.file.write(i)
self.file.flush()
self.queue = None
def open(self):
"""
This sends the opening operation off to a background thread. If we were to block the thread here, another pipe
may be waiting in the queue to be opened on the opposing side, resulting in a deadlock
"""
threading.Thread(target=self.open_and_dequeue, name="csdr_pipe_thread").start()
def write(self, data):
"""
This method queues all data to be written until the file is actually opened. As soon as a file is available,
it becomes a passthrough.
"""
if self.file is None:
with self.queueLock:
self.queue.append(data)
return
r = self.file.write(data)
self.file.flush()
return r
class ReadingPipe(Pipe):
def __init__(self, path, encoding=None):
super().__init__(path, "r", encoding=encoding)
def open(self):
"""
This method implements an interruptible loop that waits for the file descriptor to be opened and the first
batch of data coming in using repeated select() calls.
:return:
"""
if not self.doOpen:
return
super().open()
while self.doOpen:
(read, _, _) = select.select([self.file], [], [], 1)
if self.file in read:
break
def read(self):
if self.file is None:
self.open()
return self.file.read()
def readline(self):
if self.file is None:
self.open()
return self.file.readline()

3
debian/changelog vendored
View File

@ -1,5 +1,8 @@
openwebrx (1.2.0) UNRELEASED; urgency=low
* Major rewrite of all demodulation components to make use of the new
csdr/pycsdr and digiham/pydigiham demodulator modules
-- Jakob Ketterl <jakob.ketterl@gmx.de> Tue, 03 Aug 2021 13:54:00 +0000
openwebrx (1.1.0) buster hirsute; urgency=low

4
debian/control vendored
View File

@ -10,7 +10,7 @@ Vcs-Git: https://github.com/jketterl/openwebrx.git
Package: openwebrx
Architecture: all
Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.5), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends}
Recommends: digiham (>= 0.5), sox, direwolf (>= 1.4), wsjtx, runds-connector (>= 0.2), hpsdrconnector, aprs-symbols, m17-demod, js8call
Depends: adduser, python3 (>= 3.5), python3-pkg-resources, owrx-connector (>= 0.5), soapysdr-tools, python3-csdr (>= 0.18), ${python3:Depends}, ${misc:Depends}
Recommends: python3-digiham (>= 0.6), direwolf (>= 1.4), wsjtx, runds-connector (>= 0.2), hpsdrconnector, aprs-symbols, m17-demod, js8call, python3-js8py (>= 0.1), nmux (>= 0.18)
Description: multi-user web sdr
Open source, multi-user SDR receiver with a web interface

View File

@ -1,5 +1,5 @@
--- CMakeLists.txt.orig 2021-03-30 15:28:36.956587995 +0200
+++ CMakeLists.txt 2021-03-30 15:29:45.719326832 +0200
--- CMakeLists.txt.orig 2021-09-28 14:33:14.329598412 +0200
+++ CMakeLists.txt 2021-09-28 14:34:23.052345270 +0200
@@ -106,24 +106,6 @@

View File

@ -1,15 +1,6 @@
diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamlib.cmake
--- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2021-05-31 18:56:20.657682124 +0200
+++ wsjtx/CMake/Modules/Findhamlib.cmake 2021-05-31 18:57:03.963994898 +0200
@@ -85,4 +85,4 @@
# Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to
# TRUE if all listed variables are TRUE
include (FindPackageHandleStandardArgs)
-find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS)
+find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES)
diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
--- wsjtx-orig/CMakeLists.txt 2021-05-31 18:56:20.657682124 +0200
+++ wsjtx/CMakeLists.txt 2021-05-31 19:08:02.768474060 +0200
--- wsjtx-orig/CMakeLists.txt 2021-09-28 14:36:01.731488130 +0200
+++ wsjtx/CMakeLists.txt 2021-09-28 15:51:30.136197625 +0200
@@ -122,7 +122,7 @@
option (WSJT_QDEBUG_TO_FILE "Redirect Qt debuging messages to a trace file.")
option (WSJT_SOFT_KEYING "Apply a ramp to CW keying envelope to reduce transients." ON)
@ -19,10 +10,11 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
option (WSJT_RIG_NONE_CAN_SPLIT "Allow split operation with \"None\" as rig.")
option (WSJT_TRACE_UDP "Debugging option that turns on UDP message protocol diagnostics.")
option (WSJT_BUILD_UTILS "Build simulators and code demonstrators." ON)
@@ -169,74 +169,7 @@
@@ -169,76 +169,7 @@
)
set (wsjt_qt_CXXSRCS
- helper_functions.cpp
- qt_helpers.cpp
- widgets/MessageBox.cpp
- MetaDataRegistry.cpp
@ -91,19 +83,20 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- Network/NetworkAccessManager.cpp
- widgets/LazyFillComboBox.cpp
- widgets/CheckableItemComboBox.cpp
- widgets/BandComboBox.cpp
)
set (wsjt_qtmm_CXXSRCS
@@ -857,7 +790,7 @@
@@ -884,8 +815,6 @@
check_type_size (CACHE_ALL HAMLIB_OLD_CACHING)
check_symbol_exists (rig_set_cache_timeout_ms "hamlib/rig.h" HAVE_HAMLIB_CACHING)
-find_package (Portaudio REQUIRED)
-
find_package (Usb REQUIRED)
#
# libhamlib setup
#
-set (hamlib_STATIC 1)
+set (hamlib_STATIC 0)
find_package (hamlib 3 REQUIRED)
find_program (RIGCTL_EXE rigctl)
find_program (RIGCTLD_EXE rigctld)
@@ -895,9 +828,6 @@
@@ -1081,9 +1010,6 @@
if (WSJT_GENERATE_DOCS)
add_subdirectory (doc)
endif (WSJT_GENERATE_DOCS)
@ -111,11 +104,23 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- add_subdirectory (tests)
-endif ()
#
# Library building setup
@@ -1380,60 +1310,6 @@
target_link_libraries (jt9 wsjt_fort wsjt_cxx fort_qt)
endif (${OPENMP_FOUND} OR APPLE)
# build a library of package functionality (without and optionally with OpenMP support)
add_library (wsjt_cxx STATIC ${wsjt_CSRCS} ${wsjt_CXXSRCS})
@@ -1341,10 +1267,7 @@
add_library (wsjt_qt STATIC ${wsjt_qt_CXXSRCS} ${wsjt_qt_GENUISRCS} ${GENAXSRCS})
# set wsjtx_udp exports to static variants
target_compile_definitions (wsjt_qt PUBLIC UDP_STATIC_DEFINE)
-target_link_libraries (wsjt_qt Hamlib::Hamlib Boost::log qcp Qt5::Widgets Qt5::Network Qt5::Sql)
-if (WIN32)
- target_link_libraries (wsjt_qt Qt5::AxContainer Qt5::AxBase)
-endif (WIN32)
+target_link_libraries (wsjt_qt Qt5::Core)
# build a library of package Qt functionality used in Fortran utilities
add_library (fort_qt STATIC ${fort_qt_CXXSRCS})
@@ -1408,60 +1331,6 @@
add_subdirectory (map65)
endif ()
-# build the main application
-generate_version_info (wsjtx_VERSION_RESOURCES
@ -169,12 +174,12 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- )
- endif ()
-endif ()
-target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES})
-target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES})
-
# make a library for WSJT-X UDP servers
# add_library (wsjtx_udp SHARED ${UDP_library_CXXSRCS})
add_library (wsjtx_udp-static STATIC ${UDP_library_CXXSRCS})
@@ -1473,47 +1349,9 @@
@@ -1501,47 +1370,9 @@
add_executable (wsjtx_app_version AppVersion/AppVersion.cpp ${wsjtx_app_version_VERSION_RESOURCES})
target_link_libraries (wsjtx_app_version wsjt_qt)
@ -222,7 +227,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
# install (TARGETS wsjtx_udp EXPORT udp
# RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
@@ -1532,12 +1370,7 @@
@@ -1560,12 +1391,7 @@
# DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx
# )
@ -236,7 +241,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime
BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime
)
@@ -1549,38 +1382,6 @@
@@ -1578,38 +1404,6 @@
)
endif(WSJT_BUILD_UTILS)
@ -275,7 +280,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
install (FILES
cty.dat
cty.dat_copyright.txt
@@ -1589,13 +1390,6 @@
@@ -1618,13 +1412,6 @@
#COMPONENT runtime
)
@ -289,10 +294,11 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
#
# Mac installer files
#
@@ -1648,22 +1442,6 @@
@@ -1676,22 +1463,6 @@
"${CMAKE_CURRENT_BINARY_DIR}/wsjtx_config.h"
)
-
-if (NOT WIN32 AND NOT APPLE)
- # install a desktop file so wsjtx appears in the application start
- # menu with an icon
@ -308,9 +314,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- )
-endif (NOT WIN32 AND NOT APPLE)
-
-
#
# bundle fixup only done in non-Debug configurations
#
Only in wsjtx/: CMakeLists.txt.orig
Only in wsjtx/: .idea
if (APPLE)
set (CMAKE_POSTFLIGHT_SCRIPT
"${wsjtx_BINARY_DIR}/postflight.sh")
Only in wsjtx: .idea

View File

@ -29,7 +29,7 @@ tar xfz $PACKAGE
git clone https://github.com/jancona/hpsdrconnector.git
pushd hpsdrconnector
git checkout v0.4.2
git checkout v0.6.0
/tmp/go/bin/go build
install -m 0755 hpsdrconnector /usr/local/bin

View File

@ -18,7 +18,7 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="sox libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libqt5gui5 libqt5sql5 libqt5printsupport5 libpulse0 libfaad2 libopus0 libboost-program-options1.67.0 libboost-log1.67.0"
STATIC_PACKAGES="libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libpulse0 libfaad2 libopus0 libboost-program-options1.67.0 libboost-log1.67.0"
BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-default libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev"
apt-get update
apt-get -y install auto-apt-proxy
@ -51,7 +51,7 @@ rm /js8call-hamlib.patch
CMAKE_ARGS="-D CMAKE_CXX_FLAGS=-DJS8_USE_HAMLIB_THREE" cmakebuild ${JS8CALL_DIR}
rm ${JS8CALL_TGZ}
WSJT_DIR=wsjtx-2.4.0
WSJT_DIR=wsjtx-2.5.0
WSJT_TGZ=${WSJT_DIR}.tgz
wget http://physics.princeton.edu/pulsar/k1jt/${WSJT_TGZ}
tar xfz ${WSJT_TGZ}

View File

@ -18,8 +18,8 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="libfftw3-bin libprotobuf17"
BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler"
STATIC_PACKAGES="libfftw3-bin libprotobuf17 libsamplerate0 libicu63"
BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler libsamplerate-dev libicu-dev libpython3-dev"
apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
@ -31,22 +31,34 @@ popd
rm -rf js8py
git clone https://github.com/jketterl/csdr.git
cd csdr
git checkout 0.17.0
autoreconf -i
./configure
make
make install
# latest develop as of 2021-09-22 (template fixes)
cmakebuild csdr 536f3b9eb7cfe5434e9a9f1e807c96115dc9ac10
git clone https://github.com/jketterl/pycsdr.git
cd pycsdr
# latest develop as of 2021-09-22 (first version)
git checkout 52da48a87ef97eb7d337f1b146db66ca453801e4
./setup.py install install_headers
cd ..
rm -rf csdr
rm -rf pycsdr
git clone https://github.com/jketterl/codecserver.git
mkdir -p /usr/local/etc/codecserver
cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver
cmakebuild codecserver 0.1.0
# latest develop as of 2021-09-24 (new parsing)
cmakebuild codecserver c51254323b32db5b169cdfc39e043eed6d613a77
git clone https://github.com/jketterl/digiham.git
cmakebuild digiham 0.5.0
# latest develop as of 2021-09-22 (post-merge)
cmakebuild digiham 62d2b4581025568263ae8c90d2450b65561b7ce8
git clone https://github.com/jketterl/pydigiham.git
cd pydigiham
# latest develop as of 2021-09-22 (split from digiham)
git checkout b0cc0c35d5ef2ae84c9bb1a02d56161d5bd5bf2f
./setup.py install
cd ..
rm -rf pydigiham
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean

View File

@ -115,7 +115,7 @@
<img class="directcall" src="static/gfx/openwebrx-directcall.svg">
<img class="groupcall" src="static/gfx/openwebrx-groupcall.svg">
</div>
<div class="openwebrx-dmr-id"></div>
<div class="openwebrx-dmr-id"><span class="location"></span><span class="dmr-id"></span></div>
<div class="openwebrx-dmr-name"></div>
<div class="openwebrx-dmr-target"></div>
<div class="mute">
@ -128,7 +128,7 @@
<img class="directcall" src="static/gfx/openwebrx-directcall.svg">
<img class="groupcall" src="static/gfx/openwebrx-groupcall.svg">
</div>
<div class="openwebrx-dmr-id"></div>
<div class="openwebrx-dmr-id"><span class="location"></span><span class="dmr-id"></span></div>
<div class="openwebrx-dmr-name"></div>
<div class="openwebrx-dmr-target"></div>
<div class="mute">

View File

@ -13,6 +13,8 @@ Filter.prototype.getLimits = function() {
max_bw = 100000;
} else if (this.demodulator.get_modulation() === 'drm') {
max_bw = 50000;
} else if (this.demodulator.get_modulation() === "freedv") {
max_bw = 4000;
} else {
max_bw = (audioEngine.getOutputRate() / 2) - 1;
}

View File

@ -89,7 +89,7 @@ DemodulatorPanel.prototype.setMode = function(requestedModulation) {
return;
}
if (!mode.isAvailable()) {
divlog('Modulation "' + mode.name + '" not supported. Please check requirements', true);
divlog('Modulation "' + mode.name + '" not supported. Please check the feature report', true);
return;
}

View File

@ -70,7 +70,7 @@ Js8Thread.prototype.getMessageDuration = function() {
Js8Thread.prototype.getMode = function() {
// we filter messages by mode, so the first one is as good as any
if (!this.messages.length) return;
return this.messages[0].mode;
return this.messages[0].js8mode;
};
Js8Thread.prototype.acceptsMode = function(mode) {
@ -117,6 +117,10 @@ Js8Threader = function(el){
Js8Threader.prototype = new MessagePanel();
Js8Threader.prototype.supportsMessage = function(message) {
return message['mode'] === 'JS8';
};
Js8Threader.prototype.render = function() {
$(this.el).append($(
'<table>' +
@ -158,7 +162,7 @@ Js8Threader.prototype.pushMessage = function(message) {
var thread;
// only look for exising threads if the message is not a starting message
if ((message.thread_type & 1) === 0) {
thread = this.findThread(message.freq, message.mode);
thread = this.findThread(message.freq, message.js8mode);
}
if (!thread) {
var line = $("<tr></tr>");

View File

@ -4,6 +4,10 @@ function MessagePanel(el) {
this.initClearButton();
}
MessagePanel.prototype.supportsMessage = function(message) {
return false;
};
MessagePanel.prototype.render = function() {
};
@ -46,10 +50,17 @@ MessagePanel.prototype.initClearButton = function() {
function WsjtMessagePanel(el) {
MessagePanel.call(this, el);
this.initClearTimer();
this.qsoModes = ['FT8', 'JT65', 'JT9', 'FT4', 'FST4', 'Q65'];
this.beaconModes = ['WSPR', 'FST4W'];
this.modes = [].concat(this.qsoModes, this.beaconModes);
}
WsjtMessagePanel.prototype = new MessagePanel();
WsjtMessagePanel.prototype.supportsMessage = function(message) {
return this.modes.indexOf(message['mode']) >= 0;
};
WsjtMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
@ -78,14 +89,14 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
return $('<div/>').text(input).html()
};
if (['FT8', 'JT65', 'JT9', 'FT4', 'FST4', 'Q65'].indexOf(msg['mode']) >= 0) {
if (this.qsoModes.indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/);
if (matches && matches[2] !== 'RR73') {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>';
} else {
linkedmsg = html_escape(linkedmsg);
}
} else if (['WSPR', 'FST4W'].indexOf(msg['mode']) >= 0) {
} else if (this.beaconModes.indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/);
if (matches) {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>' + html_escape(matches[3]);
@ -108,7 +119,7 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
$.fn.wsjtMessagePanel = function(){
if (!this.data('panel')) {
this.data('panel', new WsjtMessagePanel(this));
};
}
return this.data('panel');
};
@ -119,6 +130,10 @@ function PacketMessagePanel(el) {
PacketMessagePanel.prototype = new MessagePanel();
PacketMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'APRS';
};
PacketMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
@ -206,7 +221,7 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
$.fn.packetMessagePanel = function() {
if (!this.data('panel')) {
this.data('panel', new PacketMessagePanel(this));
};
}
return this.data('panel');
};
@ -217,6 +232,10 @@ PocsagMessagePanel = function(el) {
PocsagMessagePanel.prototype = new MessagePanel();
PocsagMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'Pocsag';
};
PocsagMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
@ -243,6 +262,6 @@ PocsagMessagePanel.prototype.pushMessage = function(msg) {
$.fn.pocsagMessagePanel = function() {
if (!this.data('panel')) {
this.data('panel', new PocsagMessagePanel(this));
};
}
return this.data('panel');
};

View File

@ -22,20 +22,31 @@ function DmrMetaSlot(el) {
DmrMetaSlot.prototype.update = function(data) {
this.el[data['sync'] ? "addClass" : "removeClass"]("sync");
if (data['sync'] && data['sync'] === "voice") {
this.setId(data['additional'] && data['additional']['callsign'] || data['source']);
this.setId(data['additional'] && data['additional']['callsign'] || data['talkeralias'] || data['source']);
this.setName(data['additional'] && data['additional']['fname']);
this.setMode(['group', 'direct'].includes(data['type']) ? data['type'] : undefined);
this.setTarget(data['target']);
this.setLocation(data['lat'], data['lon'], this.getCallsign(data));
this.el.addClass("active");
} else {
this.clear();
}
};
DmrMetaSlot.prototype.getCallsign = function(data) {
if ('additional' in data) {
return data['additional']['callsign'];
}
if ('talkeralias' in data) {
var matches = /^([A-Z0-9]+)(\s.*)?$/.exec(data['talkeralias']);
if (matches) return matches[1];
}
};
DmrMetaSlot.prototype.setId = function(id) {
if (this.id === id) return;
this.id = id;
this.el.find('.openwebrx-dmr-id').text(id || '');
this.el.find('.openwebrx-dmr-id .dmr-id').text(id || '');
}
DmrMetaSlot.prototype.setName = function(name) {
@ -59,11 +70,23 @@ DmrMetaSlot.prototype.setTarget = function(target) {
this.el.find('.openwebrx-dmr-target').text(target || '');
}
DmrMetaSlot.prototype.setLocation = function(lat, lon, callsign) {
var hasLocation = lat && lon && callsign && callsign != '';
if (hasLocation === this.hasLocation && this.callsign === callsign) return;
this.hasLocation = hasLocation; this.callsign = callsign;
var html = '';
if (hasLocation) {
html = '<a class="openwebrx-maps-pin" href="map?callsign=' + encodeURIComponent(callsign) + '" target="_blank"><svg viewBox="0 0 20 35"><use xlink:href="static/gfx/svg-defs.svg#maps-pin"></use></svg></a>';
}
this.el.find('.openwebrx-dmr-id .location').html(html);
}
DmrMetaSlot.prototype.clear = function() {
this.setId();
this.setName();
this.setMode();
this.setTarget();
this.setLocation();
this.el.removeClass("active");
};
@ -112,7 +135,9 @@ YsfMetaPanel.prototype.update = function(data) {
this.setLocation(data['lat'], data['lon'], data['source']);
this.setUp(data['up']);
this.setDown(data['down']);
if (data['mode'].indexOf('data') < 0) {
this.el.find(".openwebrx-meta-slot").addClass("active");
}
} else {
this.clear();
}
@ -248,7 +273,7 @@ NxdnMetaPanel.prototype = new MetaPanel();
NxdnMetaPanel.prototype.update = function(data) {
if (!this.isSupported(data)) return;
if (data['sync'] && data['sync'] == 'voice') {
if (data['sync'] && data['sync'] === 'voice') {
this.el.find(".openwebrx-meta-slot").addClass("active");
this.setSource(data['additional'] && data['additional']['callsign'] || data['source']);
this.setName(data['additional'] && data['additional']['fname']);

View File

@ -779,13 +779,10 @@ function on_ws_recv(evt) {
break;
case "secondary_config":
var s = json['value'];
if ('secondary_fft_size' in s)
window.secondary_fft_size = s['secondary_fft_size'];
if ('secondary_bw' in s)
window.secondary_bw = s['secondary_bw'];
if ('if_samp_rate' in s)
window.if_samp_rate = s['if_samp_rate'];
secondary_demod_init_canvases();
secondary_fft_size = s['secondary_fft_size'] || secondary_fft_size;
secondary_bw = s['secondary_bw'] || secondary_bw;
if_samp_rate = s['if_samp_rate'] || if_samp_rate;
if (if_samp_rate) secondary_demod_init_canvases();
break;
case "receiver_details":
$('.webrx-top-container').header().setDetails(json['value']);
@ -821,12 +818,6 @@ function on_ws_recv(evt) {
this.update(json['value']);
});
break;
case "js8_message":
$("#openwebrx-panel-js8-message").js8().pushMessage(json['value']);
break;
case "wsjt_message":
$("#openwebrx-panel-wsjt-message").wsjtMessagePanel().pushMessage(json['value']);
break;
case "dial_frequencies":
var as_bookmarks = json['value'].map(function (d) {
return {
@ -837,9 +828,6 @@ function on_ws_recv(evt) {
});
bookmarks.replace_bookmarks(as_bookmarks, 'dial_frequencies');
break;
case "aprs_data":
$('#openwebrx-panel-packet-message').packetMessagePanel().pushMessage(json['value']);
break;
case "bookmarks":
bookmarks.replace_bookmarks(json['value'], "server");
break;
@ -851,14 +839,24 @@ function on_ws_recv(evt) {
$("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator();
break;
case 'secondary_demod':
secondary_demod_push_data(json['value']);
var value = json['value'];
var panels = [
$("#openwebrx-panel-wsjt-message").wsjtMessagePanel(),
$('#openwebrx-panel-packet-message').packetMessagePanel(),
$('#openwebrx-panel-pocsag-message').pocsagMessagePanel(),
$("#openwebrx-panel-js8-message").js8()
];
if (!panels.some(function(panel) {
if (!panel.supportsMessage(value)) return false;
panel.pushMessage(value);
return true;
})) {
secondary_demod_push_data(value);
}
break;
case 'log_message':
divlog(json['value'], true);
break;
case 'pocsag_data':
$('#openwebrx-panel-pocsag-message').pocsagMessagePanel().pushMessage(json['value']);
break;
case 'backoff':
divlog("Server is currently busy: " + json['reason'], true);
var $overlay = $('#openwebrx-error-overlay');
@ -1265,6 +1263,9 @@ function digimodes_init() {
$('.openwebrx-dmr-timeslot-panel').click(function (e) {
$(e.currentTarget).toggleClass("muted");
update_dmr_timeslot_filtering();
// don't mute when the location icon is clicked
}).find('.location').click(function(e) {
e.stopPropagation();
});
$('.openwebrx-meta-panel').metaPanel();
@ -1387,6 +1388,8 @@ var secondary_demod_current_canvas_actual_line;
var secondary_demod_current_canvas_context;
var secondary_demod_current_canvas_index;
var secondary_demod_canvases;
var secondary_bw = 31.25;
var if_samp_rate;
function secondary_demod_create_canvas() {
var new_canvas = document.createElement("canvas");

View File

@ -111,5 +111,6 @@ Support and info: https://groups.io/g/openwebrx
WebSocketConnection.closeAll()
Services.stop()
SdrService.stopAllSources()
ReportingEngine.stopAll()
DecoderQueue.stopAll()

View File

@ -1,9 +1,8 @@
from owrx.kiss import KissDeframer
from owrx.map import Map, LatLngLocation
from owrx.bands import Bandplan
from owrx.metrics import Metrics, CounterMetric
from owrx.parser import Parser
from owrx.bands import Bandplan
from datetime import datetime, timezone
from csdr.module import PickleModule
import re
import logging
@ -46,8 +45,8 @@ def getSymbolData(symbol, table):
return {"symbol": symbol, "table": table, "index": ord(symbol) - 33, "tableindex": ord(table) - 33}
class Ax25Parser(object):
def parse(self, ax25frame):
class Ax25Parser(PickleModule):
def process(self, ax25frame):
control_pid = ax25frame.find(bytes([0x03, 0xF0]))
if control_pid % 7 > 0:
logger.warning("aprs packet framing error: control/pid position not aligned with 7-octet callsign data")
@ -118,9 +117,9 @@ class WeatherParser(object):
WeatherMapping("s", "snowfall", 3, lambda x: x * 25.4),
]
def __init__(self, data, weather={}):
def __init__(self, data, weather=None):
self.data = data
self.weather = weather
self.weather = {} if weather is None else weather
def getWeather(self):
doWork = True
@ -152,16 +151,14 @@ class AprsLocation(LatLngLocation):
return res
class AprsParser(Parser):
def __init__(self, handler):
super().__init__(handler)
self.ax25parser = Ax25Parser()
self.deframer = KissDeframer()
class AprsParser(PickleModule):
def __init__(self):
super().__init__()
self.metrics = {}
self.band = None
def setDialFrequency(self, freq):
super().setDialFrequency(freq)
self.metrics = {}
self.band = Bandplan.getSharedInstance().findBand(freq)
def getMetric(self, category):
if category not in self.metrics:
@ -185,11 +182,8 @@ class AprsParser(Parser):
return False
return True
def parse(self, raw):
for frame in self.deframer.parse(raw):
def process(self, data):
try:
data = self.ax25parser.parse(frame)
# TODO how can we tell if this is an APRS frame at all?
aprsData = self.parseAprsData(data)
@ -198,7 +192,10 @@ class AprsParser(Parser):
self.getMetric("total").inc()
if self.isDirect(aprsData):
self.getMetric("direct").inc()
self.handler.write_aprs_data(aprsData)
# the frontend uses this to distinguis hessages from the different parsers
aprsData["mode"] = "APRS"
return aprsData
except Exception:
logger.exception("exception while parsing aprs data")

View File

@ -1,17 +1,12 @@
import socket
import time
import logging
import random
from owrx.config import Config
from abc import ABC, abstractmethod
import socket
import logging
logger = logging.getLogger(__name__)
FEND = 0xC0
FESC = 0xDB
TFEND = 0xDC
TFESC = 0xDD
FEET_PER_METER = 3.28084
@ -21,7 +16,7 @@ class DirewolfConfigSubscriber(ABC):
pass
class DirewolfConfig(object):
class DirewolfConfig:
config_keys = [
"aprs_callsign",
"aprs_igate_enabled",
@ -141,51 +136,3 @@ IGLOGIN {callsign} {password}
)
return config
class KissClient(object):
def __init__(self, port):
delay = 0.5
retries = 0
while True:
try:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.connect(("localhost", port))
break
except ConnectionError:
if retries > 20:
logger.error("maximum number of connection attempts reached. did direwolf start up correctly?")
raise
retries += 1
time.sleep(delay)
def read(self):
return self.socket.recv(1)
class KissDeframer(object):
def __init__(self):
self.escaped = False
self.buf = bytearray()
def parse(self, input):
frames = []
for b in input:
if b == FESC:
self.escaped = True
elif self.escaped:
if b == TFEND:
self.buf.append(FEND)
elif b == TFESC:
self.buf.append(FESC)
else:
logger.warning("invalid escape char: %s", str(input[0]))
self.escaped = False
elif input[0] == FEND:
# data frames start with 0x00
if len(self.buf) > 1 and self.buf[0] == 0x00:
frames += [self.buf[1:]]
self.buf = bytearray()
else:
self.buf.append(b)
return frames

54
owrx/aprs/kiss.py Normal file
View File

@ -0,0 +1,54 @@
from pycsdr.types import Format
from csdr.module import ThreadModule
import pickle
import logging
logger = logging.getLogger(__name__)
FEND = 0xC0
FESC = 0xDB
TFEND = 0xDC
TFESC = 0xDD
class KissDeframer(ThreadModule):
def __init__(self):
self.escaped = False
self.buf = bytearray()
super().__init__()
def getInputFormat(self) -> Format:
return Format.CHAR
def getOutputFormat(self) -> Format:
return Format.CHAR
def run(self):
while self.doRun:
data = self.reader.read()
if data is None:
self.doRun = False
else:
for frame in self.parse(data):
self.writer.write(pickle.dumps(frame))
def parse(self, input):
for b in input:
if b == FESC:
self.escaped = True
elif self.escaped:
if b == TFEND:
self.buf.append(FEND)
elif b == TFESC:
self.buf.append(FESC)
else:
logger.warning("invalid escape char: %s", str(input[0]))
self.escaped = False
elif b == FEND:
# data frames start with 0x00
if len(self.buf) > 1 and self.buf[0] == 0x00:
yield self.buf[1:]
self.buf = bytearray()
else:
self.buf.append(b)

81
owrx/aprs/module.py Normal file
View File

@ -0,0 +1,81 @@
from csdr.module import AutoStartModule
from pycsdr.types import Format
from pycsdr.modules import Writer, TcpSource
from subprocess import Popen, PIPE
from owrx.aprs.direwolf import DirewolfConfig, DirewolfConfigSubscriber
from owrx.config.core import CoreConfig
import threading
import time
import os
import logging
logger = logging.getLogger(__name__)
class DirewolfModule(AutoStartModule, DirewolfConfigSubscriber):
def __init__(self, service: bool = False):
self.process = None
self.tcpSource = None
self.service = service
self.direwolfConfigPath = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format(
tmp_dir=CoreConfig().get_temporary_directory(), myid=id(self)
)
self.direwolfConfig = None
super().__init__()
def setWriter(self, writer: Writer) -> None:
super().setWriter(writer)
if self.tcpSource is not None:
self.tcpSource.setWriter(writer)
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.CHAR
def start(self):
self.direwolfConfig = DirewolfConfig()
self.direwolfConfig.wire(self)
file = open(self.direwolfConfigPath, "w")
file.write(self.direwolfConfig.getConfig(self.service))
file.close()
# direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h 1>&2
self.process = Popen(
["direwolf", "-c", self.direwolfConfigPath, "-r", "48000", "-t", "0", "-q", "d", "-q", "h"],
start_new_session=True,
stdin=PIPE,
)
threading.Thread(target=self.pump(self.reader.read, self.process.stdin.write)).start()
delay = 0.5
retries = 0
while True:
try:
self.tcpSource = TcpSource(self.direwolfConfig.getPort(), Format.CHAR)
if self.writer:
self.tcpSource.setWriter(self.writer)
break
except ConnectionError:
if retries > 20:
logger.error("maximum number of connection attempts reached. did direwolf start up correctly?")
raise
retries += 1
time.sleep(delay)
def stop(self):
if self.process is not None:
self.process.terminate()
self.process.wait()
self.process = None
os.unlink(self.direwolfConfigPath)
self.direwolfConfig.unwire(self)
self.direwolfConfig = None
self.reader.stop()
def onConfigChanged(self):
self.stop()
self.start()

View File

@ -1,10 +1,13 @@
from owrx.modes import Modes, AudioChopperMode
from csdr.output import Output
from owrx.audio import AudioChopperProfile
from itertools import groupby
import threading
from owrx.audio import ProfileSourceSubscriber
from owrx.audio.wav import AudioWriter
from multiprocessing.connection import Pipe
from owrx.audio.queue import QueueJob
from csdr.module import ThreadModule
from pycsdr.types import Format
from abc import ABC, abstractmethod
import pickle
import logging
@ -12,19 +15,30 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
def __init__(self, active_dsp, mode_str: str):
self.read_fn = None
class AudioChopperParser(ABC):
@abstractmethod
def parse(self, profile: AudioChopperProfile, frequency: int, line: bytes):
pass
class AudioChopper(ThreadModule, ProfileSourceSubscriber):
def __init__(self, mode_str: str, parser: AudioChopperParser):
self.parser = parser
self.dialFrequency = None
self.doRun = True
self.dsp = active_dsp
self.writers = []
mode = Modes.findByModulation(mode_str)
if mode is None or not isinstance(mode, AudioChopperMode):
raise ValueError("Mode {} is not an audio chopper mode".format(mode_str))
self.profile_source = mode.get_profile_source()
(self.outputReader, self.outputWriter) = Pipe()
super().__init__()
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.CHAR
def stop_writers(self):
while self.writers:
self.writers.pop().stop()
@ -34,19 +48,12 @@ class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
sorted_profiles = sorted(self.profile_source.getProfiles(), key=lambda p: p.getInterval())
groups = {interval: list(group) for interval, group in groupby(sorted_profiles, key=lambda p: p.getInterval())}
writers = [
AudioWriter(self.dsp, self.outputWriter, interval, profiles) for interval, profiles in groups.items()
AudioWriter(self, interval, profiles) for interval, profiles in groups.items()
]
for w in writers:
w.start()
self.writers = writers
def supports_type(self, t):
return t == "audio"
def receive_output(self, t, read_fn):
self.read_fn = read_fn
self.start()
def run(self) -> None:
logger.debug("Audio chopper starting up")
self.setup_writers()
@ -54,37 +61,31 @@ class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
while self.doRun:
data = None
try:
data = self.read_fn(256)
data = self.reader.read()
except ValueError:
pass
if data is None or (isinstance(data, bytes) and len(data) == 0):
if data is None:
self.doRun = False
else:
for w in self.writers:
w.write(data)
w.write(data.tobytes())
logger.debug("Audio chopper shutting down")
self.profile_source.unsubscribe(self)
self.stop_writers()
self.outputWriter.close()
self.outputWriter = None
# drain messages left in the queue so that the queue can be successfully closed
# this is necessary since python keeps the file descriptors open otherwise
try:
while True:
self.outputReader.recv()
except EOFError:
pass
self.outputReader.close()
self.outputReader = None
def onProfilesChanged(self):
logger.debug("profile change received, resetting writers...")
self.setup_writers()
def read(self):
try:
return self.outputReader.recv()
except (EOFError, OSError):
return None
def setDialFrequency(self, frequency: int) -> None:
self.dialFrequency = frequency
def createJob(self, profile, filename):
return QueueJob(profile, self.dialFrequency, self, filename)
def sendResult(self, result):
for line in result.lines:
data = self.parser.parse(result.profile, result.frequency, line)
if data is not None and self.writer is not None:
self.writer.write(pickle.dumps(data))

View File

@ -12,12 +12,19 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class QueueJob(object):
def __init__(self, profile, writer, file, freq):
class QueueJobResult:
def __init__(self, profile, frequency, lines):
self.profile = profile
self.frequency = frequency
self.lines = lines
class QueueJob(object):
def __init__(self, profile, frequency, writer, file):
self.profile = profile
self.frequency = frequency
self.writer = writer
self.file = file
self.freq = freq
def run(self):
logger.debug("processing file %s", self.file)
@ -28,13 +35,18 @@ class QueueJob(object):
cwd=tmp_dir,
close_fds=True,
)
lines = None
try:
for line in decoder.stdout:
self.writer.send((self.profile, self.freq, line))
except (OSError, AttributeError):
lines = [l for l in decoder.stdout]
except OSError:
decoder.stdout.flush()
# TODO uncouple parsing from the output so that decodes can still go to the map and the spotters
logger.debug("output has gone away while decoding job.")
# keep this out of the try/except
if lines is not None:
self.writer.sendResult(QueueJobResult(self.profile, self.frequency, lines))
try:
rc = decoder.wait(timeout=10)
if rc != 0:

View File

@ -1,6 +1,6 @@
from owrx.config.core import CoreConfig
from owrx.audio import AudioChopperProfile
from owrx.audio.queue import QueueJob, DecoderQueue
from owrx.audio.queue import DecoderQueue
import threading
import wave
import os
@ -47,9 +47,8 @@ class WaveFile(object):
class AudioWriter(object):
def __init__(self, active_dsp, outputWriter, interval, profiles: List[AudioChopperProfile]):
self.dsp = active_dsp
self.outputWriter = outputWriter
def __init__(self, chopper, interval, profiles: List[AudioChopperProfile]):
self.chopper = chopper
self.interval = interval
self.profiles = profiles
self.wavefile = None
@ -102,7 +101,7 @@ class AudioWriter(object):
logger.exception("Error while linking job files")
continue
job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq())
job = self.chopper.createJob(profile, filename)
try:
DecoderQueue.getSharedInstance().put(job)
except Full:

View File

@ -15,10 +15,10 @@ from owrx.config import Config
from owrx.waterfall import WaterfallOptions
from owrx.websocket import Handler
from queue import Queue, Full, Empty
from js8py import Js8Frame
from abc import ABCMeta, abstractmethod
import json
import threading
import struct
import logging
@ -376,6 +376,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
self.send(bytes([0x04]) + data)
def write_s_meter_level(self, level):
# may contain more than one sample, so only take the last 4 bytes = 1 float
level, = struct.unpack('f', level[-4:])
try:
self.send({"type": "smeter", "value": level})
except ValueError:
@ -390,8 +392,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
def write_secondary_fft(self, data):
self.send(bytes([0x03]) + data)
def write_secondary_demod(self, data):
message = data.decode("ascii", "replace")
def write_secondary_demod(self, message):
self.send({"type": "secondary_demod", "value": message})
def write_secondary_dsp_config(self, cfg):
@ -409,46 +410,21 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
def write_metadata(self, metadata):
self.send({"type": "metadata", "value": metadata})
def write_wsjt_message(self, message):
self.send({"type": "wsjt_message", "value": message})
def write_dial_frequencies(self, frequencies):
self.send({"type": "dial_frequencies", "value": frequencies})
def write_bookmarks(self, bookmarks):
self.send({"type": "bookmarks", "value": bookmarks})
def write_aprs_data(self, data):
self.send({"type": "aprs_data", "value": data})
def write_log_message(self, message):
self.send({"type": "log_message", "value": message})
def write_sdr_error(self, message):
self.send({"type": "sdr_error", "value": message})
def write_pocsag_data(self, data):
self.send({"type": "pocsag_data", "value": data})
def write_backoff_message(self, reason):
self.send({"type": "backoff", "reason": reason})
def write_js8_message(self, frame: Js8Frame, freq: int):
self.send(
{
"type": "js8_message",
"value": {
"msg": str(frame),
"timestamp": frame.timestamp,
"db": frame.db,
"dt": frame.dt,
"freq": freq + frame.freq,
"thread_type": frame.thread_type,
"mode": frame.mode,
},
}
)
def write_modes(self, modes):
def to_json(m):
res = {

View File

@ -1,23 +1,363 @@
from owrx.meta import MetaParser
from owrx.wsjt import WsjtParser
from owrx.js8 import Js8Parser
from owrx.aprs import AprsParser
from owrx.pocsag import PocsagParser
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack, PropertyLayer, PropertyValidator
from owrx.property.validators import OrValidator, RegexValidator, BoolValidator
from owrx.modes import Modes
from owrx.config.core import CoreConfig
from csdr.output import Output
from csdr import Dsp
from csdr.chain import Chain
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, SecondaryDemodulator, DialFrequencyReceiver, MetaProvider, SlotFilterChain, SecondarySelectorChain, DeemphasisTauChain
from csdr.chain.selector import Selector, SecondarySelector
from csdr.chain.clientaudio import ClientAudioChain
from csdr.chain.fft import FftChain
from pycsdr.modules import Buffer, Writer
from pycsdr.types import Format
from typing import Union, Optional
from io import BytesIO
from abc import ABC, abstractmethod
import threading
import re
import pickle
import logging
logger = logging.getLogger(__name__)
# now that's a name. help, i've reached enterprise level OOP here
class ClientDemodulatorSecondaryDspEventClient(ABC):
@abstractmethod
def onSecondaryDspRateChange(self, rate):
pass
@abstractmethod
def onSecondaryDspBandwidthChange(self, bw):
pass
class ClientDemodulatorChain(Chain):
def __init__(self, demod: BaseDemodulatorChain, sampleRate: int, outputRate: int, hdOutputRate: int, audioCompression: str, secondaryDspEventReceiver: ClientDemodulatorSecondaryDspEventClient):
self.sampleRate = sampleRate
self.outputRate = outputRate
self.hdOutputRate = hdOutputRate
self.secondaryDspEventReceiver = secondaryDspEventReceiver
self.selector = Selector(sampleRate, outputRate)
self.selector.setBandpass(-4000, 4000)
self.selectorBuffer = Buffer(Format.COMPLEX_FLOAT)
self.audioBuffer = None
self.demodulator = demod
self.secondaryDemodulator = None
self.centerFrequency = None
self.frequencyOffset = None
self.wfmDeemphasisTau = 50e-6
inputRate = demod.getFixedAudioRate() if isinstance(demod, FixedAudioRateChain) else outputRate
oRate = hdOutputRate if isinstance(demod, HdAudio) else outputRate
self.clientAudioChain = ClientAudioChain(demod.getOutputFormat(), inputRate, oRate, audioCompression)
self.secondaryFftSize = 2048
self.secondaryFftOverlapFactor = 0.3
self.secondaryFftFps = 9
self.secondaryFftCompression = "adpcm"
self.secondaryFftChain = None
self.metaWriter = None
self.secondaryFftWriter = None
self.secondaryWriter = None
self.squelchLevel = -150
self.secondarySelector = None
self.secondaryFrequencyOffset = None
super().__init__([self.selector, self.demodulator, self.clientAudioChain])
def stop(self):
super().stop()
if self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = None
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.stop()
self.secondaryDemodulator = None
def _connect(self, w1, w2, buffer: Optional[Buffer] = None) -> None:
if w1 is self.selector:
super()._connect(w1, w2, self.selectorBuffer)
elif w2 is self.clientAudioChain:
format = w1.getOutputFormat()
if self.audioBuffer is None or self.audioBuffer.getFormat() != format:
self.audioBuffer = Buffer(format)
if self.secondaryDemodulator is not None and self.secondaryDemodulator.getInputFormat() is not Format.COMPLEX_FLOAT:
self.secondaryDemodulator.setReader(self.audioBuffer.getReader())
super()._connect(w1, w2, self.audioBuffer)
else:
super()._connect(w1, w2)
def setDemodulator(self, demodulator: BaseDemodulatorChain):
if demodulator is self.demodulator:
return
try:
self.clientAudioChain.setFormat(demodulator.getOutputFormat())
except ValueError:
# this will happen if the new format does not match the current demodulator.
# it's expected and should be mended when swapping out the demodulator in the next step
pass
self.replace(1, demodulator)
if self.demodulator is not None:
self.demodulator.stop()
self.demodulator = demodulator
outputRate = self.hdOutputRate if isinstance(self.demodulator, HdAudio) else self.outputRate
if isinstance(self.demodulator, FixedIfSampleRateChain):
self.selector.setOutputRate(self.demodulator.getFixedIfSampleRate())
elif isinstance(self.secondaryDemodulator, FixedAudioRateChain):
self.selector.setOutputRate(self.secondaryDemodulator.getFixedAudioRate())
else:
self.selector.setOutputRate(outputRate)
self.demodulator.setSampleRate(outputRate)
if isinstance(self.demodulator, FixedAudioRateChain):
self.clientAudioChain.setInputRate(self.demodulator.getFixedAudioRate())
elif isinstance(self.secondaryDemodulator, FixedAudioRateChain):
self.clientAudioChain.setInputRate(self.secondaryDemodulator.getFixedAudioRate())
else:
self.clientAudioChain.setInputRate(outputRate)
if isinstance(self.demodulator, DeemphasisTauChain):
self.demodulator.setDeemphasisTau(self.wfmDeemphasisTau)
self._updateDialFrequency()
self._syncSquelch()
self.clientAudioChain.setClientRate(outputRate)
if self.metaWriter is not None and isinstance(demodulator, MetaProvider):
demodulator.setMetaWriter(self.metaWriter)
def _getSelectorOutputRate(self):
if isinstance(self.secondaryDemodulator, FixedAudioRateChain):
if isinstance(self.demodulator, FixedAudioRateChain) and self.demodulator.getFixedAudioRate() != self.secondaryDemodulator.getFixedAudioRate():
raise ValueError("secondary and primary demodulator chain audio rates do not match!")
return self.secondaryDemodulator.getFixedAudioRate()
return self.outputRate
def setSecondaryDemodulator(self, demod: Optional[SecondaryDemodulator]):
if demod is self.secondaryDemodulator:
return
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.stop()
self.secondaryDemodulator = demod
rate = self._getSelectorOutputRate()
self.selector.setOutputRate(rate)
self.clientAudioChain.setInputRate(rate)
self.demodulator.setSampleRate(rate)
self._updateDialFrequency()
self._syncSquelch()
if isinstance(self.secondaryDemodulator, SecondarySelectorChain):
bandwidth = self.secondaryDemodulator.getBandwidth()
self.secondarySelector = SecondarySelector(rate, bandwidth)
self.secondarySelector.setReader(self.selectorBuffer.getReader())
self.secondarySelector.setFrequencyOffset(self.secondaryFrequencyOffset)
self.secondaryDspEventReceiver.onSecondaryDspBandwidthChange(bandwidth)
else:
self.secondarySelector = None
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.setSampleRate(rate)
if self.secondarySelector is not None:
buffer = Buffer(Format.COMPLEX_FLOAT)
self.secondarySelector.setWriter(buffer)
self.secondaryDemodulator.setReader(buffer.getReader())
elif self.secondaryDemodulator.getInputFormat() is Format.COMPLEX_FLOAT:
self.secondaryDemodulator.setReader(self.selectorBuffer.getReader())
else:
self.secondaryDemodulator.setReader(self.audioBuffer.getReader())
self.secondaryDemodulator.setWriter(self.secondaryWriter)
if self.secondaryDemodulator is None and self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = None
if self.secondaryDemodulator is not None and self.secondaryFftChain is None:
self._createSecondaryFftChain()
if self.secondaryFftChain is not None:
self.secondaryFftChain.setSampleRate(rate)
self.secondaryDspEventReceiver.onSecondaryDspRateChange(rate)
def _createSecondaryFftChain(self):
if self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = FftChain(self._getSelectorOutputRate(), self.secondaryFftSize, self.secondaryFftOverlapFactor, self.secondaryFftFps, self.secondaryFftCompression)
self.secondaryFftChain.setReader(self.selectorBuffer.getReader())
self.secondaryFftChain.setWriter(self.secondaryFftWriter)
def _syncSquelch(self):
if not self.demodulator.supportsSquelch() or (self.secondaryDemodulator is not None and not self.secondaryDemodulator.supportsSquelch()):
self.selector.setSquelchLevel(-150)
else:
self.selector.setSquelchLevel(self.squelchLevel)
def setLowCut(self, lowCut):
self.selector.setLowCut(lowCut)
def setHighCut(self, highCut):
self.selector.setHighCut(highCut)
def setBandpass(self, lowCut, highCut):
self.selector.setBandpass(lowCut, highCut)
def setFrequencyOffset(self, offset: int) -> None:
if offset == self.frequencyOffset:
return
self.frequencyOffset = offset
self.selector.setFrequencyOffset(offset)
self._updateDialFrequency()
def setCenterFrequency(self, frequency: int) -> None:
if frequency == self.centerFrequency:
return
self.centerFrequency = frequency
self._updateDialFrequency()
def _updateDialFrequency(self):
if self.centerFrequency is None or self.frequencyOffset is None:
return
dialFrequency = self.centerFrequency + self.frequencyOffset
if isinstance(self.demodulator, DialFrequencyReceiver):
self.demodulator.setDialFrequency(dialFrequency)
if isinstance(self.secondaryDemodulator, DialFrequencyReceiver):
self.secondaryDemodulator.setDialFrequency(dialFrequency)
def setAudioCompression(self, compression: str) -> None:
self.clientAudioChain.setAudioCompression(compression)
def setSquelchLevel(self, level: float) -> None:
if level == self.squelchLevel:
return
self.squelchLevel = level
self._syncSquelch()
def setOutputRate(self, outputRate) -> None:
if outputRate == self.outputRate:
return
self.outputRate = outputRate
if isinstance(self.demodulator, HdAudio):
return
self._updateDemodulatorOutputRate(outputRate)
def setHdOutputRate(self, outputRate) -> None:
if outputRate == self.hdOutputRate:
return
self.hdOutputRate = outputRate
if not isinstance(self.demodulator, HdAudio):
return
self._updateDemodulatorOutputRate(outputRate)
def _updateDemodulatorOutputRate(self, outputRate):
if not isinstance(self.demodulator, FixedIfSampleRateChain):
self.selector.setOutputRate(outputRate)
self.demodulator.setSampleRate(outputRate)
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.setSampleRate(outputRate)
if not isinstance(self.demodulator, FixedAudioRateChain):
self.clientAudioChain.setClientRate(outputRate)
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
self.selector.setInputRate(sampleRate)
def setPowerWriter(self, writer: Writer) -> None:
self.selector.setPowerWriter(writer)
def setMetaWriter(self, writer: Writer) -> None:
if writer is self.metaWriter:
return
self.metaWriter = writer
if isinstance(self.demodulator, MetaProvider):
self.demodulator.setMetaWriter(self.metaWriter)
def setSecondaryFftWriter(self, writer: Writer) -> None:
if writer is self.secondaryFftWriter:
return
self.secondaryFftWriter = writer
if self.secondaryFftChain is not None:
self.secondaryFftChain.setWriter(writer)
def setSecondaryWriter(self, writer: Writer) -> None:
if writer is self.secondaryWriter:
return
self.secondaryWriter = writer
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.setWriter(writer)
def setSlotFilter(self, filter: int) -> None:
if not isinstance(self.demodulator, SlotFilterChain):
return
self.demodulator.setSlotFilter(filter)
def setSecondaryFftSize(self, size: int) -> None:
if size == self.secondaryFftSize:
return
self.secondaryFftSize = size
if not self.secondaryFftChain:
return
self._createSecondaryFftChain()
def setSecondaryFrequencyOffset(self, freq: int) -> None:
if self.secondaryFrequencyOffset == freq:
return
self.secondaryFrequencyOffset = freq
if self.secondarySelector is None:
return
self.secondarySelector.setFrequencyOffset(self.secondaryFrequencyOffset)
def setSecondaryFftCompression(self, compression: str) -> None:
if compression == self.secondaryFftCompression:
return
self.secondaryFftCompression = compression
if not self.secondaryFftChain:
return
self.secondaryFftChain.setCompression(self.secondaryFftCompression)
def setSecondaryFftOverlapFactor(self, overlap: float) -> None:
if overlap == self.secondaryFftOverlapFactor:
return
self.secondaryFftOverlapFactor = overlap
if not self.secondaryFftChain:
return
self.secondaryFftChain.setVOverlapFactor(self.secondaryFftOverlapFactor)
def setSecondaryFftFps(self, fps: int) -> None:
if fps == self.secondaryFftFps:
return
self.secondaryFftFps = fps
if not self.secondaryFftChain:
return
self.secondaryFftChain.setFps(self.secondaryFftFps)
def getSecondaryFftOutputFormat(self) -> Format:
if self.secondaryFftCompression == "adpcm":
return Format.CHAR
return Format.SHORT
def setWfmDeemphasisTau(self, tau: float) -> None:
if tau == self.wfmDeemphasisTau:
return
self.wfmDeemphasisTau = tau
if isinstance(self.demodulator, DeemphasisTauChain):
self.demodulator.setDeemphasisTau(self.wfmDeemphasisTau)
class ModulationValidator(OrValidator):
"""
This validator only allows alphanumeric characters and numbers, but no spaces or special characters
@ -27,20 +367,16 @@ class ModulationValidator(OrValidator):
super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$")))
class DspManager(Output, SdrSourceEventClient):
class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient):
def __init__(self, handler, sdrSource):
self.handler = handler
self.sdrSource = sdrSource
self.parsers = {
"meta": MetaParser(self.handler),
"wsjt_demod": WsjtParser(self.handler),
"packet_demod": AprsParser(self.handler),
"pocsag_demod": PocsagParser(self.handler),
"js8_demod": Js8Parser(self.handler),
}
self.props = PropertyStack()
# current audio mode. should be "audio" or "hd_audio" depending on what demodulatur is in use.
self.audioOutput = None
# local demodulator properties not forwarded to the sdr
# ensure strict validation since these can be set from the client
# and are used to build executable commands
@ -75,87 +411,80 @@ class DspManager(Output, SdrSourceEventClient):
),
)
self.dsp = Dsp(self)
self.dsp.nc_port = self.sdrSource.getPort()
# defaults for values that may not be set
self.props.addLayer(
2,
PropertyLayer(
output_rate=12000,
hd_output_rate=48000,
digital_voice_codecserver="",
).readonly()
)
def set_low_cut(cut):
bpf = self.dsp.get_bpf()
bpf[0] = cut
self.dsp.set_bpf(*bpf)
self.chain = ClientDemodulatorChain(
self._getDemodulator("nfm"),
self.props["samp_rate"],
self.props["output_rate"],
self.props["hd_output_rate"],
self.props["audio_compression"],
self
)
def set_high_cut(cut):
bpf = self.dsp.get_bpf()
bpf[1] = cut
self.dsp.set_bpf(*bpf)
def set_dial_freq(changes):
if (
"center_freq" not in self.props
or self.props["center_freq"] is None
or "offset_freq" not in self.props
or self.props["offset_freq"] is None
):
return
freq = self.props["center_freq"] + self.props["offset_freq"]
for parser in self.parsers.values():
parser.setDialFrequency(freq)
self.readers = {}
if "start_mod" in self.props:
self.dsp.set_demodulator(self.props["start_mod"])
self.setDemodulator(self.props["start_mod"])
mode = Modes.findByModulation(self.props["start_mod"])
if mode and mode.bandpass:
self.dsp.set_bpf(mode.bandpass.low_cut, mode.bandpass.high_cut)
else:
self.dsp.set_bpf(-4000, 4000)
bpf = [mode.bandpass.low_cut, mode.bandpass.high_cut]
self.chain.setBandpass(*bpf)
if "start_freq" in self.props and "center_freq" in self.props:
self.dsp.set_offset_freq(self.props["start_freq"] - self.props["center_freq"])
self.chain.setFrequencyOffset(self.props["start_freq"] - self.props["center_freq"])
else:
self.dsp.set_offset_freq(0)
self.chain.setFrequencyOffset(0)
self.subscriptions = [
self.props.wireProperty("audio_compression", self.dsp.set_audio_compression),
self.props.wireProperty("fft_compression", self.dsp.set_fft_compression),
self.props.wireProperty("digimodes_fft_size", self.dsp.set_secondary_fft_size),
self.props.wireProperty("samp_rate", self.dsp.set_samp_rate),
self.props.wireProperty("output_rate", self.dsp.set_output_rate),
self.props.wireProperty("hd_output_rate", self.dsp.set_hd_output_rate),
self.props.wireProperty("offset_freq", self.dsp.set_offset_freq),
self.props.wireProperty("center_freq", self.dsp.set_center_freq),
self.props.wireProperty("squelch_level", self.dsp.set_squelch_level),
self.props.wireProperty("low_cut", set_low_cut),
self.props.wireProperty("high_cut", set_high_cut),
self.props.wireProperty("mod", self.dsp.set_demodulator),
self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter),
self.props.wireProperty("wfm_deemphasis_tau", self.dsp.set_wfm_deemphasis_tau),
self.props.wireProperty("digital_voice_codecserver", self.dsp.set_codecserver),
self.props.filter("center_freq", "offset_freq").wire(set_dial_freq),
self.props.wireProperty("audio_compression", self.setAudioCompression),
self.props.wireProperty("fft_compression", self.chain.setSecondaryFftCompression),
self.props.wireProperty("fft_voverlap_factor", self.chain.setSecondaryFftOverlapFactor),
self.props.wireProperty("fft_fps", self.chain.setSecondaryFftFps),
self.props.wireProperty("digimodes_fft_size", self.setSecondaryFftSize),
self.props.wireProperty("samp_rate", self.chain.setSampleRate),
self.props.wireProperty("output_rate", self.chain.setOutputRate),
self.props.wireProperty("hd_output_rate", self.chain.setHdOutputRate),
self.props.wireProperty("offset_freq", self.chain.setFrequencyOffset),
self.props.wireProperty("center_freq", self.chain.setCenterFrequency),
self.props.wireProperty("squelch_level", self.chain.setSquelchLevel),
self.props.wireProperty("low_cut", self.chain.setLowCut),
self.props.wireProperty("high_cut", self.chain.setHighCut),
self.props.wireProperty("mod", self.setDemodulator),
self.props.wireProperty("dmr_filter", self.chain.setSlotFilter),
self.props.wireProperty("wfm_deemphasis_tau", self.chain.setWfmDeemphasisTau),
self.props.wireProperty("secondary_mod", self.setSecondaryDemodulator),
self.props.wireProperty("secondary_offset_freq", self.chain.setSecondaryFrequencyOffset),
]
self.dsp.set_temporary_directory(CoreConfig().get_temporary_directory())
# wire power level output
buffer = Buffer(Format.FLOAT)
self.chain.setPowerWriter(buffer)
self.wireOutput("smeter", buffer)
def send_secondary_config(*args):
self.handler.write_secondary_dsp_config(
{
"secondary_fft_size": self.props["digimodes_fft_size"],
"if_samp_rate": self.dsp.if_samp_rate(),
"secondary_bw": self.dsp.secondary_bw(),
}
)
# wire meta output
buffer = Buffer(Format.CHAR)
self.chain.setMetaWriter(buffer)
self.wireOutput("meta", buffer)
def set_secondary_mod(mod):
if mod == False:
mod = None
self.dsp.set_secondary_demodulator(mod)
if mod is not None:
send_secondary_config()
# wire secondary FFT
buffer = Buffer(self.chain.getSecondaryFftOutputFormat())
self.chain.setSecondaryFftWriter(buffer)
self.wireOutput("secondary_fft", buffer)
self.subscriptions += [
self.props.wireProperty("secondary_mod", set_secondary_mod),
self.props.wireProperty("digimodes_fft_size", send_secondary_config),
self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq),
]
# wire secondary demodulator
buffer = Buffer(Format.CHAR)
self.chain.setSecondaryWriter(buffer)
self.wireOutput("secondary_demod", buffer)
self.startOnAvailable = False
@ -163,30 +492,156 @@ class DspManager(Output, SdrSourceEventClient):
super().__init__()
def setSecondaryFftSize(self, size):
self.chain.setSecondaryFftSize(size)
self.handler.write_secondary_dsp_config({"secondary_fft_size": size})
def _getDemodulator(self, demod: Union[str, BaseDemodulatorChain]) -> Optional[BaseDemodulatorChain]:
if isinstance(demod, BaseDemodulatorChain):
return demod
# TODO: move this to Modes
if demod == "nfm":
from csdr.chain.analog import NFm
return NFm(self.props["output_rate"])
elif demod == "wfm":
from csdr.chain.analog import WFm
return WFm(self.props["hd_output_rate"], self.props["wfm_deemphasis_tau"])
elif demod == "am":
from csdr.chain.analog import Am
return Am()
elif demod in ["usb", "lsb", "cw"]:
from csdr.chain.analog import Ssb
return Ssb()
elif demod == "dmr":
from csdr.chain.digiham import Dmr
return Dmr(self.props["digital_voice_codecserver"])
elif demod == "dstar":
from csdr.chain.digiham import Dstar
return Dstar(self.props["digital_voice_codecserver"])
elif demod == "ysf":
from csdr.chain.digiham import Ysf
return Ysf(self.props["digital_voice_codecserver"])
elif demod == "nxdn":
from csdr.chain.digiham import Nxdn
return Nxdn(self.props["digital_voice_codecserver"])
elif demod == "m17":
from csdr.chain.m17 import M17
return M17()
elif demod == "drm":
from csdr.chain.drm import Drm
return Drm()
elif demod == "freedv":
from csdr.chain.freedv import FreeDV
return FreeDV()
def setDemodulator(self, mod):
demodulator = self._getDemodulator(mod)
if demodulator is None:
raise ValueError("unsupported demodulator: {}".format(mod))
self.chain.setDemodulator(demodulator)
output = "hd_audio" if isinstance(demodulator, HdAudio) else "audio"
if output != self.audioOutput:
self.audioOutput = output
# re-wire the audio to the correct client API
buffer = Buffer(self.chain.getOutputFormat())
self.chain.setWriter(buffer)
self.wireOutput(self.audioOutput, buffer)
def _getSecondaryDemodulator(self, mod) -> Optional[SecondaryDemodulator]:
if isinstance(mod, SecondaryDemodulator):
return mod
if mod in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"]:
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.wsjt import WsjtParser
return AudioChopperDemodulator(mod, WsjtParser())
elif mod == "js8":
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.js8 import Js8Parser
return AudioChopperDemodulator(mod, Js8Parser())
elif mod == "packet":
from csdr.chain.digimodes import PacketDemodulator
return PacketDemodulator()
elif mod == "pocsag":
from csdr.chain.digimodes import PocsagDemodulator
return PocsagDemodulator()
elif mod == "bpsk31":
from csdr.chain.digimodes import PskDemodulator
return PskDemodulator(31.25)
elif mod == "bpsk63":
from csdr.chain.digimodes import PskDemodulator
return PskDemodulator(62.5)
def setSecondaryDemodulator(self, mod):
demodulator = self._getSecondaryDemodulator(mod)
if not demodulator:
self.chain.setSecondaryDemodulator(None)
else:
self.chain.setSecondaryDemodulator(demodulator)
def setAudioCompression(self, comp):
try:
self.chain.setAudioCompression(comp)
except ValueError:
# wrong output format... need to re-wire
buffer = Buffer(self.chain.getOutputFormat())
self.chain.setWriter(buffer)
self.wireOutput(self.audioOutput, buffer)
def start(self):
if self.sdrSource.isAvailable():
self.dsp.start()
self.chain.setReader(self.sdrSource.getBuffer().getReader())
else:
self.startOnAvailable = True
def receive_output(self, t, read_fn):
logger.debug("adding new output of type %s", t)
def unwireOutput(self, t: str):
if t in self.readers:
self.readers[t].stop()
del self.readers[t]
def wireOutput(self, t: str, buffer: Buffer):
logger.debug("wiring new output of type %s", t)
writers = {
"audio": self.handler.write_dsp_data,
"hd_audio": self.handler.write_hd_audio,
"smeter": self.handler.write_s_meter_level,
"secondary_fft": self.handler.write_secondary_fft,
"secondary_demod": self.handler.write_secondary_demod,
"secondary_demod": self._unpickle(self.handler.write_secondary_demod),
"meta": self._unpickle(self.handler.write_metadata),
}
for demod, parser in self.parsers.items():
writers[demod] = parser.parse
write = writers[t]
threading.Thread(target=self.pump(read_fn, write), name="dsp_pump_{}".format(t)).start()
self.unwireOutput(t)
reader = buffer.getReader()
self.readers[t] = reader
threading.Thread(target=self.chain.pump(reader.read, write), name="dsp_pump_{}".format(t)).start()
def _unpickle(self, callback):
def unpickler(data):
b = data.tobytes()
io = BytesIO(b)
try:
while True:
callback(pickle.load(io))
except EOFError:
pass
# TODO: this is not ideal. is there a way to know beforehand if the data will be pickled?
except pickle.UnpicklingError:
callback(b.decode("ascii"))
return unpickler
def stop(self):
self.dsp.stop()
if self.chain:
self.chain.stop()
self.chain = None
for reader in self.readers.values():
reader.stop()
self.readers = {}
self.startOnAvailable = False
self.sdrSource.removeClient(self)
for sub in self.subscriptions:
@ -207,15 +662,21 @@ class DspManager(Output, SdrSourceEventClient):
if state is SdrSourceState.RUNNING:
logger.debug("received STATE_RUNNING, attempting DspSource restart")
if self.startOnAvailable:
self.dsp.start()
self.chain.setReader(self.sdrSource.getBuffer().getReader())
self.startOnAvailable = False
elif state is SdrSourceState.STOPPING:
logger.debug("received STATE_STOPPING, shutting down DspSource")
self.dsp.stop()
self.stop()
def onFail(self):
logger.debug("received onFail(), shutting down DspSource")
self.dsp.stop()
self.stop()
def onShutdown(self):
self.dsp.stop()
self.stop()
def onSecondaryDspBandwidthChange(self, bw):
self.handler.write_secondary_dsp_config({"secondary_bw": bw})
def onSecondaryDspRateChange(self, rate):
self.handler.write_secondary_dsp_config({"if_samp_rate": rate})

View File

@ -2,7 +2,7 @@ import subprocess
from functools import reduce
from operator import and_
import re
from distutils.version import LooseVersion
from distutils.version import LooseVersion, StrictVersion
import inspect
from owrx.config.core import CoreConfig
from owrx.config import Config
@ -51,18 +51,18 @@ class FeatureCache(object):
class FeatureDetector(object):
features = {
# core features; we won't start without these
"core": ["csdr", "nmux", "nc"],
"core": ["csdr", "pycsdr"],
# different types of sdrs and their requirements
"rtl_sdr": ["rtl_connector"],
"rtl_sdr_soapy": ["soapy_connector", "soapy_rtl_sdr"],
"rtl_tcp": ["rtl_tcp_connector"],
"sdrplay": ["soapy_connector", "soapy_sdrplay"],
"hackrf": ["soapy_connector", "soapy_hackrf"],
"perseussdr": ["perseustest"],
"perseussdr": ["perseustest", "nmux"],
"airspy": ["soapy_connector", "soapy_airspy"],
"airspyhf": ["soapy_connector", "soapy_airspyhf"],
"lime_sdr": ["soapy_connector", "soapy_lime_sdr"],
"fifi_sdr": ["alsa", "rockprog"],
"fifi_sdr": ["alsa", "rockprog", "nmux"],
"pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"],
"soapy_remote": ["soapy_connector", "soapy_remote"],
"uhd": ["soapy_connector", "soapy_uhd"],
@ -72,16 +72,16 @@ class FeatureDetector(object):
"hpsdr": ["hpsdr_connector"],
"runds": ["runds_connector"],
# optional features and their requirements
"digital_voice_digiham": ["digiham", "sox", "codecserver_ambe"],
"digital_voice_freedv": ["freedv_rx", "sox"],
"digital_voice_m17": ["m17_demod", "sox", "digiham"],
"wsjt-x": ["wsjtx", "sox"],
"wsjt-x-2-3": ["wsjtx_2_3", "sox"],
"wsjt-x-2-4": ["wsjtx_2_4", "sox"],
"packet": ["direwolf", "sox"],
"pocsag": ["digiham", "sox"],
"js8call": ["js8", "sox"],
"drm": ["dream", "sox"],
"digital_voice_digiham": ["digiham", "codecserver_ambe"],
"digital_voice_freedv": ["freedv_rx"],
"digital_voice_m17": ["m17_demod", "digiham"],
"wsjt-x": ["wsjtx"],
"wsjt-x-2-3": ["wsjtx_2_3"],
"wsjt-x-2-4": ["wsjtx_2_4"],
"packet": ["direwolf"],
"pocsag": ["digiham"],
"js8call": ["js8", "js8py"],
"drm": ["dream"],
}
def feature_availability(self):
@ -167,24 +167,28 @@ class FeatureDetector(object):
except FileNotFoundError:
return False
_required_csdr_version = LooseVersion("0.18.0")
def has_csdr(self):
"""
OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project
page on github](https://github.com/jketterl/csdr) for further details and installation instructions.
"""
required_version = LooseVersion("0.17.0")
csdr_version_regex = re.compile("^csdr version (.*)$")
try:
process = subprocess.Popen(["csdr", "version"], stderr=subprocess.PIPE)
matches = csdr_version_regex.match(process.stderr.readline().decode())
if matches is None:
from pycsdr.modules import csdr_version
return LooseVersion(csdr_version) >= FeatureDetector._required_csdr_version
except ImportError:
return False
version = LooseVersion(matches.group(1))
process.wait(1)
return version >= required_version
except FileNotFoundError:
def has_pycsdr(self):
"""
OpenWebRX uses the csdr python bindings from the pycsdr package to build its demodulator pipelines.
Please visit [the project page](https://github.com/jketterl/pycsdr) for further details.
"""
try:
from pycsdr.modules import version as pycsdr_version
return LooseVersion(pycsdr_version) >= FeatureDetector._required_csdr_version
except ImportError:
return False
def has_nmux(self):
@ -194,13 +198,6 @@ class FeatureDetector(object):
"""
return self.command_is_runnable("nmux --help")
def has_nc(self):
"""
Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended
for better performance) or GNU netcat packages. Please check your distribution package manager for options.
"""
return self.command_is_runnable("nc --help")
def has_perseustest(self):
"""
To use a Microtelecom Perseus HF receiver, compile and
@ -231,40 +228,11 @@ class FeatureDetector(object):
"""
required_version = LooseVersion("0.5")
digiham_version_regex = re.compile("^(.*) version (.*)$")
def check_digiham_version(command):
try:
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
matches = digiham_version_regex.match(process.stdout.readline().decode())
if matches is None:
from digiham.modules import version as digiham_version
return LooseVersion(digiham_version) >= required_version
except ImportError:
return False
version = LooseVersion(matches.group(2))
process.wait(1)
return matches.group(1) in [command, "digiham"] and 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",
"fsk_demodulator",
"pocsag_decoder",
"dstar_decoder",
"nxdn_decoder",
"dc_block",
],
),
True,
)
def _check_connector(self, command, required_version):
owrx_connector_version_regex = re.compile("^{} version (.*)$".format(re.escape(command)))
@ -423,15 +391,6 @@ class FeatureDetector(object):
"""
return self.command_is_runnable("m17-demod")
def has_sox(self):
"""
The sox audio library is used to convert between the typical 8 kHz audio sampling rate used by digital modes and
the audio sampling rate requested by the client.
It is available for most distributions through the respective package manager.
"""
return self.command_is_runnable("sox")
def has_direwolf(self):
"""
OpenWebRX uses the [direwolf](https://github.com/wb2osz/direwolf) software modem to decode Packet Radio and
@ -490,6 +449,18 @@ class FeatureDetector(object):
"""
return self.command_is_runnable("js8")
def has_js8py(self):
"""
The js8py library is used to decode binary JS8 messages into readable text. More information is available on
[its github page](https://github.com/jketterl/js8py).
"""
required_version = StrictVersion("0.1")
try:
from js8py.version import strictversion
return strictversion >= required_version
except ImportError:
return False
def has_alsa(self):
"""
Some SDR receivers are identifying themselves as a soundcard. In order to read their data, OpenWebRX relies
@ -555,19 +526,15 @@ class FeatureDetector(object):
You can find more information [here](https://github.com/jketterl/codecserver).
"""
tmp_dir = CoreConfig().get_temporary_directory()
cmd = ["mbe_synthesizer", "--test"]
config = Config.get()
server = ""
if "digital_voice_codecserver" in config:
cmd += ["--server", config["digital_voice_codecserver"]]
server = config["digital_voice_codecserver"]
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=tmp_dir,
)
return process.wait() == 0
except FileNotFoundError:
from digiham.modules import MbeSynthesizer
return MbeSynthesizer.hasAmbe(server)
except ImportError:
return False
except ConnectionError:
return False

View File

@ -1,17 +1,16 @@
from owrx.config.core import CoreConfig
from owrx.config import Config
import csdr
from csdr.output import Output
import threading
from csdr.chain.fft import FftChain
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack
from pycsdr.modules import Buffer
import threading
import logging
logger = logging.getLogger(__name__)
class SpectrumThread(Output, SdrSourceEventClient):
class SpectrumThread(SdrSourceEventClient):
def __init__(self, sdrSource):
self.sdrSource = sdrSource
super().__init__()
@ -19,7 +18,7 @@ class SpectrumThread(Output, SdrSourceEventClient):
stack = PropertyStack()
stack.addLayer(0, self.sdrSource.props)
stack.addLayer(1, Config.get())
self.props = props = stack.filter(
self.props = stack.filter(
"samp_rate",
"fft_size",
"fft_fps",
@ -27,52 +26,66 @@ class SpectrumThread(Output, SdrSourceEventClient):
"fft_compression",
)
self.dsp = dsp = csdr.Dsp(self)
dsp.nc_port = self.sdrSource.getPort()
dsp.set_demodulator("fft")
self.dsp = None
self.reader = None
def set_fft_averages(changes=None):
samp_rate = props["samp_rate"]
fft_size = props["fft_size"]
fft_fps = props["fft_fps"]
fft_voverlap_factor = props["fft_voverlap_factor"]
self.subscriptions = []
dsp.set_fft_averages(
int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor)))
if fft_voverlap_factor > 0
else 0
)
self.subscriptions = [
props.wireProperty("samp_rate", dsp.set_samp_rate),
props.wireProperty("fft_size", dsp.set_fft_size),
props.wireProperty("fft_fps", dsp.set_fft_fps),
props.wireProperty("fft_compression", dsp.set_fft_compression),
props.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages),
]
set_fft_averages()
dsp.set_temporary_directory(CoreConfig().get_temporary_directory())
logger.debug("Spectrum thread initialized successfully.")
def start(self):
if self.dsp is not None:
return
self.dsp = FftChain(
self.props['samp_rate'],
self.props['fft_size'],
self.props['fft_voverlap_factor'],
self.props['fft_fps'],
self.props['fft_compression']
)
self.sdrSource.addClient(self)
self.subscriptions += [
self.props.filter("fft_size").wire(self.restart),
# these props can be set on the fly
self.props.wireProperty("samp_rate", self.dsp.setSampleRate),
self.props.wireProperty("fft_fps", self.dsp.setFps),
self.props.wireProperty("fft_voverlap_factor", self.dsp.setVOverlapFactor),
self.props.wireProperty("fft_compression", self._setCompression),
]
if self.sdrSource.isAvailable():
self.dsp.start()
self.dsp.setReader(self.sdrSource.getBuffer().getReader())
def supports_type(self, t):
return t == "audio"
def _setCompression(self, compression):
if self.reader:
self.reader.stop()
try:
self.dsp.setCompression(compression)
except ValueError:
# expected since the compressions have different formats
pass
def receive_output(self, type, read_fn):
threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start()
buffer = Buffer(self.dsp.getOutputFormat())
self.dsp.setWriter(buffer)
self.reader = buffer.getReader()
threading.Thread(target=self.dsp.pump(self.reader.read, self.sdrSource.writeSpectrumData)).start()
def stop(self):
if self.dsp is None:
return
self.dsp.stop()
self.dsp = None
self.reader.stop()
self.reader = None
self.sdrSource.removeClient(self)
for c in self.subscriptions:
c.cancel()
self.subscriptions = []
while self.subscriptions:
self.subscriptions.pop().cancel()
def restart(self, *args, **kwargs):
self.stop()
self.start()
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.USER
@ -80,11 +93,18 @@ class SpectrumThread(Output, SdrSourceEventClient):
def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.STOPPING:
self.dsp.stop()
elif state is SdrSourceState.RUNNING:
self.dsp.start()
elif state == SdrSourceState.RUNNING:
if self.dsp is None:
self.start()
else:
self.dsp.setReader(self.sdrSource.getBuffer().getReader())
def onFail(self):
if self.dsp is None:
return
self.dsp.stop()
def onShutdown(self):
if self.dsp is None:
return
self.dsp.stop()

View File

@ -1,5 +1,5 @@
from owrx.audio import AudioChopperProfile, ConfigWiredProfileSource
from owrx.parser import Parser
from owrx.audio.chopper import AudioChopperParser
import re
from js8py import Js8
from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound
@ -8,6 +8,7 @@ from owrx.metrics import Metrics, CounterMetric
from owrx.config import Config
from abc import ABCMeta, abstractmethod
from owrx.reporting import ReportingEngine
from owrx.bands import Bandplan
from typing import List
import logging
@ -81,13 +82,15 @@ class Js8TurboProfile(Js8Profile):
return "C"
class Js8Parser(Parser):
class Js8Parser(AudioChopperParser):
decoderRegex = re.compile(" ?<Decode(Started|Debug|Finished)>")
def parse(self, raw):
def parse(self, profile: AudioChopperProfile, freq: int, raw_msg: bytes):
try:
profile, freq, raw_msg = raw
self.setDialFrequency(freq)
band = None
if freq is not None:
band = Bandplan.getSharedInstance().findBand(freq)
msg = raw_msg.decode().rstrip()
if Js8Parser.decoderRegex.match(msg):
return
@ -95,38 +98,48 @@ class Js8Parser(Parser):
return
frame = Js8().parse_message(msg)
self.handler.write_js8_message(frame, self.dial_freq)
self.pushDecode()
self.pushDecode(band)
if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid:
Map.getSharedInstance().updateLocation(
frame.callsign, LocatorLocation(frame.grid), "JS8", self.band
frame.callsign, LocatorLocation(frame.grid), "JS8", band
)
ReportingEngine.getSharedInstance().spot(
{
"callsign": frame.callsign,
"mode": "JS8",
"locator": frame.grid,
"freq": self.dial_freq + frame.freq,
"freq": freq + frame.freq,
"db": frame.db,
"timestamp": frame.timestamp,
"msg": str(frame),
}
)
out = {
"mode": "JS8",
"msg": str(frame),
"timestamp": frame.timestamp,
"db": frame.db,
"dt": frame.dt,
"freq": freq + frame.freq,
"thread_type": frame.thread_type,
"js8mode": frame.mode,
}
return out
except Exception:
logger.exception("error while parsing js8 message")
def pushDecode(self):
def pushDecode(self, band):
metrics = Metrics.getSharedInstance()
band = "unknown"
if self.band is not None:
band = self.band.getName()
if band is None:
band = "unknown"
bandName = "unknown"
if band is not None:
bandName = band.getName()
name = "js8call.decodes.{band}.JS8".format(band=band)
name = "js8call.decodes.{band}.JS8".format(band=bandName)
metric = metrics.getMetric(name)
if metric is None:
metric = CounterMetric()

View File

@ -1,13 +1,18 @@
from owrx.config import Config
from urllib import request
import json
from datetime import datetime, timedelta
import logging
import threading
from owrx.map import Map, LatLngLocation
from owrx.parser import Parser
import pickle
import re
from abc import ABC, ABCMeta, abstractmethod
from datetime import datetime, timedelta
from urllib import request
from urllib.error import HTTPError
from csdr.module import PickleModule
from owrx.aprs import AprsParser, AprsLocation
from abc import ABC, abstractmethod
from owrx.config import Config
from owrx.map import Map, LatLngLocation
from owrx.bands import Bandplan
logger = logging.getLogger(__name__)
@ -17,7 +22,7 @@ class Enricher(ABC):
self.parser = parser
@abstractmethod
def enrich(self, meta):
def enrich(self, meta, callback):
pass
@ -58,9 +63,15 @@ class RadioIDEnricher(Enricher):
super().__init__(parser)
self.mode = mode
self.threads = {}
self.callbacks = {}
def _fillCache(self, id):
RadioIDCache.getSharedInstance().put(self.mode, id, self._downloadRadioIdData(id))
data = self._downloadRadioIdData(id)
RadioIDCache.getSharedInstance().put(self.mode, id, data)
if id in self.callbacks:
while self.callbacks[id]:
self.callbacks[id].pop()(data)
del self.callbacks[id]
del self.threads[id]
def _downloadRadioIdData(self, id):
@ -77,10 +88,12 @@ class RadioIDEnricher(Enricher):
return item
except json.JSONDecodeError:
logger.warning("unable to parse radioid response JSON")
except HTTPError as e:
logger.warning("radioid responded with error: %s", str(e))
return None
def enrich(self, meta):
def enrich(self, meta, callback):
config_key = "digital_voice_{}_id_lookup".format(self.mode)
if not Config.get()[config_key]:
return meta
@ -92,6 +105,15 @@ class RadioIDEnricher(Enricher):
if id not in self.threads:
self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True)
self.threads[id].start()
if id not in self.callbacks:
self.callbacks[id] = []
def onFinish(data):
if data is not None:
meta["additional"] = data
callback(meta)
self.callbacks[id].append(onFinish)
return meta
data = cache.get(self.mode, id)
if data is not None:
@ -99,34 +121,76 @@ class RadioIDEnricher(Enricher):
return meta
class YsfMetaEnricher(Enricher):
def enrich(self, meta):
for key in ["source", "up", "down", "target"]:
if key in meta:
meta[key] = meta[key].strip()
class DigihamEnricher(Enricher, metaclass=ABCMeta):
def parseCoordinate(self, meta, mode):
for key in ["lat", "lon"]:
if key in meta:
meta[key] = float(meta[key])
if "source" in meta and "lat" in meta and "lon" in meta:
callsign = self.getCallsign(meta)
if callsign is not None and "lat" in meta and "lon" in meta:
loc = LatLngLocation(meta["lat"], meta["lon"])
Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF", self.parser.getBand())
Map.getSharedInstance().updateLocation(callsign, loc, mode, self.parser.getBand())
return meta
@abstractmethod
def getCallsign(self, meta):
pass
class DmrEnricher(DigihamEnricher, RadioIDEnricher):
# callsign must be uppercase alphanumeric and at the beginning
# if there's anything after the callsign, it must be separated by a whitespace
talkerAliasRegex = re.compile("^([A-Z0-9]+)(\\s.*)?$")
def __init__(self, parser):
super().__init__("dmr", parser)
def getCallsign(self, meta):
# there's no explicit callsign data in dmr, so we can only rely on one of the following:
# a) a callsign provided by a radioid lookup
if "additional" in meta and "callsign" in meta["additional"]:
return meta["additional"]["callsign"]
# b) a callsign in the talker alias
if "talkeralias" in meta:
matches = DmrEnricher.talkerAliasRegex.match(meta["talkeralias"])
if matches:
return matches.group(1)
def enrich(self, meta, callback):
def asyncParse(meta):
self.parseCoordinate(meta, "DMR")
callback(meta)
meta = super().enrich(meta, asyncParse)
meta = self.parseCoordinate(meta, "DMR")
return meta
class DStarEnricher(Enricher):
def enrich(self, meta):
for key in ["lat", "lon"]:
if key in meta:
meta[key] = float(meta[key])
if "ourcall" in meta and "lat" in meta and "lon" in meta:
loc = LatLngLocation(meta["lat"], meta["lon"])
Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "D-Star", self.parser.getBand())
class YsfMetaEnricher(DigihamEnricher):
def getCallsign(self, meta):
if "source" in meta:
return meta["source"]
def enrich(self, meta, callback):
meta = self.parseCoordinate(meta, "YSF")
return meta
class DStarEnricher(DigihamEnricher):
def getCallsign(self, meta):
if "ourcall" in meta:
return meta["ourcall"]
def enrich(self, meta, callback):
meta = self.parseCoordinate(meta, "D-Star")
meta = self.parseDprs(meta)
return meta
def parseDprs(self, meta):
if "dprs" in meta:
try:
# we can send the DPRS stuff through our APRS parser to extract the information
# TODO: only third-party parsing accepts this format right now
# TODO: we also need to pass a handler, which is not needed
parser = AprsParser(None)
parser = AprsParser()
dprsData = parser.parseThirdpartyAprsData(meta["dprs"])
if "data" in dprsData:
data = dprsData["data"]
@ -145,22 +209,34 @@ class DStarEnricher(Enricher):
return meta
class MetaParser(Parser):
def __init__(self, handler):
super().__init__(handler)
class MetaParser(PickleModule):
def __init__(self):
self.enrichers = {
"DMR": RadioIDEnricher("dmr", self),
"DMR": DmrEnricher(self),
"YSF": YsfMetaEnricher(self),
"DSTAR": DStarEnricher(self),
"NXDN": RadioIDEnricher("nxdn", self),
}
self.currentMetaData = None
self.band = None
super().__init__()
def parse(self, meta):
fields = meta.split(";")
meta = {v[0]: ":".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""}
def process(self, meta):
self.currentMetaData = None
if "protocol" in meta:
protocol = meta["protocol"]
if protocol in self.enrichers:
meta = self.enrichers[protocol].enrich(meta)
self.handler.write_metadata(meta)
self.currentMetaData = meta = self.enrichers[protocol].enrich(meta, self.receive)
return meta
def receive(self, meta):
# we may have moved on in the meantime
if meta is not self.currentMetaData:
return
self.writer.write(pickle.dumps(meta))
def setDialFrequency(self, freq):
self.band = Bandplan.getSharedInstance().findBand(freq)
def getBand(self):
return self.band

View File

@ -10,8 +10,8 @@ class Bandpass(object):
self.high_cut = high_cut
class Mode(object):
def __init__(self, modulation, name, bandpass: Bandpass = None, requirements=None, service=False, squelch=True):
class Mode:
def __init__(self, modulation: str, name: str, bandpass: Bandpass = None, requirements=None, service=False, squelch=True):
self.modulation = modulation
self.name = name
self.requirements = requirements if requirements is not None else []
@ -44,13 +44,16 @@ class DigitalMode(Mode):
super().__init__(modulation, name, bandpass, requirements, service, squelch)
self.underlying = underlying
def get_underlying_mode(self):
return Modes.findByModulation(self.underlying[0])
def get_bandpass(self):
if self.bandpass is not None:
return self.bandpass
return Modes.findByModulation(self.underlying[0]).get_bandpass()
return self.get_underlying_mode().get_bandpass()
def get_modulation(self):
return Modes.findByModulation(self.underlying[0]).get_modulation()
return self.get_underlying_mode().get_modulation()
class AudioChopperMode(DigitalMode, metaclass=ABCMeta):

View File

@ -1,20 +0,0 @@
from abc import ABC, abstractmethod
from owrx.bands import Bandplan
class Parser(ABC):
def __init__(self, handler):
self.handler = handler
self.dial_freq = None
self.band = None
@abstractmethod
def parse(self, raw):
pass
def setDialFrequency(self, freq):
self.dial_freq = freq
self.band = Bandplan.getSharedInstance().findBand(freq)
def getBand(self):
return self.band

View File

@ -1,17 +1,15 @@
from owrx.parser import Parser
from csdr.module import PickleModule
import logging
logger = logging.getLogger(__name__)
class PocsagParser(Parser):
def parse(self, raw):
class PocsagParser(PickleModule):
def process(self, meta):
try:
fields = raw.decode("ascii", "replace").rstrip("\n").split(";")
meta = {v[0]: "".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""}
if "address" in meta:
meta["address"] = int(meta["address"])
self.handler.write_pocsag_data(meta)
meta["mode"] = "Pocsag"
return meta
except Exception:
logger.exception("Exception while parsing Pocsag message")

View File

@ -259,3 +259,8 @@ class SdrService(object):
if SdrService.availableProfiles is None:
SdrService.availableProfiles = AvailableProfiles(SdrService.getActiveSources())
return SdrService.availableProfiles
@staticmethod
def stopAllSources():
for source in SdrService.getAllSources().values():
source.stop()

View File

@ -2,65 +2,21 @@ import threading
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.sdr import SdrService
from owrx.bands import Bandplan
from csdr.output import Output
from csdr import Dsp
from owrx.wsjt import WsjtParser
from owrx.aprs import AprsParser
from owrx.js8 import Js8Parser
from owrx.config.core import CoreConfig
from owrx.config import Config
from owrx.source.resampler import Resampler
from owrx.property import PropertyLayer, PropertyDeleted
from js8py import Js8Frame
from abc import ABCMeta, abstractmethod
from owrx.service.schedule import ServiceScheduler
from owrx.modes import Modes
from owrx.service.chain import ServiceDemodulatorChain
from owrx.modes import Modes, DigitalMode
from typing import Union, Optional
from csdr.chain.demodulator import BaseDemodulatorChain, ServiceDemodulator, DialFrequencyReceiver
from pycsdr.modules import Buffer
import logging
logger = logging.getLogger(__name__)
class ServiceOutput(Output, metaclass=ABCMeta):
def __init__(self, frequency):
self.frequency = frequency
@abstractmethod
def getParser(self):
# abstract method; implement in subclasses
pass
def receive_output(self, t, read_fn):
parser = self.getParser()
parser.setDialFrequency(self.frequency)
target = self.pump(read_fn, parser.parse)
threading.Thread(target=target, name="service_output_receive").start()
class WsjtServiceOutput(ServiceOutput):
def getParser(self):
return WsjtParser(WsjtHandler())
def supports_type(self, t):
return t == "wsjt_demod"
class AprsServiceOutput(ServiceOutput):
def getParser(self):
return AprsParser(AprsHandler())
def supports_type(self, t):
return t == "packet_demod"
class Js8ServiceOutput(ServiceOutput):
def getParser(self):
return Js8Parser(Js8Handler())
def supports_type(self, t):
return t == "js8_demod"
class ServiceHandler(SdrSourceEventClient):
def __init__(self, source):
self.lock = threading.RLock()
@ -196,12 +152,9 @@ class ServiceHandler(SdrSourceEventClient):
if len(group) > 1:
cf = self.get_center_frequency(group)
bw = self.get_bandwidth(group)
logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw))
resampler_props = PropertyLayer()
resampler_props["center_freq"] = cf
resampler_props["samp_rate"] = bw
logger.debug("setting up resampler on center frequency: {0}, bandwidth: {1}".format(cf, bw))
resampler_props = PropertyLayer(center_freq=cf, samp_rate=bw)
resampler = Resampler(resampler_props, self.source)
resampler.start()
for dial in group:
self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler))
@ -287,43 +240,58 @@ class ServiceHandler(SdrSourceEventClient):
def setupService(self, mode, frequency, source):
logger.debug("setting up service {0} on frequency {1}".format(mode, frequency))
# TODO selecting outputs will need some more intelligence here
if mode == "packet":
output = AprsServiceOutput(frequency)
elif mode == "js8":
output = Js8ServiceOutput(frequency)
else:
output = WsjtServiceOutput(frequency)
d = Dsp(output)
d.nc_port = source.getPort()
center_freq = source.getProps()["center_freq"]
d.set_offset_freq(frequency - center_freq)
d.set_center_freq(center_freq)
modeObject = Modes.findByModulation(mode)
d.set_demodulator(modeObject.get_modulation())
d.set_bandpass(modeObject.get_bandpass())
d.set_secondary_demodulator(mode)
d.set_audio_compression("none")
d.set_samp_rate(source.getProps()["samp_rate"])
d.set_temporary_directory(CoreConfig().get_temporary_directory())
d.set_service()
d.start()
return d
if not isinstance(modeObject, DigitalMode):
logger.warning("mode is not a digimode: %s", mode)
return None
demod = self._getDemodulator(modeObject.get_modulation())
secondaryDemod = self._getSecondaryDemodulator(modeObject.modulation)
center_freq = source.getProps()["center_freq"]
sampleRate = source.getProps()["samp_rate"]
bandpass = modeObject.get_bandpass()
if isinstance(secondaryDemod, DialFrequencyReceiver):
secondaryDemod.setDialFrequency(frequency)
class WsjtHandler(object):
def write_wsjt_message(self, msg):
pass
chain = ServiceDemodulatorChain(demod, secondaryDemod, sampleRate, frequency - center_freq)
chain.setBandPass(bandpass.low_cut, bandpass.high_cut)
chain.setReader(source.getBuffer().getReader())
# dummy buffer, we don't use the output right now
buffer = Buffer(chain.getOutputFormat())
chain.setWriter(buffer)
return chain
class AprsHandler(object):
def write_aprs_data(self, data):
pass
# TODO move this elsewhere
def _getDemodulator(self, demod: Union[str, BaseDemodulatorChain]):
if isinstance(demod, BaseDemodulatorChain):
return demod
# TODO: move this to Modes
if demod == "nfm":
from csdr.chain.analog import NFm
return NFm(48000)
elif demod in ["usb", "lsb", "cw"]:
from csdr.chain.analog import Ssb
return Ssb()
class Js8Handler(object):
def write_js8_message(self, frame: Js8Frame, freq: int):
pass
# TODO move this elsewhere
def _getSecondaryDemodulator(self, mod) -> Optional[ServiceDemodulator]:
if isinstance(mod, ServiceDemodulatorChain):
return mod
# TODO add remaining modes
if mod in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"]:
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.wsjt import WsjtParser
return AudioChopperDemodulator(mod, WsjtParser())
elif mod == "js8":
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.js8 import Js8Parser
return AudioChopperDemodulator(mod, Js8Parser())
elif mod == "packet":
from csdr.chain.digimodes import PacketDemodulator
return PacketDemodulator(service=True)
return None
class Services(object):

23
owrx/service/chain.py Normal file
View File

@ -0,0 +1,23 @@
from csdr.chain import Chain
from csdr.chain.selector import Selector
from csdr.chain.demodulator import BaseDemodulatorChain, ServiceDemodulator
from pycsdr.types import Format
class ServiceDemodulatorChain(Chain):
def __init__(self, demod: BaseDemodulatorChain, secondaryDemod: ServiceDemodulator, sampleRate: int, frequencyOffset: int):
self.selector = Selector(sampleRate, secondaryDemod.getFixedAudioRate(), withSquelch=False)
self.selector.setFrequencyOffset(frequencyOffset)
workers = [self.selector]
# primary demodulator is only necessary if the secondary does not accept IQ input
if secondaryDemod.getInputFormat() is not Format.COMPLEX_FLOAT:
workers += [demod]
workers += [secondaryDemod]
super().__init__(workers)
def setBandPass(self, lowCut, highCut):
self.selector.setBandpass(lowCut, highCut)

View File

@ -21,6 +21,9 @@ from owrx.feature import FeatureDetector
from typing import List
from enum import Enum
from pycsdr.modules import TcpSource, Buffer
from pycsdr.types import Format
import logging
logger = logging.getLogger(__name__)
@ -109,6 +112,8 @@ class SdrSource(ABC):
self.id = id
self.commandMapper = None
self.tcpSource = None
self.buffer = None
self.props = PropertyStack()
@ -244,6 +249,18 @@ class SdrSource(ABC):
def getPort(self):
return self.port
def _getTcpSource(self):
with self.modificationLock:
if self.tcpSource is None:
self.tcpSource = TcpSource(self.port, Format.COMPLEX_FLOAT)
return self.tcpSource
def getBuffer(self):
if self.buffer is None:
self.buffer = Buffer(Format.COMPLEX_FLOAT)
self._getTcpSource().setWriter(self.buffer)
return self.buffer
def getCommandValues(self):
dict = self.sdrProps.__dict__()
if "lfo_offset" in dict and dict["lfo_offset"] is not None:
@ -305,6 +322,7 @@ class SdrSource(ABC):
if self.monitor is None:
break
testsock = socket.socket()
testsock.settimeout(1)
try:
testsock.connect(("127.0.0.1", self.getPort()))
testsock.close()
@ -348,11 +366,22 @@ class SdrSource(ABC):
self.setState(SdrSourceState.STOPPING)
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
if self.monitor:
# wait 10 seconds for a regular shutdown
self.monitor.join(10)
# if the monitor is still running, the process still hasn't ended, so kill it
if self.monitor:
logger.warning("source has not shut down normally within 10 seconds, sending SIGKILL")
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError:
# been killed by something else, ignore
pass
if self.monitor:
self.monitor.join()
if self.tcpSource is not None:
self.tcpSource.stop()
self.tcpSource = None
self.buffer = None
def shutdown(self):
self.stop()

View File

@ -1,5 +1,9 @@
from abc import ABCMeta
from owrx.source import SdrSource, SdrDeviceDescription
from csdr.chain import Chain
from typing import Optional
from pycsdr.modules import Buffer
from pycsdr.types import Format
import logging
@ -38,16 +42,30 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
]
def getCommand(self):
return super().getCommand() + self.getFormatConversion() + self.getNmuxCommand()
return super().getCommand() + self.getNmuxCommand()
# override this in subclasses, if necessary
def getFormatConversion(self):
return []
def getFormatConversion(self) -> Optional[Chain]:
return None
# override this in subclasses, if necessary
def sleepOnRestart(self):
pass
def getBuffer(self):
if self.buffer is None:
source = self._getTcpSource()
buffer = Buffer(source.getOutputFormat())
source.setWriter(buffer)
conversion = self.getFormatConversion()
if conversion is not None:
conversion.setReader(buffer.getReader())
# this one must be COMPLEX_FLOAT
buffer = Buffer(Format.COMPLEX_FLOAT)
conversion.setWriter(buffer)
self.buffer = buffer
return self.buffer
class DirectSourceDeviceDescription(SdrDeviceDescription):
pass

View File

@ -1,6 +1,9 @@
from owrx.command import Option
from owrx.source.direct import DirectSource, DirectSourceDeviceDescription
from subprocess import Popen
from csdr.chain import Chain
from pycsdr.modules import Convert, Gain
from pycsdr.types import Format
import logging
@ -20,8 +23,8 @@ class FifiSdrSource(DirectSource):
def getEventNames(self):
return super().getEventNames() + ["device"]
def getFormatConversion(self):
return ["csdr++ convert -i s16 -o float", "csdr++ gain 5"]
def getFormatConversion(self) -> Chain:
return Chain([Convert(Format.COMPLEX_SHORT, Format.COMPLEX_FLOAT), Gain(Format.COMPLEX_FLOAT, 5.0)])
def sendRockProgFrequency(self, frequency):
process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)])

View File

@ -1,33 +1,44 @@
from .direct import DirectSource
from owrx.source import SdrSource
from pycsdr.modules import Buffer, FirDecimate, Shift
from pycsdr.types import Format
from csdr.chain import Chain
import logging
logger = logging.getLogger(__name__)
class Resampler(DirectSource):
class Resampler(SdrSource):
def onPropertyChange(self, changes):
logger.warning("Resampler is unable to handle property changes: {0}".format(changes))
def __init__(self, props, sdr):
sdrProps = sdr.getProps()
self.shift = (sdrProps["center_freq"] - props["center_freq"]) / sdrProps["samp_rate"]
self.decimation = int(float(sdrProps["samp_rate"]) / props["samp_rate"])
if_samp_rate = sdrProps["samp_rate"] / self.decimation
self.transition_bw = 0.15 * (if_samp_rate / float(sdrProps["samp_rate"]))
shift = (sdrProps["center_freq"] - props["center_freq"]) / sdrProps["samp_rate"]
decimation = int(float(sdrProps["samp_rate"]) / props["samp_rate"])
if_samp_rate = sdrProps["samp_rate"] / decimation
transition_bw = 0.15 * (if_samp_rate / float(sdrProps["samp_rate"]))
props["samp_rate"] = if_samp_rate
self.sdr = sdr
self.chain = Chain([
Shift(shift),
FirDecimate(decimation, transition_bw)
])
self.chain.setReader(sdr.getBuffer().getReader())
super().__init__(None, props)
def getCommand(self):
return [
"nc -v 127.0.0.1 {nc_port}".format(nc_port=self.sdr.getPort()),
"csdr++ shift {shift}".format(shift=self.shift),
"csdr++ firdecimate {decimation} {ddc_transition_bw} --window hamming".format(
decimation=self.decimation, ddc_transition_bw=self.transition_bw
),
] + self.getNmuxCommand()
def getBuffer(self):
if self.buffer is None:
self.buffer = Buffer(Format.COMPLEX_FLOAT)
self.chain.setWriter(self.buffer)
return self.buffer
def stop(self):
self.chain.stop()
self.chain = None
super().stop()
def activateProfile(self, profile_id=None):
logger.warning("Resampler does not support setting profiles")

View File

@ -1,15 +1,15 @@
from datetime import datetime, timezone
from typing import List
from owrx.map import Map, LocatorLocation
import re
from owrx.metrics import Metrics, CounterMetric
from owrx.reporting import ReportingEngine
from owrx.parser import Parser
from owrx.audio import AudioChopperProfile, StaticProfileSource, ConfigWiredProfileSource
from owrx.audio.chopper import AudioChopperParser
from abc import ABC, ABCMeta, abstractmethod
from owrx.config import Config
from enum import Enum
from owrx.bands import Bandplan
import re
import logging
@ -245,11 +245,13 @@ class Q65Profile(WsjtProfile):
return ["jt9", "--q65", "-p", str(self.interval), "-b", self.mode.name, "-d", str(self.decoding_depth()), file]
class WsjtParser(Parser):
def parse(self, data):
class WsjtParser(AudioChopperParser):
def parse(self, profile: WsjtProfile, freq: int, raw_msg: bytes):
try:
profile, freq, raw_msg = data
self.setDialFrequency(freq)
band = None
if freq is not None:
band = Bandplan.getSharedInstance().findBand(freq)
msg = raw_msg.decode().rstrip()
# known debug messages we know to skip
if msg.startswith("<DecodeFinished>"):
@ -273,29 +275,27 @@ class WsjtParser(Parser):
out["mode"] = mode
out["interval"] = profile.getInterval()
self.pushDecode(mode)
self.pushDecode(mode, band)
if "callsign" in out and "locator" in out:
Map.getSharedInstance().updateLocation(
out["callsign"], LocatorLocation(out["locator"]), mode, self.band
out["callsign"], LocatorLocation(out["locator"]), mode, band
)
ReportingEngine.getSharedInstance().spot(out)
self.handler.write_wsjt_message(out)
return out
except Exception:
logger.exception("Exception while parsing wsjt message")
def pushDecode(self, mode):
def pushDecode(self, mode, band):
metrics = Metrics.getSharedInstance()
band = "unknown"
if self.band is not None:
band = self.band.getName()
if band is None:
band = "unknown"
bandName = "unknown"
if band is not None:
bandName = band.getName()
if mode is None:
mode = "unknown"
name = "wsjt.decodes.{band}.{mode}".format(band=band, mode=mode)
name = "wsjt.decodes.{band}.{mode}".format(band=bandName, mode=mode)
metric = metrics.getMetric(name)
if metric is None:
metric = CounterMetric()

View File

@ -26,7 +26,10 @@ setup(
"owrx.reporting",
"owrx.audio",
"owrx.admin",
"owrx.aprs",
"csdr",
"csdr.chain",
"csdr.module",
"htdocs",
]
),