diff --git a/csdr/chain/dablin.py b/csdr/chain/dablin.py index c5a321167..2f6a27940 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 @@ def resetShift(self): 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 @@ def stop(self): 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 7b4506ccd..40a6c0156 100644 --- a/csdr/chain/demodulator.py +++ b/csdr/chain/demodulator.py @@ -55,9 +55,9 @@ def setRdsRbds(self, rdsRbds: bool) -> None: 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 new file mode 100644 index 000000000..7584bc89a --- /dev/null +++ b/csdr/chain/hdradio.py @@ -0,0 +1,50 @@ +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, 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, 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) -> int: + return self.hdradio.getFixedAudioRate() + + 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 setAudioServiceId(self, serviceId: int) -> None: + self.hdradio.setProgram(serviceId) + + def setDialFrequency(self, frequency: int) -> None: + 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 new file mode 100644 index 000000000..1aa08ef08 --- /dev/null +++ b/csdr/module/hdradio.py @@ -0,0 +1,259 @@ +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.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 + + def getOutputFormat(self) -> Format: + return Format.SHORT + + def getFixedAudioRate(self) -> int: + return 744188 # 744187.5 + + # Change program + def setProgram(self, program: int) -> None: + 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) -> 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...") + + # Main loop + logger.debug("Running the loop...") + while self.doRun: + data = self.reader.read() + if data is None or len(data) == 0: + self.doRun = False + 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.LOST_DEVICE: + logger.info("Lost device") + self.doRun = False + elif evt_type == EventType.AUDIO: + if evt.program == self.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.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 new 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.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) + # 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", + 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 new metadata + meta = { + "audio_services" : [], + "data_services" : [] + } + 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["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) + 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) + 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/csdr/module/nrsc5.py b/csdr/module/nrsc5.py new file mode 100644 index 000000000..b9d1b13d8 --- /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/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index fae763788..dde1487a5 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 1e1c4c438..be713261d 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -153,6 +153,7 @@

Under construction

+
diff --git a/htdocs/lib/Demodulator.js b/htdocs/lib/Demodulator.js index 601bc86bf..1a1a61d70 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 584fadf6e..d6c80af9b 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 625254ea6..641cdd2bf 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -5,7 +5,7 @@ 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 @@ def setSlotFilter(self, filter: int) -> None: 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 @@ def __init__(self, handler, sdrSource): "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 @@ def __init__(self, handler, sdrSource): 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,6 +573,9 @@ def _getDemodulator(self, demod: Union[str, BaseDemodulatorChain]) -> Optional[B elif demod == "nxdn": from csdr.chain.digiham import Nxdn return Nxdn(self.props["digital_voice_codecserver"]) + elif demod == "hdr": + from csdr.chain.hdradio import HdRadio + return HdRadio() elif demod == "m17": from csdr.chain.m17 import M17 return M17() diff --git a/owrx/feature.py b/owrx/feature.py index e458a9622..62141a1fc 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): @@ -715,3 +716,11 @@ def has_paho_mqtt(self): 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 f5ea047ef..61123775b 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -134,7 +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("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"]),