restore audio chopper decoding

This commit is contained in:
Jakob Ketterl 2021-08-31 16:54:37 +02:00
parent 4a4901fa38
commit 73d326037c
8 changed files with 168 additions and 77 deletions

View File

@ -1,3 +1,4 @@
from pycsdr.modules import Reader
from csdr.chain import Chain from csdr.chain import Chain
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@ -10,6 +11,10 @@ class BaseDemodulatorChain(Chain):
return True return True
class SecondaryDemodulator(Chain):
pass
class FixedAudioRateChain(ABC): class FixedAudioRateChain(ABC):
@abstractmethod @abstractmethod
def getFixedAudioRate(self): def getFixedAudioRate(self):

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

@ -0,0 +1,14 @@
from csdr.chain.demodulator import SecondaryDemodulator, FixedAudioRateChain
from owrx.audio.chopper import AudioChopper
from pycsdr.modules import Agc, Convert
from pycsdr.types import Format
class AudioChopperDemodulator(SecondaryDemodulator, FixedAudioRateChain):
# TODO parser typing
def __init__(self, mode: str, parser):
workers = [Convert(Format.FLOAT, Format.SHORT), AudioChopper(mode, parser)]
super().__init__(workers)
def getFixedAudioRate(self):
return 12000

View File

@ -1,10 +1,10 @@
from owrx.modes import Modes, AudioChopperMode from owrx.modes import Modes, AudioChopperMode
from csdr.output import Output
from itertools import groupby from itertools import groupby
import threading import threading
from owrx.audio import ProfileSourceSubscriber from owrx.audio import ProfileSourceSubscriber
from owrx.audio.wav import AudioWriter from owrx.audio.wav import AudioWriter
from multiprocessing.connection import Pipe from csdr.chain import Chain
import pickle
import logging import logging
@ -12,18 +12,18 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber): class AudioChopper(threading.Thread, Chain, ProfileSourceSubscriber):
def __init__(self, active_dsp, mode_str: str): # TODO parser typing
self.read_fn = None def __init__(self, mode_str: str, parser):
self.parser = parser
self.doRun = True self.doRun = True
self.dsp = active_dsp
self.writers = [] self.writers = []
mode = Modes.findByModulation(mode_str) mode = Modes.findByModulation(mode_str)
if mode is None or not isinstance(mode, AudioChopperMode): if mode is None or not isinstance(mode, AudioChopperMode):
raise ValueError("Mode {} is not an audio chopper mode".format(mode_str)) raise ValueError("Mode {} is not an audio chopper mode".format(mode_str))
self.profile_source = mode.get_profile_source() self.profile_source = mode.get_profile_source()
(self.outputReader, self.outputWriter) = Pipe()
super().__init__() super().__init__()
Chain.__init__(self, [])
def stop_writers(self): def stop_writers(self):
while self.writers: while self.writers:
@ -34,19 +34,20 @@ class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
sorted_profiles = sorted(self.profile_source.getProfiles(), key=lambda p: p.getInterval()) 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())} groups = {interval: list(group) for interval, group in groupby(sorted_profiles, key=lambda p: p.getInterval())}
writers = [ 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: for w in writers:
w.start() w.start()
self.writers = writers self.writers = writers
def supports_type(self, t): def setReader(self, reader):
return t == "audio" super().setReader(reader)
def receive_output(self, t, read_fn):
self.read_fn = read_fn
self.start() self.start()
def stop(self):
self.reader.stop()
super().stop()
def run(self) -> None: def run(self) -> None:
logger.debug("Audio chopper starting up") logger.debug("Audio chopper starting up")
self.setup_writers() self.setup_writers()
@ -54,37 +55,24 @@ class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
while self.doRun: while self.doRun:
data = None data = None
try: try:
data = self.read_fn(256) data = self.reader.read()
except ValueError: except ValueError:
pass pass
if data is None or (isinstance(data, bytes) and len(data) == 0): if data is None:
self.doRun = False self.doRun = False
else: else:
for w in self.writers: for w in self.writers:
w.write(data) w.write(data.tobytes())
logger.debug("Audio chopper shutting down") logger.debug("Audio chopper shutting down")
self.profile_source.unsubscribe(self) self.profile_source.unsubscribe(self)
self.stop_writers() 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): def onProfilesChanged(self):
logger.debug("profile change received, resetting writers...") logger.debug("profile change received, resetting writers...")
self.setup_writers() self.setup_writers()
def read(self): def send(self, profile, line):
try: data = self.parser.parse(profile, line)
return self.outputReader.recv() if data is not None:
except (EOFError, OSError): self.writer.write(pickle.dumps(data))
return None

View File

@ -13,11 +13,10 @@ logger.setLevel(logging.INFO)
class QueueJob(object): class QueueJob(object):
def __init__(self, profile, writer, file, freq): def __init__(self, profile, writer, file):
self.profile = profile self.profile = profile
self.writer = writer self.writer = writer
self.file = file self.file = file
self.freq = freq
def run(self): def run(self):
logger.debug("processing file %s", self.file) logger.debug("processing file %s", self.file)
@ -30,7 +29,7 @@ class QueueJob(object):
) )
try: try:
for line in decoder.stdout: for line in decoder.stdout:
self.writer.send((self.profile, self.freq, line)) self.writer.send(self.profile, line)
except (OSError, AttributeError): except (OSError, AttributeError):
decoder.stdout.flush() decoder.stdout.flush()
# TODO uncouple parsing from the output so that decodes can still go to the map and the spotters # TODO uncouple parsing from the output so that decodes can still go to the map and the spotters

View File

@ -47,8 +47,7 @@ class WaveFile(object):
class AudioWriter(object): class AudioWriter(object):
def __init__(self, active_dsp, outputWriter, interval, profiles: List[AudioChopperProfile]): def __init__(self, outputWriter, interval, profiles: List[AudioChopperProfile]):
self.dsp = active_dsp
self.outputWriter = outputWriter self.outputWriter = outputWriter
self.interval = interval self.interval = interval
self.profiles = profiles self.profiles = profiles
@ -102,7 +101,7 @@ class AudioWriter(object):
logger.exception("Error while linking job files") logger.exception("Error while linking job files")
continue continue
job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq()) job = QueueJob(profile, self.outputWriter, filename)
try: try:
DecoderQueue.getSharedInstance().put(job) DecoderQueue.getSharedInstance().put(job)
except Full: except Full:

View File

@ -17,9 +17,11 @@ from owrx.websocket import Handler
from queue import Queue, Full, Empty from queue import Queue, Full, Empty
from js8py import Js8Frame from js8py import Js8Frame
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from io import BytesIO
import json import json
import threading import threading
import struct import struct
import pickle
import logging import logging
@ -417,7 +419,12 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
self.send({"type": "metadata", "value": metadata}) self.send({"type": "metadata", "value": metadata})
def write_wsjt_message(self, message): def write_wsjt_message(self, message):
self.send({"type": "wsjt_message", "value": message}) io = BytesIO(message.tobytes())
try:
while True:
self.send({"type": "wsjt_message", "value": pickle.load(io)})
except EOFError:
pass
def write_dial_frequencies(self, frequencies): def write_dial_frequencies(self, frequencies):
self.send({"type": "dial_frequencies", "value": frequencies}) self.send({"type": "dial_frequencies", "value": frequencies})

View File

@ -9,12 +9,13 @@ from owrx.property.validators import OrValidator, RegexValidator, BoolValidator
from owrx.modes import Modes from owrx.modes import Modes
from csdr.output import Output from csdr.output import Output
from csdr.chain import Chain from csdr.chain import Chain
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, SecondaryDemodulator
from csdr.chain.selector import Selector from csdr.chain.selector import Selector
from csdr.chain.clientaudio import ClientAudioChain from csdr.chain.clientaudio import ClientAudioChain
from csdr.chain.analog import NFm, WFm, Am, Ssb from csdr.chain.analog import NFm, WFm, Am, Ssb
from csdr.chain.digiham import DigihamChain, Dmr, Dstar, Nxdn, Ysf from csdr.chain.digiham import DigihamChain, Dmr, Dstar, Nxdn, Ysf
from csdr.chain.fft import FftChain from csdr.chain.fft import FftChain
from csdr.chain.digimodes import AudioChopperDemodulator
from pycsdr.modules import Buffer, Writer from pycsdr.modules import Buffer, Writer
from pycsdr.types import Format from pycsdr.types import Format
from typing import Union from typing import Union
@ -34,22 +35,45 @@ class ClientDemodulatorChain(Chain):
self.selector = Selector(sampleRate, outputRate, 0.0) self.selector = Selector(sampleRate, outputRate, 0.0)
self.selector.setBandpass(-4000, 4000) self.selector.setBandpass(-4000, 4000)
self.selectorBuffer = Buffer(Format.COMPLEX_FLOAT) self.selectorBuffer = Buffer(Format.COMPLEX_FLOAT)
self.audioBuffer = None
self.demodulator = demod self.demodulator = demod
self.secondaryDemodulator = None
inputRate = demod.getFixedAudioRate() if isinstance(demod, FixedAudioRateChain) else outputRate inputRate = demod.getFixedAudioRate() if isinstance(demod, FixedAudioRateChain) else outputRate
oRate = hdOutputRate if isinstance(demod, HdAudio) else outputRate oRate = hdOutputRate if isinstance(demod, HdAudio) else outputRate
self.clientAudioChain = ClientAudioChain(demod.getOutputFormat(), inputRate, oRate, audioCompression) self.clientAudioChain = ClientAudioChain(demod.getOutputFormat(), inputRate, oRate, audioCompression)
self.secondaryFftChain = None self.secondaryFftChain = None
self.metaWriter = None self.metaWriter = None
self.secondaryFftWriter = None
self.secondaryWriter = None
self.squelchLevel = -150 self.squelchLevel = -150
super().__init__([self.selector, self.demodulator, self.clientAudioChain]) 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: Union[Buffer, None] = None) -> None: def _connect(self, w1, w2, buffer: Union[Buffer, None] = None) -> None:
if w1 is self.selector: if w1 is self.selector:
super()._connect(w1, w2, self.selectorBuffer) 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:
self.secondaryDemodulator.setReader(self.audioBuffer.getReader())
super()._connect(w1, w2, self.audioBuffer)
else: else:
super()._connect(w1, w2) super()._connect(w1, w2)
def setDemodulator(self, demodulator: BaseDemodulatorChain): def setDemodulator(self, demodulator: BaseDemodulatorChain):
if demodulator is self.demodulator:
return
try: try:
self.clientAudioChain.setFormat(demodulator.getOutputFormat()) self.clientAudioChain.setFormat(demodulator.getOutputFormat())
except ValueError: except ValueError:
@ -68,11 +92,15 @@ class ClientDemodulatorChain(Chain):
if isinstance(self.demodulator, FixedIfSampleRateChain): if isinstance(self.demodulator, FixedIfSampleRateChain):
self.selector.setOutputRate(self.demodulator.getFixedIfSampleRate()) self.selector.setOutputRate(self.demodulator.getFixedIfSampleRate())
elif self.secondaryDemodulator is not None and isinstance(self.secondaryDemodulator, FixedAudioRateChain):
self.selector.setOutputRate(self.secondaryDemodulator.getFixedAudioRate())
else: else:
self.selector.setOutputRate(outputRate) self.selector.setOutputRate(outputRate)
if isinstance(self.demodulator, FixedAudioRateChain): if isinstance(self.demodulator, FixedAudioRateChain):
self.clientAudioChain.setInputRate(self.demodulator.getFixedAudioRate()) self.clientAudioChain.setInputRate(self.demodulator.getFixedAudioRate())
elif self.secondaryDemodulator is not None and isinstance(self.secondaryDemodulator, FixedAudioRateChain):
self.clientAudioChain.setInputRate(self.secondaryDemodulator.getFixedAudioRate())
else: else:
self.clientAudioChain.setInputRate(outputRate) self.clientAudioChain.setInputRate(outputRate)
@ -86,6 +114,39 @@ class ClientDemodulatorChain(Chain):
if self.metaWriter is not None and isinstance(demodulator, DigihamChain): if self.metaWriter is not None and isinstance(demodulator, DigihamChain):
demodulator.setMetaWriter(self.metaWriter) demodulator.setMetaWriter(self.metaWriter)
def setSecondaryDemodulator(self, demod: Union[SecondaryDemodulator, None]):
if demod is self.secondaryDemodulator:
return
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.stop()
self.secondaryDemodulator = demod
if self.secondaryDemodulator is not None and 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!")
else:
rate = self.secondaryDemodulator.getFixedAudioRate()
else:
rate = self.outputRate
self.selector.setOutputRate(rate)
self.clientAudioChain.setInputRate(rate)
if self.secondaryDemodulator is not None:
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:
# TODO eliminate constants
self.secondaryFftChain = FftChain(self.outputRate, 2048, 0.3, 9, "adpcm")
self.secondaryFftChain.setReader(self.selectorBuffer.getReader())
self.secondaryFftChain.setWriter(self.secondaryFftWriter)
def setLowCut(self, lowCut): def setLowCut(self, lowCut):
self.selector.setLowCut(lowCut) self.selector.setLowCut(lowCut)
@ -153,19 +214,21 @@ class ClientDemodulatorChain(Chain):
if isinstance(self.demodulator, DigihamChain): if isinstance(self.demodulator, DigihamChain):
self.demodulator.setMetaWriter(self.metaWriter) self.demodulator.setMetaWriter(self.metaWriter)
def setSecondaryFftWriter(self, writer: Union[Writer, None]) -> None: def setSecondaryFftWriter(self, writer: Writer) -> None:
if writer is None: if writer is self.secondaryFftWriter:
if self.secondaryFftChain is not None: return
self.secondaryFftChain.stop() self.secondaryFftWriter = writer
self.secondaryFftChain = None
else:
if self.secondaryFftChain is None:
# TODO eliminate constants
self.secondaryFftChain = FftChain(self.outputRate, 2048, 0.3, 9, "adpcm")
self.secondaryFftChain.setReader(self.selectorBuffer.getReader())
if self.secondaryFftChain is not None:
self.secondaryFftChain.setWriter(writer) 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 setSecondaryFftSize(self, size: int) -> None: def setSecondaryFftSize(self, size: int) -> None:
# TODO # TODO
pass pass
@ -186,7 +249,6 @@ class DspManager(Output, SdrSourceEventClient):
self.sdrSource = sdrSource self.sdrSource = sdrSource
self.parsers = { self.parsers = {
"meta": MetaParser(self.handler), "meta": MetaParser(self.handler),
"wsjt_demod": WsjtParser(self.handler),
"packet_demod": AprsParser(self.handler), "packet_demod": AprsParser(self.handler),
"pocsag_demod": PocsagParser(self.handler), "pocsag_demod": PocsagParser(self.handler),
"js8_demod": Js8Parser(self.handler), "js8_demod": Js8Parser(self.handler),
@ -260,6 +322,18 @@ class DspManager(Output, SdrSourceEventClient):
self.chain.setMetaWriter(buffer) self.chain.setMetaWriter(buffer)
self.wireOutput("meta", buffer) self.wireOutput("meta", buffer)
# wire secondary FFT
# TODO format is different depending on compression
buffer = Buffer(Format.CHAR)
self.chain.setSecondaryFftWriter(buffer)
self.wireOutput("secondary_fft", buffer)
# wire secondary demodulator
buffer = Buffer(Format.CHAR)
self.chain.setSecondaryWriter(buffer)
# TODO there's multiple outputs depending on the modulation right now
self.wireOutput("wsjt_demod", buffer)
def set_dial_freq(changes): def set_dial_freq(changes):
if ( if (
"center_freq" not in self.props "center_freq" not in self.props
@ -380,16 +454,21 @@ class DspManager(Output, SdrSourceEventClient):
} }
) )
def setSecondaryDemodulator(self, mod): def _getSecondaryDemodulator(self, mod):
if not mod: if isinstance(mod, SecondaryDemodulator):
self.chain.setSecondaryFftWriter(None) return mod
else: # TODO add remaining modes
buffer = Buffer(Format.CHAR) if mod in ["ft8"]:
self.chain.setSecondaryFftWriter(buffer) return AudioChopperDemodulator(mod, WsjtParser())
self.wireOutput("secondary_fft", buffer) return None
def setSecondaryDemodulator(self, mod):
demodulator = self._getSecondaryDemodulator(mod)
if not demodulator:
self.chain.setSecondaryDemodulator(None)
else:
self.sendSecondaryConfig() self.sendSecondaryConfig()
#self.chain.setSecondaryDemodulator(mod) self.chain.setSecondaryDemodulator(demodulator)
def setAudioCompression(self, comp): def setAudioCompression(self, comp):
try: try:
@ -420,6 +499,7 @@ class DspManager(Output, SdrSourceEventClient):
"smeter": self.handler.write_s_meter_level, "smeter": self.handler.write_s_meter_level,
"secondary_fft": self.handler.write_secondary_fft, "secondary_fft": self.handler.write_secondary_fft,
"secondary_demod": self.handler.write_secondary_demod, "secondary_demod": self.handler.write_secondary_demod,
"wsjt_demod": self.handler.write_wsjt_message,
} }
for demod, parser in self.parsers.items(): for demod, parser in self.parsers.items():
writers[demod] = parser.parse writers[demod] = parser.parse

View File

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