From 5fa7e9d69563c4eb18d5bc6f68abc71aaa5bd5d9 Mon Sep 17 00:00:00 2001 From: Marat Fayzullin Date: Mon, 2 Sep 2024 12:33:41 -0400 Subject: [PATCH] Updating with the latest OWRX+ HDRadio changes. --- csdr/chain/dablin.py | 6 +- csdr/chain/demodulator.py | 4 +- csdr/chain/hdradio.py | 34 ++++++-- csdr/module/hdradio.py | 171 +++++++++++++++++++++++++++++--------- htdocs/css/openwebrx.css | 46 ++++++++++ htdocs/index.html | 1 + htdocs/lib/Demodulator.js | 8 +- htdocs/lib/MetaPanel.js | 79 +++++++++++++++++- owrx/dsp.py | 25 ++---- owrx/modes.py | 7 +- 10 files changed, 301 insertions(+), 80 deletions(-) diff --git a/csdr/chain/dablin.py b/csdr/chain/dablin.py index c5a32116..2f6a2794 100644 --- a/csdr/chain/dablin.py +++ b/csdr/chain/dablin.py @@ -1,5 +1,5 @@ from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, \ - MetaProvider, DabServiceSelector, DialFrequencyReceiver + MetaProvider, AudioServiceSelector, DialFrequencyReceiver from csdr.module import PickleModule from csdreti.modules import EtiDecoder from owrx.dab.dablin import DablinModule @@ -58,7 +58,7 @@ class MetaProcessor(PickleModule): self.shifter.setRate(0) -class Dablin(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, MetaProvider, DabServiceSelector, DialFrequencyReceiver): +class Dablin(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, MetaProvider, AudioServiceSelector, DialFrequencyReceiver): def __init__(self): shift = Shift(0) self.decoder = EtiDecoder() @@ -99,7 +99,7 @@ class Dablin(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, def setMetaWriter(self, writer: Writer) -> None: self.processor.setWriter(writer) - def setDabServiceId(self, serviceId: int) -> None: + def setAudioServiceId(self, serviceId: int) -> None: self.decoder.setServiceIdFilter([serviceId]) self.dablin.setDabServiceId(serviceId) diff --git a/csdr/chain/demodulator.py b/csdr/chain/demodulator.py index 7b4506cc..40a6c015 100644 --- a/csdr/chain/demodulator.py +++ b/csdr/chain/demodulator.py @@ -55,9 +55,9 @@ class RdsChain(ABC): pass -class DabServiceSelector(ABC): +class AudioServiceSelector(ABC): @abstractmethod - def setDabServiceId(self, serviceId: int) -> None: + def setAudioServiceId(self, serviceId: int) -> None: pass diff --git a/csdr/chain/hdradio.py b/csdr/chain/hdradio.py index ceaa519b..7584bc89 100644 --- a/csdr/chain/hdradio.py +++ b/csdr/chain/hdradio.py @@ -1,34 +1,50 @@ -from csdr.chain.demodulator import FixedIfSampleRateChain, BaseDemodulatorChain, FixedAudioRateChain, DialFrequencyReceiver +from csdr.chain.demodulator import FixedIfSampleRateChain, BaseDemodulatorChain, FixedAudioRateChain, DialFrequencyReceiver, HdAudio, MetaProvider, AudioServiceSelector from csdr.module.hdradio import HdRadioModule -from pycsdr.modules import Convert, Agc, Downmix, Writer +from pycsdr.modules import Convert, Agc, Downmix, Writer, Buffer, Throttle from pycsdr.types import Format +from typing import Optional + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) -class HdRadio(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver): +class HdRadio(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, MetaProvider, DialFrequencyReceiver, AudioServiceSelector): def __init__(self, program: int = 0): self.hdradio = HdRadioModule(program = program) workers = [ Agc(Format.COMPLEX_FLOAT), Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), self.hdradio, + Throttle(Format.SHORT, 44100 * 2), Downmix(Format.SHORT), ] super().__init__(workers) - def getFixedIfSampleRate(self): + def getFixedIfSampleRate(self) -> int: return self.hdradio.getFixedAudioRate() - def getFixedAudioRate(self): + def getFixedAudioRate(self) -> int: return 44100 + def supportsSquelch(self) -> bool: + return False + # Set metadata consumer def setMetaWriter(self, writer: Writer) -> None: self.hdradio.setMetaWriter(writer) # Change program - def setProgram(self, program: int) -> None: - self.hdradio.setProgram(program) + def setAudioServiceId(self, serviceId: int) -> None: + self.hdradio.setProgram(serviceId) def setDialFrequency(self, frequency: int) -> None: - # Clear station metadata when changing frequency - pass + self.hdradio.setFrequency(frequency) + + def _connect(self, w1, w2, buffer: Optional[Buffer] = None) -> None: + if isinstance(w2, Throttle): + # Audio data comes in in bursts, so we use a throttle + # and 10x the default buffer size here + buffer = Buffer(Format.SHORT, 2621440) + return super()._connect(w1, w2, buffer) diff --git a/csdr/module/hdradio.py b/csdr/module/hdradio.py index a7dde19a..1aa08ef0 100644 --- a/csdr/module/hdradio.py +++ b/csdr/module/hdradio.py @@ -2,23 +2,56 @@ from csdr.module.nrsc5 import NRSC5, Mode, EventType, ComponentType, Access from csdr.module import ThreadModule from pycsdr.modules import Writer from pycsdr.types import Format +from owrx.map import Map, LatLngLocation import logging import threading +import pickle logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) +class StationLocation(LatLngLocation): + def __init__(self, data): + super().__init__(data["lat"], data["lon"]) + # Complete station data + self.data = data + + def getSymbolData(self, symbol, table): + return {"symbol": symbol, "table": table, "index": ord(symbol) - 33, "tableindex": ord(table) - 33} + + def __dict__(self): + # Return APRS-like dictionary object with "antenna tower" symbol + res = super(StationLocation, self).__dict__() + res["symbol"] = self.getSymbolData('r', '/') + res.update(self.data) + return res + + class HdRadioModule(ThreadModule): def __init__(self, program: int = 0, amMode: bool = False): + self.program = program + self.frequency = 0 + self.metaLock = threading.Lock() self.metaWriter = None - self.program = program - self.radio = NRSC5(lambda evt_type, evt: self.callback(evt_type, evt)) + self.meta = {} + self._clearMeta() + # Initialize and start NRSC5 decoder + self.radio = NRSC5(lambda evt_type, evt: self.callback(evt_type, evt)) + self.radio.open_pipe() + self.radio.start() # Crashes things? # self.radio.set_mode(Mode.AM if amMode else Mode.FM) super().__init__() + def __del__(self): + # Make sure NRSC5 object is truly destroyed + if self.radio is not None: + self.radio.stop() + self.radio.close() + self.radio = None + def getInputFormat(self) -> Format: return Format.COMPLEX_SHORT @@ -30,53 +63,101 @@ class HdRadioModule(ThreadModule): # Change program def setProgram(self, program: int) -> None: - self.program = program + if program != self.program: + self.program = program + logger.info("Now playing program #{0}".format(self.program)) + # Clear program metadata + with self.metaLock: + self.meta["program"] = self.program + if "title" in self.meta: + del self.meta["title"] + if "artist" in self.meta: + del self.meta["artist"] + if "album" in self.meta: + del self.meta["album"] + if "genre" in self.meta: + del self.meta["genre"] + self._writeMeta() + + # Change frequency + def setFrequency(self, frequency: int) -> None: + if frequency != self.frequency: + self.frequency = frequency + self.program = 0 + logger.info("Now playing program #{0} at {1}MHz".format(self.program, self.frequency / 1000000)) + self._clearMeta() # Set metadata consumer def setMetaWriter(self, writer: Writer) -> None: self.metaWriter = writer # Write metadata - def _writeMeta(self, data) -> None: - if data and self.metaWriter: - self.metaWriter.write(data) + def _writeMeta(self) -> None: + if self.meta and self.metaWriter: + logger.debug("Metadata: {0}".format(self.meta)) + self.metaWriter.write(pickle.dumps(self.meta)) + + # Clear all metadata + def _clearMeta(self) -> None: + with self.metaLock: + self.meta = { + "mode" : "HDR", + "frequency" : self.frequency, + "program" : self.program + } + self._writeMeta() + + # Update existing metadata + def _updateMeta(self, data) -> None: + # Update station location on the map + if "station" in data and "lat" in data and "lon" in data: + loc = StationLocation(data) + Map.getSharedInstance().updateLocation(data["station"], loc, "HDR") + # Update any new or different values + with self.metaLock: + changes = 0 + for key in data.keys(): + if key not in self.meta or self.meta[key] != data[key]: + self.meta[key] = data[key] + changes = changes + 1 + # If anything changed, write metadata to the buffer + if changes > 0: + self._writeMeta() def run(self): # Start NRSC5 decoder logger.debug("Starting NRSC5 decoder...") - self.radio.open_pipe() - self.radio.start() # Main loop logger.debug("Running the loop...") while self.doRun: data = self.reader.read() - if data is None: + if data is None or len(data) == 0: self.doRun = False - break - try: - self.radio.pipe_samples_cs16(data.tobytes()) - except Exception as exptn: - logger.debug("Exception: %s" % str(exptn)) + else: + try: + self.radio.pipe_samples_cs16(data.tobytes()) + except Exception as exptn: + logger.debug("Exception: %s" % str(exptn)) # Stop NRSC5 decoder logger.debug("Stopping NRSC5 decoder...") self.radio.stop() self.radio.close() + self.radio = None logger.debug("DONE.") def callback(self, evt_type, evt): - if evt_type == EventType.AUDIO: + if evt_type == EventType.LOST_DEVICE: + logger.info("Lost device") + self.doRun = False + elif evt_type == EventType.AUDIO: if evt.program == self.program: - #logger.info("Audio data for program %d", evt.program) self.writer.write(evt.data) elif evt_type == EventType.HDC: if evt.program == self.program: #logger.info("HDC data for program %d", evt.program) pass - elif evt_type == EventType.LOST_DEVICE: - logger.info("Lost device") - self.doRun = False elif evt_type == EventType.IQ: logger.info("IQ data") elif evt_type == EventType.SYNC: @@ -89,7 +170,7 @@ class HdRadioModule(ThreadModule): logger.info("BER: %.6f", evt.cber) elif evt_type == EventType.ID3: if evt.program == self.program: - # Collect metadata + # Collect new metadata meta = {} if evt.title: meta["title"] = evt.title @@ -98,13 +179,13 @@ class HdRadioModule(ThreadModule): if evt.album: meta["album"] = evt.album if evt.genre: - meta["genre"] = evt.album + meta["genre"] = evt.genre if evt.ufid: logger.info("Unique file identifier: %s %s", evt.ufid.owner, evt.ufid.id) if evt.xhdr: logger.info("XHDR: param=%s mime=%s lot=%s", evt.xhdr.param, evt.xhdr.mime, evt.xhdr.lot) - # Output collected metadata - self._writeMeta(meta) + # Update existing metadata + self._updateMeta(meta) elif evt_type == EventType.SIG: for service in evt: logger.info("SIG Service: type=%s number=%s name=%s", @@ -130,8 +211,11 @@ class HdRadioModule(ThreadModule): logger.info("LOT file: port=%04X lot=%s name=%s size=%s mime=%s expiry=%s", evt.port, evt.lot, evt.name, len(evt.data), evt.mime, time_str) elif evt_type == EventType.SIS: - # Collect metadata - meta = {} + # Collect new metadata + meta = { + "audio_services" : [], + "data_services" : [] + } if evt.country_code: meta["country"] = evt.country_code meta["fcc_id"] = evt.fcc_facility_id @@ -146,17 +230,30 @@ class HdRadioModule(ThreadModule): if evt.latitude: meta["lat"] = evt.latitude meta["lon"] = evt.longitude - meta["alt"] = evt.altitude + meta["altitude"] = round(evt.altitude) for audio_service in evt.audio_services: - logger.info("Audio program %s: %s, type: %s, sound experience %s", - audio_service.program, - "public" if audio_service.access == Access.PUBLIC else "restricted", - self.radio.program_type_name(audio_service.type), - audio_service.sound_exp) + #logger.info("Audio program %s: %s, type: %s, sound experience %s", + # audio_service.program, + # "public" if audio_service.access == Access.PUBLIC else "restricted", + # self.radio.program_type_name(audio_service.type), + # audio_service.sound_exp) + meta["audio_services"] += [{ + "id" : audio_service.program, + "type" : audio_service.type.value, + "name" : self.radio.program_type_name(audio_service.type), + "public" : audio_service.access == Access.PUBLIC, + "experience" : audio_service.sound_exp + }] for data_service in evt.data_services: - logger.info("Data service: %s, type: %s, MIME type %03x", - "public" if data_service.access == Access.PUBLIC else "restricted", - self.radio.service_data_type_name(data_service.type), - data_service.mime_type) - # Output collected metadata - self._writeMeta(meta) + #logger.info("Data service: %s, type: %s, MIME type %03x", + # "public" if data_service.access == Access.PUBLIC else "restricted", + # self.radio.service_data_type_name(data_service.type), + # data_service.mime_type) + meta["data_services"] += [{ + "mime" : data_service.mime_type, + "type" : data_service.type.value, + "name" : self.radio.service_data_type_name(data_service.type), + "public" : data_service.access == Access.PUBLIC + }] + # Update existing metadata + self._updateMeta(meta) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index fae76378..dde1487a 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -1381,6 +1381,52 @@ img.openwebrx-mirror-img content: "🔗 "; } +#openwebrx-panel-metadata-hdr { + width: 350px; + max-height: 300px; + padding: 10px 10px 10px 10px; +} + +.hdr-container { + width: 100%; + text-align: center; + overflow: hidden auto; +} + +.hdr-container .hdr-station { + font-weight: bold; + font-size: 18pt; +} + +.hdr-container .hdr-top-line, +.hdr-container .hdr-bottom-line, +.hdr-container .hdr-station, +.hdr-container .hdr-message { + min-height: 1lh; +} + +.hdr-container .hdr-top-line { + padding: 0 0 5px 0; +} + +.hdr-container .hdr-bottom-line { + padding: 5px 0 0 0; +} + +.hdr-container .hdr-station, +.hdr-container .hdr-message { + padding: 5px 0 5px 0; +} + +.hdr-container .hdr-selector, +.hdr-container .hdr-genre { + float: left; +} + +.hdr-container .hdr-identifier { + float: right; +} + #openwebrx-panel-metadata-dab { width: 300px; } diff --git a/htdocs/index.html b/htdocs/index.html index 1e1c4c43..be713261 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -153,6 +153,7 @@ +
diff --git a/htdocs/lib/Demodulator.js b/htdocs/lib/Demodulator.js index 601bc86b..1a1a61d7 100644 --- a/htdocs/lib/Demodulator.js +++ b/htdocs/lib/Demodulator.js @@ -239,7 +239,7 @@ function Demodulator(offset_frequency, modulation) { this.filter = new Filter(this); this.squelch_level = -150; this.dmr_filter = 3; - this.dab_service_id = 0; + this.audio_service_id = 0; this.started = false; this.state = {}; this.secondary_demod = false; @@ -328,7 +328,7 @@ Demodulator.prototype.set = function () { //this function sends demodulator par "offset_freq": this.offset_frequency, "mod": this.modulation, "dmr_filter": this.dmr_filter, - "dab_service_id": this.dab_service_id, + "audio_service_id": this.audio_service_id, "squelch_level": this.squelch_level, "secondary_mod": this.secondary_demod, "secondary_offset_freq": this.secondary_offset_freq @@ -366,8 +366,8 @@ Demodulator.prototype.setDmrFilter = function(dmr_filter) { this.set(); }; -Demodulator.prototype.setDabServiceId = function(dab_service_id) { - this.dab_service_id = dab_service_id; +Demodulator.prototype.setAudioServiceId = function(audio_service_id) { + this.audio_service_id = audio_service_id; this.set(); } diff --git a/htdocs/lib/MetaPanel.js b/htdocs/lib/MetaPanel.js index 584fadf6..d6c80af9 100644 --- a/htdocs/lib/MetaPanel.js +++ b/htdocs/lib/MetaPanel.js @@ -471,7 +471,6 @@ WfmMetaPanel.prototype.update = function(data) { if ('info.weather' in tags) { this.radiotext_plus.weather = tags['info.weather']; } - } if ('radiotext' in data && !this.radiotext_plus) { @@ -550,6 +549,80 @@ WfmMetaPanel.prototype.clear = function() { this.radiotext_plus = false; }; +function HdrMetaPanel(el) { + MetaPanel.call(this, el); + this.modes = ['HDR']; + + // Create info panel + var $container = $( + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + ); + + $(this.el).append($container); + + var $select = $('#hdr-program-id'); + $select.hide(); + $select.on("change", function() { + var id = parseInt($(this).val()); + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().setAudioServiceId(id); + }); +} + +HdrMetaPanel.prototype = new MetaPanel(); + +HdrMetaPanel.prototype.update = function(data) { + if (!this.isSupported(data)) return; + + // Convert FCC ID to hexadecimal + var fcc_id = ''; + if ('fcc_id' in data) { + fcc_id = data.fcc_id.toString(16).toUpperCase(); + fcc_id = '0x' + ('0000' + fcc_id).slice(-4); + fcc_id = ('country' in data? data.country + ':' : '') + fcc_id; + } + + // Update panel + var $el = $(this.el); + $el.find('.hdr-identifier').text(fcc_id); + $el.find('.hdr-station').text(data.station || ''); + $el.find('.hdr-message').text(data.alert || data.message || data.slogan || ''); + $el.find('.hdr-title').text(data.title || ''); + $el.find('.hdr-artist').text(data.artist || ''); + $el.find('.hdr-genre').text(data.genre || ''); + $el.find('.hdr-album').text(data.album || ''); + + // Update program selector + var $select = $('#hdr-program-id'); + if (data.audio_services && data.audio_services.length) { + $select.html(data.audio_services.map(function(pgm) { + var selected = data.program == pgm.id? ' selected' : ''; + return ''; + }).join()); + $select.show(); + } else { + $select.html(''); + $select.hide(); + } +}; + +HdrMetaPanel.prototype.isSupported = function(data) { + return this.modes.includes(data.mode); +}; + function DabMetaPanel(el) { MetaPanel.call(this, el); var me = this; @@ -558,7 +631,7 @@ function DabMetaPanel(el) { this.$select = $(''); this.$select.on("change", function() { me.service_id = parseInt($(this).val()); - $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().setDabServiceId(me.service_id); + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().setAudioServiceId(me.service_id); }); var $container = $( '
' + @@ -580,7 +653,6 @@ DabMetaPanel.prototype.isSupported = function(data) { return this.modes.includes(data.mode); } - DabMetaPanel.prototype.update = function(data) { if (!this.isSupported(data)) return; @@ -633,6 +705,7 @@ MetaPanel.types = { m17: M17MetaPanel, wfm: WfmMetaPanel, dab: DabMetaPanel, + hdr: HdrMetaPanel, }; $.fn.metaPanel = function() { diff --git a/owrx/dsp.py b/owrx/dsp.py index c8c6fe5c..641cdd2b 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -5,7 +5,7 @@ from owrx.modes import Modes, DigitalMode from csdr.chain import Chain from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, \ SecondaryDemodulator, DialFrequencyReceiver, MetaProvider, SlotFilterChain, SecondarySelectorChain, \ - DeemphasisTauChain, DemodulatorError, RdsChain, DabServiceSelector + DeemphasisTauChain, DemodulatorError, RdsChain, AudioServiceSelector from csdr.chain.selector import Selector, SecondarySelector from csdr.chain.clientaudio import ClientAudioChain from csdr.chain.fft import FftChain @@ -330,10 +330,10 @@ class ClientDemodulatorChain(Chain): return self.demodulator.setSlotFilter(filter) - def setDabServiceId(self, serviceId: int) -> None: - if not isinstance(self.demodulator, DabServiceSelector): + def setAudioServiceId(self, serviceId: int) -> None: + if not isinstance(self.demodulator, AudioServiceSelector): return - self.demodulator.setDabServiceId(serviceId) + self.demodulator.setAudioServiceId(serviceId) def setSecondaryFftSize(self, size: int) -> None: if size == self.secondaryFftSize: @@ -429,7 +429,7 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) "mod": ModulationValidator(), "secondary_offset_freq": "int", "dmr_filter": "int", - "dab_service_id": "int", + "audio_service_id": "int", } self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators) @@ -510,7 +510,7 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) self.props.wireProperty("high_cut", self.setHighCut), self.props.wireProperty("mod", self.setDemodulator), self.props.wireProperty("dmr_filter", self.chain.setSlotFilter), - self.props.wireProperty("dab_service_id", self.chain.setDabServiceId), + self.props.wireProperty("audio_service_id", self.chain.setAudioServiceId), self.props.wireProperty("wfm_deemphasis_tau", self.chain.setWfmDeemphasisTau), self.props.wireProperty("wfm_rds_rbds", self.chain.setRdsRbds), self.props.wireProperty("secondary_mod", self.setSecondaryDemodulator), @@ -573,18 +573,9 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) elif demod == "nxdn": from csdr.chain.digiham import Nxdn return Nxdn(self.props["digital_voice_codecserver"]) - elif demod == "hdr1": + elif demod == "hdr": from csdr.chain.hdradio import HdRadio - return HdRadio(program = 0) - elif demod == "hdr2": - from csdr.chain.hdradio import HdRadio - return HdRadio(program = 1) - elif demod == "hdr3": - from csdr.chain.hdradio import HdRadio - return HdRadio(program = 2) - elif demod == "hdr4": - from csdr.chain.hdradio import HdRadio - return HdRadio(program = 3) + return HdRadio() elif demod == "m17": from csdr.chain.m17 import M17 return M17() diff --git a/owrx/modes.py b/owrx/modes.py index a65b386e..61123775 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -134,11 +134,8 @@ class Modes(object): "freedv", "FreeDV", bandpass=Bandpass(300, 3000), requirements=["digital_voice_freedv"], squelch=False ), AnalogMode("drm", "DRM", bandpass=Bandpass(-5000, 5000), requirements=["drm"], squelch=False), - AnalogMode("dab", "DAB", bandpass=None, ifRate=2.048e6, requirements=["dab"], squelch=False), - AnalogMode("hdr1", "HDR1", bandpass=None, ifRate=744188, requirements=["hdradio"], squelch=False), - AnalogMode("hdr2", "HDR2", bandpass=Bandpass(-200000, 200000), requirements=["hdradio"], squelch=False), - AnalogMode("hdr3", "HDR3", bandpass=Bandpass(-200000, 200000), requirements=["hdradio"], squelch=False), - AnalogMode("hdr4", "HDR4", bandpass=Bandpass(-200000, 200000), requirements=["hdradio"], squelch=False), + AnalogMode("dab", "DAB", bandpass=None, ifRate=2048000, requirements=["dab"], squelch=False), + AnalogMode("hdr", "HDR", bandpass=Bandpass(-200000, 200000), requirements=["hdradio"], squelch=False), DigitalMode("bpsk31", "BPSK31", underlying=["usb"]), DigitalMode("bpsk63", "BPSK63", underlying=["usb"]), DigitalMode("rtty170", "RTTY 45/170", underlying=["usb", "lsb"]),