From c7f0d52a21201dda7f75e8477c0686186ba7b4e4 Mon Sep 17 00:00:00 2001 From: Marat Fayzullin Date: Fri, 15 Mar 2024 23:21:23 -0400 Subject: [PATCH] Porting initial HDRadio support to 1.3-devel. --- csdr/chain/hdradio.py | 34 +++ csdr/module/hdradio.py | 162 +++++++++++ csdr/module/nrsc5.py | 617 +++++++++++++++++++++++++++++++++++++++++ owrx/dsp.py | 12 + owrx/feature.py | 9 + owrx/modes.py | 4 + 6 files changed, 838 insertions(+) create mode 100644 csdr/chain/hdradio.py create mode 100644 csdr/module/hdradio.py create mode 100644 csdr/module/nrsc5.py diff --git a/csdr/chain/hdradio.py b/csdr/chain/hdradio.py new file mode 100644 index 00000000..ceaa519b --- /dev/null +++ b/csdr/chain/hdradio.py @@ -0,0 +1,34 @@ +from csdr.chain.demodulator import FixedIfSampleRateChain, BaseDemodulatorChain, FixedAudioRateChain, DialFrequencyReceiver +from csdr.module.hdradio import HdRadioModule +from pycsdr.modules import Convert, Agc, Downmix, Writer +from pycsdr.types import Format + + +class HdRadio(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver): + 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, + Downmix(Format.SHORT), + ] + super().__init__(workers) + + def getFixedIfSampleRate(self): + return self.hdradio.getFixedAudioRate() + + def getFixedAudioRate(self): + return 44100 + + # 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 setDialFrequency(self, frequency: int) -> None: + # Clear station metadata when changing frequency + pass diff --git a/csdr/module/hdradio.py b/csdr/module/hdradio.py new file mode 100644 index 00000000..a7dde19a --- /dev/null +++ b/csdr/module/hdradio.py @@ -0,0 +1,162 @@ +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 + +import logging +import threading + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class HdRadioModule(ThreadModule): + def __init__(self, program: int = 0, amMode: bool = False): + self.metaWriter = None + self.program = program + self.radio = NRSC5(lambda evt_type, evt: self.callback(evt_type, evt)) +# Crashes things? +# self.radio.set_mode(Mode.AM if amMode else Mode.FM) + super().__init__() + + def getInputFormat(self) -> Format: + return Format.COMPLEX_SHORT + + def getOutputFormat(self) -> Format: + return Format.SHORT + + def getFixedAudioRate(self) -> int: + return 744188 # 744187.5 + + # Change program + def setProgram(self, program: int) -> None: + self.program = program + + # 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 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: + self.doRun = False + break + 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() + logger.debug("DONE.") + + def callback(self, evt_type, evt): + if 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: + logger.info("Synchronized") + elif evt_type == EventType.LOST_SYNC: + logger.info("Lost synchronization") + elif evt_type == EventType.MER: + logger.info("MER: %.1f dB (lower), %.1f dB (upper)", evt.lower, evt.upper) + elif evt_type == EventType.BER: + logger.info("BER: %.6f", evt.cber) + elif evt_type == EventType.ID3: + if evt.program == self.program: + # Collect metadata + meta = {} + if evt.title: + meta["title"] = evt.title + if evt.artist: + meta["artist"] = evt.artist + if evt.album: + meta["album"] = evt.album + if evt.genre: + meta["genre"] = evt.album + 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) + elif evt_type == EventType.SIG: + for service in evt: + logger.info("SIG Service: type=%s number=%s name=%s", + service.type, service.number, service.name) + for component in service.components: + if component.type == ComponentType.AUDIO: + logger.info(" Audio component: id=%s port=%04X type=%s mime=%s", + component.id, component.audio.port, + component.audio.type, component.audio.mime) + elif component.type == ComponentType.DATA: + logger.info(" Data component: id=%s port=%04X service_data_type=%s type=%s mime=%s", + component.id, component.data.port, + component.data.service_data_type, + component.data.type, component.data.mime) + elif evt_type == EventType.STREAM: + logger.info("Stream data: port=%04X seq=%04X mime=%s size=%s", + evt.port, evt.seq, evt.mime, len(evt.data)) + elif evt_type == EventType.PACKET: + logger.info("Packet data: port=%04X seq=%04X mime=%s size=%s", + evt.port, evt.seq, evt.mime, len(evt.data)) + elif evt_type == EventType.LOT: + time_str = evt.expiry_utc.strftime("%Y-%m-%dT%H:%M:%SZ") + 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 = {} + if evt.country_code: + meta["country"] = evt.country_code + meta["fcc_id"] = evt.fcc_facility_id + if evt.name: + meta["station"] = evt.name + if evt.slogan: + meta["slogan"] = evt.slogan + if evt.message: + meta["message"] = evt.message + if evt.alert: + meta["alert"] = evt.alert + if evt.latitude: + meta["lat"] = evt.latitude + meta["lon"] = evt.longitude + meta["alt"] = 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) + 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) diff --git a/csdr/module/nrsc5.py b/csdr/module/nrsc5.py new file mode 100644 index 00000000..b9d1b13d --- /dev/null +++ b/csdr/module/nrsc5.py @@ -0,0 +1,617 @@ +import collections +import ctypes +import datetime +import enum +import math +import platform +import socket + + +class Mode(enum.Enum): + FM = 0 + AM = 1 + + +class EventType(enum.Enum): + LOST_DEVICE = 0 + IQ = 1 + SYNC = 2 + LOST_SYNC = 3 + MER = 4 + BER = 5 + HDC = 6 + AUDIO = 7 + ID3 = 8 + SIG = 9 + LOT = 10 + SIS = 11 + STREAM = 12 + PACKET = 13 + + +class ServiceType(enum.Enum): + AUDIO = 0 + DATA = 1 + + +class ComponentType(enum.Enum): + AUDIO = 0 + DATA = 1 + + +class MIMEType(enum.Enum): + PRIMARY_IMAGE = 0xBE4B7536 + STATION_LOGO = 0xD9C72536 + NAVTEQ = 0x2D42AC3E + HERE_TPEG = 0x82F03DFC + HERE_IMAGE = 0xB7F03DFC + HD_TMC = 0xEECB55B6 + HDC = 0x4DC66C5A + TEXT = 0xBB492AAC + JPEG = 0x1E653E9C + PNG = 0x4F328CA0 + TTN_TPEG_1 = 0xB39EBEB2 + TTN_TPEG_2 = 0x4EB03469 + TTN_TPEG_3 = 0x52103469 + TTN_STM_TRAFFIC = 0xFF8422D7 + TTN_STM_WEATHER = 0xEF042E96 + + +class Access(enum.Enum): + PUBLIC = 0 + RESTRICTED = 1 + + +class ServiceDataType(enum.Enum): + NON_SPECIFIC = 0 + NEWS = 1 + SPORTS = 3 + WEATHER = 29 + EMERGENCY = 31 + TRAFFIC = 65 + IMAGE_MAPS = 66 + TEXT = 80 + ADVERTISING = 256 + FINANCIAL = 257 + STOCK_TICKER = 258 + NAVIGATION = 259 + ELECTRONIC_PROGRAM_GUIDE = 260 + AUDIO = 261 + PRIVATE_DATA_NETWORK = 262 + SERVICE_MAINTENANCE = 263 + HD_RADIO_SYSTEM_SERVICES = 264 + AUDIO_RELATED_DATA = 265 + RESERVED_FOR_SPECIAL_TESTS = 511 + + +class ProgramType(enum.Enum): + UNDEFINED = 0 + NEWS = 1 + INFORMATION = 2 + SPORTS = 3 + TALK = 4 + ROCK = 5 + CLASSIC_ROCK = 6 + ADULT_HITS = 7 + SOFT_ROCK = 8 + TOP_40 = 9 + COUNTRY = 10 + OLDIES = 11 + SOFT = 12 + NOSTALGIA = 13 + JAZZ = 14 + CLASSICAL = 15 + RHYTHM_AND_BLUES = 16 + SOFT_RHYTHM_AND_BLUES = 17 + FOREIGN_LANGUAGE = 18 + RELIGIOUS_MUSIC = 19 + RELIGIOUS_TALK = 20 + PERSONALITY = 21 + PUBLIC = 22 + COLLEGE = 23 + SPANISH_TALK = 24 + SPANISH_MUSIC = 25 + HIP_HOP = 26 + WEATHER = 29 + EMERGENCY_TEST = 30 + EMERGENCY = 31 + TRAFFIC = 65 + SPECIAL_READING_SERVICES = 76 + + +IQ = collections.namedtuple("IQ", ["data"]) +MER = collections.namedtuple("MER", ["lower", "upper"]) +BER = collections.namedtuple("BER", ["cber"]) +HDC = collections.namedtuple("HDC", ["program", "data"]) +Audio = collections.namedtuple("Audio", ["program", "data"]) +UFID = collections.namedtuple("UFID", ["owner", "id"]) +XHDR = collections.namedtuple("XHDR", ["mime", "param", "lot"]) +ID3 = collections.namedtuple("ID3", ["program", "title", "artist", "album", "genre", "ufid", "xhdr"]) +SIGAudioComponent = collections.namedtuple("SIGAudioComponent", ["port", "type", "mime"]) +SIGDataComponent = collections.namedtuple("SIGDataComponent", ["port", "service_data_type", "type", "mime"]) +SIGComponent = collections.namedtuple("SIGComponent", ["type", "id", "audio", "data"]) +SIGService = collections.namedtuple("SIGService", ["type", "number", "name", "components"]) +SIG = collections.namedtuple("SIG", ["services"]) +STREAM = collections.namedtuple("STREAM", ["port", "seq", "mime", "data"]) +PACKET = collections.namedtuple("PACKET", ["port", "seq", "mime", "data"]) +LOT = collections.namedtuple("LOT", ["port", "lot", "mime", "name", "data", "expiry_utc"]) +SISAudioService = collections.namedtuple("SISAudioService", ["program", "access", "type", "sound_exp"]) +SISDataService = collections.namedtuple("SISDataService", ["access", "type", "mime_type"]) +SIS = collections.namedtuple("SIS", ["country_code", "fcc_facility_id", "name", "slogan", "message", "alert", + "latitude", "longitude", "altitude", "audio_services", "data_services"]) + + +class _IQ(ctypes.Structure): + _fields_ = [ + ("data", ctypes.POINTER(ctypes.c_char)), + ("count", ctypes.c_size_t), + ] + + +class _MER(ctypes.Structure): + _fields_ = [ + ("lower", ctypes.c_float), + ("upper", ctypes.c_float), + ] + + +class _BER(ctypes.Structure): + _fields_ = [ + ("cber", ctypes.c_float), + ] + + +class _HDC(ctypes.Structure): + _fields_ = [ + ("program", ctypes.c_uint), + ("data", ctypes.POINTER(ctypes.c_char)), + ("count", ctypes.c_size_t), + ] + + +class _Audio(ctypes.Structure): + _fields_ = [ + ("program", ctypes.c_uint), + ("data", ctypes.POINTER(ctypes.c_char)), + ("count", ctypes.c_size_t), + ] + + +class _UFID(ctypes.Structure): + _fields_ = [ + ("owner", ctypes.c_char_p), + ("id", ctypes.c_char_p), + ] + + +class _XHDR(ctypes.Structure): + _fields_ = [ + ("mime", ctypes.c_uint32), + ("param", ctypes.c_int), + ("lot", ctypes.c_int), + ] + + +class _ID3(ctypes.Structure): + _fields_ = [ + ("program", ctypes.c_uint), + ("title", ctypes.c_char_p), + ("artist", ctypes.c_char_p), + ("album", ctypes.c_char_p), + ("genre", ctypes.c_char_p), + ("ufid", _UFID), + ("xhdr", _XHDR), + ] + + +class _SIGData(ctypes.Structure): + _fields_ = [ + ("port", ctypes.c_uint16), + ("service_data_type", ctypes.c_uint16), + ("type", ctypes.c_uint8), + ("mime", ctypes.c_uint32), + ] + + +class _SIGAudio(ctypes.Structure): + _fields_ = [ + ("port", ctypes.c_uint8), + ("type", ctypes.c_uint8), + ("mime", ctypes.c_uint32), + ] + + +class _SIGUnion(ctypes.Union): + _fields_ = [ + ("audio", _SIGAudio), + ("data", _SIGData), + ] + + +class _SIGComponent(ctypes.Structure): + pass + + +_SIGComponent._fields_ = [ + ("next", ctypes.POINTER(_SIGComponent)), + ("type", ctypes.c_uint8), + ("id", ctypes.c_uint8), + ("u", _SIGUnion), +] + + +class _SIGService(ctypes.Structure): + pass + + +_SIGService._fields_ = [ + ("next", ctypes.POINTER(_SIGService)), + ("type", ctypes.c_uint8), + ("number", ctypes.c_uint16), + ("name", ctypes.c_char_p), + ("components", ctypes.POINTER(_SIGComponent)), +] + + +class _SIG(ctypes.Structure): + _fields_ = [ + ("services", ctypes.POINTER(_SIGService)), + ] + + +class _STREAM(ctypes.Structure): + _fields_ = [ + ("port", ctypes.c_uint16), + ("seq", ctypes.c_uint16), + ("size", ctypes.c_uint), + ("mime", ctypes.c_uint32), + ("data", ctypes.POINTER(ctypes.c_char)), + ] + + +class _PACKET(ctypes.Structure): + _fields_ = [ + ("port", ctypes.c_uint16), + ("seq", ctypes.c_uint16), + ("size", ctypes.c_uint), + ("mime", ctypes.c_uint32), + ("data", ctypes.POINTER(ctypes.c_char)), + ] + + +class _TimeStruct(ctypes.Structure): + _fields_ = [ + ("tm_sec", ctypes.c_int), + ("tm_min", ctypes.c_int), + ("tm_hour", ctypes.c_int), + ("tm_mday", ctypes.c_int), + ("tm_mon", ctypes.c_int), + ("tm_year", ctypes.c_int), + ("tm_wday", ctypes.c_int), + ("tm_yday", ctypes.c_int), + ("tm_isdst", ctypes.c_int), + ] + + +class _LOT(ctypes.Structure): + _fields_ = [ + ("port", ctypes.c_uint16), + ("lot", ctypes.c_uint), + ("size", ctypes.c_uint), + ("mime", ctypes.c_uint32), + ("name", ctypes.c_char_p), + ("data", ctypes.POINTER(ctypes.c_char)), + ("expiry_utc", ctypes.POINTER(_TimeStruct)), + ] + + +class _SISAudioService(ctypes.Structure): + pass + + +_SISAudioService._fields_ = [ + ("next", ctypes.POINTER(_SISAudioService)), + ("program", ctypes.c_uint), + ("access", ctypes.c_uint), + ("type", ctypes.c_uint), + ("sound_exp", ctypes.c_uint), +] + + +class _SISDataService(ctypes.Structure): + pass + + +_SISDataService._fields_ = [ + ("next", ctypes.POINTER(_SISDataService)), + ("access", ctypes.c_uint), + ("type", ctypes.c_uint), + ("mime_type", ctypes.c_uint32), +] + + +class _SIS(ctypes.Structure): + _fields_ = [ + ("country_code", ctypes.c_char_p), + ("fcc_facility_id", ctypes.c_int), + ("name", ctypes.c_char_p), + ("slogan", ctypes.c_char_p), + ("message", ctypes.c_char_p), + ("alert", ctypes.c_char_p), + ("latitude", ctypes.c_float), + ("longitude", ctypes.c_float), + ("altitude", ctypes.c_int), + ("audio_services", ctypes.POINTER(_SISAudioService)), + ("data_services", ctypes.POINTER(_SISDataService)), + ] + + +class _EventUnion(ctypes.Union): + _fields_ = [ + ("iq", _IQ), + ("mer", _MER), + ("ber", _BER), + ("hdc", _HDC), + ("audio", _Audio), + ("id3", _ID3), + ("sig", _SIG), + ("stream", _STREAM), + ("packet", _PACKET), + ("lot", _LOT), + ("sis", _SIS), + ] + + +class _Event(ctypes.Structure): + _fields_ = [ + ("event", ctypes.c_uint), + ("u", _EventUnion), + ] + + +class NRSC5Error(Exception): + pass + + +class NRSC5: + libnrsc5 = None + + def _load_library(self): + if NRSC5.libnrsc5 is None: + if platform.system() == "Windows": + lib_name = "libnrsc5.dll" + elif platform.system() == "Linux": + lib_name = "libnrsc5.so" + elif platform.system() == "Darwin": + lib_name = "libnrsc5.dylib" + else: + raise NRSC5Error("Unsupported platform: " + platform.system()) + NRSC5.libnrsc5 = ctypes.cdll.LoadLibrary(lib_name) + self.radio = ctypes.c_void_p() + + @staticmethod + def _decode(string): + if string is None: + return string + return string.decode() + + def _callback_wrapper(self, c_evt): + c_evt = c_evt.contents + evt = None + + try: + evt_type = EventType(c_evt.event) + except ValueError: + return + + if evt_type == EventType.IQ: + iq = c_evt.u.iq + evt = IQ(iq.data[:iq.count]) + elif evt_type == EventType.MER: + mer = c_evt.u.mer + evt = MER(mer.lower, mer.upper) + elif evt_type == EventType.BER: + ber = c_evt.u.ber + evt = BER(ber.cber) + elif evt_type == EventType.HDC: + hdc = c_evt.u.hdc + evt = HDC(hdc.program, hdc.data[:hdc.count]) + elif evt_type == EventType.AUDIO: + audio = c_evt.u.audio + evt = Audio(audio.program, audio.data[:audio.count * 2]) + elif evt_type == EventType.ID3: + id3 = c_evt.u.id3 + + ufid = None + if id3.ufid.owner or id3.ufid.id: + ufid = UFID(self._decode(id3.ufid.owner), self._decode(id3.ufid.id)) + + xhdr = None + if id3.xhdr.mime != 0 or id3.xhdr.param != -1 or id3.xhdr.lot != -1: + xhdr = XHDR(None if id3.xhdr.mime == 0 else MIMEType(id3.xhdr.mime), + None if id3.xhdr.param == -1 else id3.xhdr.param, + None if id3.xhdr.lot == -1 else id3.xhdr.lot) + + evt = ID3(id3.program, self._decode(id3.title), self._decode(id3.artist), + self._decode(id3.album), self._decode(id3.genre), ufid, xhdr) + elif evt_type == EventType.SIG: + evt = [] + service_ptr = c_evt.u.sig.services + while service_ptr: + service = service_ptr.contents + components = [] + component_ptr = service.components + while component_ptr: + component = component_ptr.contents + component_type = ComponentType(component.type) + if component_type == ComponentType.AUDIO: + audio = SIGAudioComponent(component.u.audio.port, ProgramType(component.u.audio.type), + MIMEType(component.u.audio.mime)) + components.append(SIGComponent(component_type, component.id, audio, None)) + if component_type == ComponentType.DATA: + data = SIGDataComponent(component.u.data.port, + ServiceDataType(component.u.data.service_data_type), + component.u.data.type, MIMEType(component.u.data.mime)) + components.append(SIGComponent(component_type, component.id, None, data)) + component_ptr = component.next + evt.append(SIGService(ServiceType(service.type), service.number, + self._decode(service.name), components)) + service_ptr = service.next + elif evt_type == EventType.STREAM: + stream = c_evt.u.stream + evt = STREAM(stream.port, stream.seq, MIMEType(stream.mime), stream.data[:stream.size]) + elif evt_type == EventType.PACKET: + packet = c_evt.u.packet + evt = PACKET(packet.port, packet.seq, MIMEType(packet.mime), packet.data[:packet.size]) + elif evt_type == EventType.LOT: + lot = c_evt.u.lot + expiry_struct = lot.expiry_utc.contents + expiry_time = datetime.datetime( + expiry_struct.tm_year + 1900, + expiry_struct.tm_mon + 1, + expiry_struct.tm_mday, + expiry_struct.tm_hour, + expiry_struct.tm_min, + expiry_struct.tm_sec, + tzinfo=datetime.timezone.utc + ) + evt = LOT(lot.port, lot.lot, MIMEType(lot.mime), self._decode(lot.name), lot.data[:lot.size], expiry_time) + elif evt_type == EventType.SIS: + sis = c_evt.u.sis + + latitude, longitude, altitude = None, None, None + if not math.isnan(sis.latitude): + latitude, longitude, altitude = sis.latitude, sis.longitude, sis.altitude + + audio_services = [] + audio_service_ptr = sis.audio_services + while audio_service_ptr: + asd = audio_service_ptr.contents + audio_services.append(SISAudioService(asd.program, Access(asd.access), + ProgramType(asd.type), asd.sound_exp)) + audio_service_ptr = asd.next + + data_services = [] + data_service_ptr = sis.data_services + while data_service_ptr: + dsd = data_service_ptr.contents + data_services.append(SISDataService(Access(dsd.access), ServiceDataType(dsd.type), dsd.mime_type)) + data_service_ptr = dsd.next + + evt = SIS(self._decode(sis.country_code), sis.fcc_facility_id, self._decode(sis.name), + self._decode(sis.slogan), self._decode(sis.message), self._decode(sis.alert), + latitude, longitude, altitude, audio_services, data_services) + self.callback(evt_type, evt) + + def __init__(self, callback): + self._load_library() + self.radio = ctypes.c_void_p() + self.callback = callback + + @staticmethod + def get_version(): + version = ctypes.c_char_p() + NRSC5.libnrsc5.nrsc5_get_version(ctypes.byref(version)) + return version.value.decode() + + @staticmethod + def service_data_type_name(service_data_type): + name = ctypes.c_char_p() + NRSC5.libnrsc5.nrsc5_service_data_type_name(service_data_type.value, ctypes.byref(name)) + return name.value.decode() + + @staticmethod + def program_type_name(program_type): + name = ctypes.c_char_p() + NRSC5.libnrsc5.nrsc5_program_type_name(program_type.value, ctypes.byref(name)) + return name.value.decode() + + def open(self, device_index): + result = NRSC5.libnrsc5.nrsc5_open(ctypes.byref(self.radio), device_index) + if result != 0: + raise NRSC5Error("Failed to open RTL-SDR.") + self._set_callback() + + def open_pipe(self): + result = NRSC5.libnrsc5.nrsc5_open_pipe(ctypes.byref(self.radio)) + if result != 0: + raise NRSC5Error("Failed to open pipe.") + self._set_callback() + + def open_rtltcp(self, host, port): + s = socket.create_connection((host, port)) + result = NRSC5.libnrsc5.nrsc5_open_rtltcp(ctypes.byref(self.radio), s.detach()) + if result != 0: + raise NRSC5Error("Failed to open rtl_tcp.") + self._set_callback() + + def close(self): + NRSC5.libnrsc5.nrsc5_close(self.radio) + + def start(self): + NRSC5.libnrsc5.nrsc5_start(self.radio) + + def stop(self): + NRSC5.libnrsc5.nrsc5_stop(self.radio) + + def set_mode(self, mode): + NRSC5.libnrsc5.nrsc5_set_mode(self.radio, mode.value) + + def set_bias_tee(self, on): + result = NRSC5.libnrsc5.nrsc5_set_bias_tee(self.radio, on) + if result != 0: + raise NRSC5Error("Failed to set bias-T.") + + def set_direct_sampling(self, on): + result = NRSC5.libnrsc5.nrsc5_set_direct_sampling(self.radio, on) + if result != 0: + raise NRSC5Error("Failed to set direct sampling.") + + def set_freq_correction(self, ppm_error): + result = NRSC5.libnrsc5.nrsc5_set_freq_correction(self.radio, ppm_error) + if result != 0: + raise NRSC5Error("Failed to set frequency correction.") + + def get_frequency(self): + frequency = ctypes.c_float() + NRSC5.libnrsc5.nrsc5_get_frequency(self.radio, ctypes.byref(frequency)) + return frequency.value + + def set_frequency(self, freq): + result = NRSC5.libnrsc5.nrsc5_set_frequency(self.radio, ctypes.c_float(freq)) + if result != 0: + raise NRSC5Error("Failed to set frequency.") + + def get_gain(self): + gain = ctypes.c_float() + NRSC5.libnrsc5.nrsc5_get_gain(self.radio, ctypes.byref(gain)) + return gain.value + + def set_gain(self, gain): + result = NRSC5.libnrsc5.nrsc5_set_gain(self.radio, ctypes.c_float(gain)) + if result != 0: + raise NRSC5Error("Failed to set gain.") + + def set_auto_gain(self, enabled): + NRSC5.libnrsc5.nrsc5_set_auto_gain(self.radio, int(enabled)) + + def _set_callback(self): + def callback_closure(evt, opaque): + self._callback_wrapper(evt) + + self.callback_func = ctypes.CFUNCTYPE(None, ctypes.POINTER(_Event), ctypes.c_void_p)(callback_closure) + NRSC5.libnrsc5.nrsc5_set_callback(self.radio, self.callback_func, None) + + def pipe_samples_cu8(self, samples): + if len(samples) % 4 != 0: + raise NRSC5Error("len(samples) must be a multiple of 4.") + result = NRSC5.libnrsc5.nrsc5_pipe_samples_cu8(self.radio, samples, len(samples)) + if result != 0: + raise NRSC5Error("Failed to pipe samples.") + + def pipe_samples_cs16(self, samples): + if len(samples) % 4 != 0: + raise NRSC5Error("len(samples) must be a multiple of 4.") + result = NRSC5.libnrsc5.nrsc5_pipe_samples_cs16(self.radio, samples, len(samples) // 2) + if result != 0: + raise NRSC5Error("Failed to pipe samples.") diff --git a/owrx/dsp.py b/owrx/dsp.py index 625254ea..c8c6fe5c 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -573,6 +573,18 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) elif demod == "nxdn": from csdr.chain.digiham import Nxdn return Nxdn(self.props["digital_voice_codecserver"]) + elif demod == "hdr1": + 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) elif demod == "m17": from csdr.chain.m17 import M17 return M17() diff --git a/owrx/feature.py b/owrx/feature.py index f5b76aeb..baefae23 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -92,6 +92,7 @@ class FeatureDetector(object): "redsea": ["redsea"], "dab": ["csdreti", "dablin"], "mqtt": ["paho_mqtt"], + "hdradio": ["nrsc5"], } def feature_availability(self): @@ -694,3 +695,11 @@ class FeatureDetector(object): return True except ImportError: return False + + def has_nrsc5(self): + """ + OpenWebRX uses the [nrsc5](https://github.com/theori-io/nrsc5) tool to decode HDRadio + FM broadcasts. Nrsc5 is not yet available as a package and thus you will have + to compile it from source. + """ + return self.command_is_runnable("nrsc5 -v") diff --git a/owrx/modes.py b/owrx/modes.py index f5ea047e..a65b386e 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -135,6 +135,10 @@ class Modes(object): ), 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), DigitalMode("bpsk31", "BPSK31", underlying=["usb"]), DigitalMode("bpsk63", "BPSK63", underlying=["usb"]), DigitalMode("rtty170", "RTTY 45/170", underlying=["usb", "lsb"]),