This commit is contained in:
Luarvique L. Luarvique 2025-01-31 07:30:46 -05:00 committed by GitHub
commit 0ebce00a27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1078 additions and 19 deletions

View file

@ -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)

View file

@ -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

50
csdr/chain/hdradio.py Normal file
View file

@ -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)

259
csdr/module/hdradio.py Normal file
View file

@ -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)

617
csdr/module/nrsc5.py Normal file
View file

@ -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.")

View file

@ -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;
}

View file

@ -153,6 +153,7 @@
</div>
</div>
<div class="openwebrx-panel openwebrx-meta-panel disabled" id="openwebrx-panel-metadata-wfm" style="display: none;" data-panel-name="metadata-wfm"></div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-hdr" style="display: none;" data-panel-name="metadata-hdr"></div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-dab" style="display: none;" data-panel-name="metadata-dab"></div>
<div class="openwebrx-panel" id="openwebrx-panel-log" data-panel-name="debug" style="width: 619px;">
<div class="openwebrx-panel-inner nano" id="openwebrx-log-scroll">

View file

@ -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();
}

View file

@ -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 = $(
'<div class="hdr-container">' +
'<div class="hdr-top-line">' +
'<select id="hdr-program-id" class="hdr-selector"></select>' +
'<span class="hdr-identifier"></span>' +
'</div>' +
'<div class="hdr-station"></div>' +
'<div class="hdr-message"></div>' +
'<div class="hdr-title"></div>' +
'<div class="hdr-artist"></div>' +
'<div class="hdr-album"></div>' +
'<div class="hdr-bottom-line">' +
'<span class="hdr-genre"></span>' +
'</div>' +
'</div>'
);
$(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 '<option value="' + pgm.id + '"' + selected + '>P' +
(pgm.id + 1) + ' - ' + pgm.name + '</option>';
}).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 = $('<select id="dab-service-id"></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 = $(
'<div class="dab-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() {

View file

@ -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,6 +573,9 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
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()

View file

@ -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 @@ 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")

View file

@ -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"]),